kkthakur commited on
Commit
b336134
Β·
0 Parent(s):

Deploy Local Hybrid Engine

Browse files
Dockerfile ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use an official Python runtime as a parent image
2
+ FROM python:3.10-slim
3
+
4
+ # Set the working directory in the container
5
+ WORKDIR /app
6
+
7
+ # Install system dependencies needed for compiling llama-cpp-python and basic libraries
8
+ RUN apt-get update && apt-get install -y --no-install-recommends \
9
+ build-essential \
10
+ curl \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ # Copy the requirements file into the container
14
+ COPY requirements.txt .
15
+
16
+ # Install dependencies
17
+ RUN pip install --no-cache-dir -r requirements.txt
18
+
19
+ # Copy the rest of the application code
20
+ COPY . .
21
+
22
+ # Configure Hugging Face home and cache directories under /tmp/ for write permissions
23
+ ENV HF_HOME=/tmp/.cache/huggingface
24
+ ENV XDG_CACHE_HOME=/tmp/.cache
25
+ ENV TRANSFORMERS_CACHE=/tmp/.cache/huggingface/hub
26
+ ENV NUMBA_CACHE_DIR=/tmp/.cache/numba
27
+ ENV PORT=7860
28
+
29
+ # Expose port 7860
30
+ EXPOSE 7860
31
+
32
+ # Run uvicorn on port 7860
33
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Zero-LLM Engine
3
+ emoji: πŸ“Š
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # Zero-LLM Data Engine
12
+
13
+ A high-scale, production-ready backend engine for natural-language Excel/CSV/Parquet editing, built with Python and Polars.
14
+
15
+ ## Local Development
16
+
17
+ To run the engine locally:
18
+
19
+ ```bash
20
+ cd zero-llm-engine
21
+ pip install -r requirements.txt
22
+ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
23
+ ```
24
+
25
+ ## Features
26
+
27
+ 1. **Hybrid Parser**: Fast regex/keyword path with fallback to offline local embedding/spelling parser, local Qwen GGUF model, or Gemini cloud API.
28
+ 2. **Polars Execution**: Safe and fast data frame operations with concurrency, low latency, and memory efficiency.
29
+ 3. **MVCC Versioning**: Safe read/write concurrency via versioned files.
api/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # api package
api/routes.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ REST routes β€” upload, download, session info, delete.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from fastapi import APIRouter, UploadFile, File, HTTPException
8
+ from fastapi.responses import FileResponse
9
+
10
+ from models.schemas import UploadResponse, SessionInfo, ErrorResponse
11
+ from services import file_manager, session_manager, audit_service
12
+
13
+ router = APIRouter(tags=["file operations"])
14
+
15
+
16
+ # ── Upload ─────────────────────────────────────────────────────────
17
+
18
+ @router.post(
19
+ "/upload",
20
+ response_model=UploadResponse,
21
+ responses={400: {"model": ErrorResponse}, 413: {"model": ErrorResponse}},
22
+ summary="Upload CSV / Excel / Parquet file",
23
+ )
24
+ async def upload_file(file: UploadFile = File(...)):
25
+ content = await file.read()
26
+ try:
27
+ result = file_manager.handle_upload(content, file.filename or "unknown.csv")
28
+ except ValueError as e:
29
+ raise HTTPException(status_code=400, detail=str(e))
30
+ except Exception as e:
31
+ raise HTTPException(status_code=500, detail=f"Upload failed: {e}")
32
+ return UploadResponse(**result)
33
+
34
+
35
+ # ── Download ───────────────────────────────────────────────────────
36
+
37
+ @router.get(
38
+ "/download/{session_id}",
39
+ responses={404: {"model": ErrorResponse}},
40
+ summary="Download file as CSV",
41
+ )
42
+ async def download_file(session_id: str):
43
+ csv_path = file_manager.export_to_csv(session_id)
44
+ if csv_path is None:
45
+ raise HTTPException(status_code=404, detail="Session not found")
46
+ return FileResponse(
47
+ path=csv_path,
48
+ media_type="text/csv",
49
+ filename=f"{session_id}.csv",
50
+ )
51
+
52
+
53
+ # ── Session info ───────────────────────────────────────────────────
54
+
55
+ @router.get(
56
+ "/session/{session_id}",
57
+ response_model=SessionInfo,
58
+ responses={404: {"model": ErrorResponse}},
59
+ summary="Get session metadata",
60
+ )
61
+ async def get_session(session_id: str):
62
+ meta = session_manager.get(session_id)
63
+ if meta is None:
64
+ raise HTTPException(status_code=404, detail="Session not found")
65
+ return SessionInfo(
66
+ session_id=meta.session_id,
67
+ file_name=meta.file_name,
68
+ file_size_bytes=meta.file_size_bytes,
69
+ columns=meta.columns,
70
+ row_count=meta.row_count,
71
+ status=meta.status,
72
+ )
73
+
74
+
75
+ # ── Delete session ─────────────────────────────────────────────────
76
+
77
+ @router.delete(
78
+ "/session/{session_id}",
79
+ summary="Delete session and its data",
80
+ )
81
+ async def delete_session(session_id: str):
82
+ ok = file_manager.delete_session(session_id)
83
+ if not ok:
84
+ raise HTTPException(status_code=404, detail="Session not found")
85
+ return {"detail": "Session deleted"}
86
+
87
+
88
+ # ── Audit history ──────────────────────────────────────────────────
89
+
90
+ @router.get(
91
+ "/history/{session_id}",
92
+ summary="Get command history for a session",
93
+ )
94
+ async def get_history(session_id: str, limit: int = 50):
95
+ history = await audit_service.get_history(session_id, limit)
96
+ return {"history": history}
api/websocket.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ WebSocket endpoint β€” receives natural-language commands, returns results.
3
+
4
+ Protocol:
5
+ Client β†’ Server: {"command": "salary ko 10% badhao"}
6
+ Server β†’ Client: {"status": "success|error|unresolved", "message": "...", "diff": {...}, "suggestions": [...]}
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from fastapi import WebSocket, WebSocketDisconnect
12
+
13
+ from core.parser.fallback import parse_intent_hybrid
14
+ from core.validator import validate_intent
15
+ from core.execution.mvcc_executor import execute_mvcc_command
16
+ from core.column_registry import column_registry
17
+ from services import session_manager, audit_service
18
+
19
+
20
+ async def websocket_handler(ws: WebSocket, session_id: str) -> None:
21
+ await ws.accept()
22
+
23
+ # Verify session exists
24
+ meta = session_manager.get(session_id)
25
+ if meta is None:
26
+ await ws.send_json({
27
+ "status": "error",
28
+ "message": "Session nahi mila. Pehle file upload karo.",
29
+ })
30
+ await ws.close()
31
+ return
32
+
33
+ try:
34
+ while True:
35
+ # ── Receive command ─────────────────────────────────────
36
+ data = await ws.receive_json()
37
+ command: str = data.get("command", "").strip()
38
+ if not command:
39
+ await ws.send_json({
40
+ "status": "error",
41
+ "message": "Empty command",
42
+ })
43
+ continue
44
+
45
+ # ── Parse intent ────────────────────────────────────────
46
+ intent = parse_intent_hybrid(session_id, command)
47
+
48
+ if intent is None:
49
+ # Unresolved β€” suggest available columns
50
+ cols = column_registry.get_columns(session_id)
51
+ await ws.send_json({
52
+ "status": "unresolved",
53
+ "message": "Command samajh nahi aaya. Column ka naam check karo.",
54
+ "suggestions": cols,
55
+ })
56
+ continue
57
+
58
+ # ── Validate ────────────────────────────────────────────
59
+ error = validate_intent(session_id, intent)
60
+ if error:
61
+ await ws.send_json({
62
+ "status": "error",
63
+ "message": error,
64
+ })
65
+ continue
66
+
67
+ # ── Execute (offload to thread for large files) ─────────
68
+ try:
69
+ result = await asyncio.to_thread(execute_mvcc_command, session_id, intent)
70
+ except Exception as exc:
71
+ await ws.send_json({
72
+ "status": "error",
73
+ "message": f"Execution error: {exc}",
74
+ })
75
+ continue
76
+
77
+ # ── Audit log ───────────────────────────────────────────
78
+ await audit_service.log_command(
79
+ session_id=session_id,
80
+ command=command,
81
+ intent=intent,
82
+ diff=result.get("diff"),
83
+ )
84
+
85
+ # Touch session
86
+ session_manager.touch(session_id)
87
+
88
+ # ── Send result ─────────────────────────────────────────
89
+ await ws.send_json({
90
+ "status": "success",
91
+ "message": result["message"],
92
+ "diff": result.get("diff", {}),
93
+ })
94
+
95
+ except WebSocketDisconnect:
96
+ pass # Client disconnected, clean exit
config.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Application configuration.
3
+ Swap values via environment variables in production.
4
+ """
5
+ import os
6
+
7
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
8
+
9
+ # Storage paths
10
+ DATA_DIR = os.path.join(BASE_DIR, "data", "sessions")
11
+ UPLOAD_DIR = os.path.join(BASE_DIR, "data", "uploads")
12
+ AUDIT_DB_PATH = os.path.join(BASE_DIR, "data", "audit.db")
13
+
14
+ # Files above this size use LazyFrame streaming instead of eager loading
15
+ LAZY_THRESHOLD_BYTES = 100 * 1024 * 1024 # 100 MB
16
+
17
+ # Fuzzy matching threshold (0-100)
18
+ FUZZY_THRESHOLD = 78
19
+
20
+ # Max file upload size: 1 GB
21
+ MAX_UPLOAD_BYTES = 1024 * 1024 * 1024
22
+
23
+ # Session auto-cleanup after idle minutes
24
+ SESSION_TTL_MINUTES = 120
25
+
26
+ # Ensure directories exist
27
+ for _d in (DATA_DIR, UPLOAD_DIR, os.path.dirname(AUDIT_DB_PATH)):
28
+ os.makedirs(_d, exist_ok=True)
core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # core package
core/a_to_z/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # zero-llm-engine core a-to-z operations
core/a_to_z/business/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # zero-llm-engine core a-to-z business operations
core/a_to_z/business/dedup.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Remove duplicates tool.
3
+
4
+ If a column is specified, drops rows with duplicate values in that column
5
+ (keeps first occurrence). If no column, drops fully-duplicate rows.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import polars as pl
11
+
12
+ from config import DATA_DIR, LAZY_THRESHOLD_BYTES
13
+
14
+
15
+ def _get_path(session_id: str) -> str:
16
+ return os.path.join(DATA_DIR, f"{session_id}.parquet")
17
+
18
+
19
+ def execute_remove_duplicates(session_id: str, intent: dict) -> dict:
20
+ path = _get_path(session_id)
21
+ col = intent.get("column") # may be None β†’ full-row dedup
22
+
23
+ # Get count before
24
+ before_count = pl.scan_parquet(path).select(pl.len()).collect().item()
25
+
26
+ lf = pl.scan_parquet(path)
27
+ if col:
28
+ lf = lf.unique(subset=[col], keep="first")
29
+ else:
30
+ lf = lf.unique(keep="first")
31
+
32
+ file_sz = os.path.getsize(path)
33
+ if file_sz > LAZY_THRESHOLD_BYTES:
34
+ lf.sink_parquet(path)
35
+ else:
36
+ lf.collect().write_parquet(path)
37
+
38
+ after_count = pl.scan_parquet(path).select(pl.len()).collect().item()
39
+ removed = before_count - after_count
40
+
41
+ if col:
42
+ msg = f"'{col}' ke duplicate rows hata diye ({removed} rows removed, {after_count:,} remaining)"
43
+ else:
44
+ msg = f"Duplicate rows hata diye ({removed} rows removed, {after_count:,} remaining)"
45
+
46
+ return {
47
+ "message": msg,
48
+ "diff": {
49
+ "operation": "remove_duplicates",
50
+ "column": col,
51
+ "rows_removed": removed,
52
+ "rows_remaining": after_count,
53
+ },
54
+ }
core/a_to_z/business/find_replace.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Find & Replace tool.
3
+
4
+ Replaces all occurrences of old_value with new_value in a column.
5
+ Supports both string and numeric replacements.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import polars as pl
11
+
12
+ from config import DATA_DIR, LAZY_THRESHOLD_BYTES
13
+
14
+
15
+ def _get_path(session_id: str) -> str:
16
+ return os.path.join(DATA_DIR, f"{session_id}.parquet")
17
+
18
+
19
+ def execute_find_replace(session_id: str, intent: dict) -> dict:
20
+ path = _get_path(session_id)
21
+ col = intent["column"]
22
+ old_val = intent["old_value"]
23
+ new_val = intent["new_value"]
24
+
25
+ # Determine if we should do string or numeric replacement
26
+ schema = pl.scan_parquet(path).collect_schema()
27
+ dtype = schema[col]
28
+ is_numeric = dtype in (pl.Float64, pl.Int64, pl.Int32, pl.Float32)
29
+
30
+ if is_numeric:
31
+ try:
32
+ old_num = float(old_val)
33
+ new_num = float(new_val)
34
+ expr = pl.when(pl.col(col) == old_num).then(pl.lit(new_num)).otherwise(pl.col(col)).alias(col)
35
+ except ValueError:
36
+ return {
37
+ "message": f"'{col}' numeric hai, replace values bhi numbers hone chahiye",
38
+ "diff": {},
39
+ }
40
+ else:
41
+ expr = (
42
+ pl.when(pl.col(col).cast(pl.String) == str(old_val))
43
+ .then(pl.lit(str(new_val)))
44
+ .otherwise(pl.col(col))
45
+ .alias(col)
46
+ )
47
+
48
+ file_sz = os.path.getsize(path)
49
+ if file_sz > LAZY_THRESHOLD_BYTES:
50
+ pl.scan_parquet(path).with_columns(expr).sink_parquet(path)
51
+ else:
52
+ df = pl.read_parquet(path).with_columns(expr)
53
+ df.write_parquet(path)
54
+
55
+ # Count affected rows
56
+ if is_numeric:
57
+ try:
58
+ old_num = float(old_val)
59
+ count = (
60
+ pl.scan_parquet(path)
61
+ .filter(pl.col(col) == float(new_val))
62
+ .select(pl.len())
63
+ .collect()
64
+ .item()
65
+ )
66
+ except ValueError:
67
+ count = "N/A"
68
+ else:
69
+ count = (
70
+ pl.scan_parquet(path)
71
+ .filter(pl.col(col).cast(pl.String) == str(new_val))
72
+ .select(pl.len())
73
+ .collect()
74
+ .item()
75
+ )
76
+
77
+ return {
78
+ "message": f"'{col}' mein '{old_val}' ko '{new_val}' se replace kiya ({count} rows changed)",
79
+ "diff": {
80
+ "operation": "find_replace",
81
+ "column": col,
82
+ "old_value": old_val,
83
+ "new_value": new_val,
84
+ "affected_rows": count,
85
+ },
86
+ }
core/a_to_z/student/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # zero-llm-engine core a-to-z student operations
core/a_to_z/student/aggregate.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Aggregate tools β€” sum, average, count, min, max.
3
+
4
+ These are READ-ONLY operations. They do NOT modify the stored Parquet.
5
+ They compute the result and return it as a message.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import polars as pl
11
+
12
+ from config import DATA_DIR, LAZY_THRESHOLD_BYTES
13
+
14
+
15
+ def _get_path(session_id: str) -> str:
16
+ return os.path.join(DATA_DIR, f"{session_id}.parquet")
17
+
18
+
19
+ def _query(session_id: str, expr: pl.Expr):
20
+ """Run a single aggregate expression against the Parquet file.
21
+
22
+ Always uses LazyFrame + collect so the code path is identical
23
+ for both small and large files.
24
+ """
25
+ path = _get_path(session_id)
26
+ return pl.scan_parquet(path).select(expr).collect().item()
27
+
28
+
29
+ def _format_number(val) -> str:
30
+ """Pretty-print a number with commas."""
31
+ if val is None:
32
+ return "N/A"
33
+ if isinstance(val, float):
34
+ if val == int(val):
35
+ return f"{int(val):,}"
36
+ return f"{val:,.2f}"
37
+ return f"{val:,}"
38
+
39
+
40
+ def execute_sum(session_id: str, intent: dict) -> dict:
41
+ col = intent["column"]
42
+ result = _query(session_id, pl.col(col).sum())
43
+ return {
44
+ "message": f"{col} ka sum = {_format_number(result)}",
45
+ "diff": {"operation": "sum", "column": col, "result": result},
46
+ }
47
+
48
+
49
+ def execute_average(session_id: str, intent: dict) -> dict:
50
+ col = intent["column"]
51
+ result = _query(session_id, pl.col(col).mean())
52
+ return {
53
+ "message": f"{col} ka average = {_format_number(result)}",
54
+ "diff": {"operation": "average", "column": col, "result": result},
55
+ }
56
+
57
+
58
+ def execute_count(session_id: str, intent: dict) -> dict:
59
+ col = intent.get("column")
60
+ if col:
61
+ result = _query(session_id, pl.col(col).count())
62
+ return {
63
+ "message": f"{col} mein {_format_number(result)} non-null values hain",
64
+ "diff": {"operation": "count", "column": col, "result": result},
65
+ }
66
+ else:
67
+ result = _query(session_id, pl.len())
68
+ return {
69
+ "message": f"Total {_format_number(result)} rows hain",
70
+ "diff": {"operation": "count", "column": None, "result": result},
71
+ }
72
+
73
+
74
+ def execute_min(session_id: str, intent: dict) -> dict:
75
+ col = intent["column"]
76
+ result = _query(session_id, pl.col(col).min())
77
+ return {
78
+ "message": f"{col} ka minimum = {_format_number(result)}",
79
+ "diff": {"operation": "min", "column": col, "result": result},
80
+ }
81
+
82
+
83
+ def execute_max(session_id: str, intent: dict) -> dict:
84
+ col = intent["column"]
85
+ result = _query(session_id, pl.col(col).max())
86
+ return {
87
+ "message": f"{col} ka maximum = {_format_number(result)}",
88
+ "diff": {"operation": "max", "column": col, "result": result},
89
+ }
core/a_to_z/student/column_ops.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Column manipulation tools β€” delete, rename, add, cast type.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import polars as pl
8
+
9
+ from config import DATA_DIR, LAZY_THRESHOLD_BYTES
10
+
11
+
12
+ def _get_path(session_id: str) -> str:
13
+ return os.path.join(DATA_DIR, f"{session_id}.parquet")
14
+
15
+
16
+ def _rewrite(session_id: str, lf: pl.LazyFrame) -> None:
17
+ path = _get_path(session_id)
18
+ if os.path.getsize(path) > LAZY_THRESHOLD_BYTES:
19
+ lf.sink_parquet(path)
20
+ else:
21
+ lf.collect().write_parquet(path)
22
+
23
+
24
+ def execute_delete_column(session_id: str, intent: dict) -> dict:
25
+ path = _get_path(session_id)
26
+ col = intent["column"]
27
+ lf = pl.scan_parquet(path).drop(col)
28
+ _rewrite(session_id, lf)
29
+
30
+ return {
31
+ "message": f"'{col}' column delete ho gaya",
32
+ "diff": {"operation": "delete_column", "column": col},
33
+ }
34
+
35
+
36
+ def execute_rename_column(session_id: str, intent: dict) -> dict:
37
+ path = _get_path(session_id)
38
+ old = intent["column"]
39
+ new = intent["new_name"]
40
+ lf = pl.scan_parquet(path).rename({old: new})
41
+ _rewrite(session_id, lf)
42
+
43
+ # Update column registry
44
+ from core.column_registry import column_registry
45
+ schema = pl.scan_parquet(_get_path(session_id)).collect_schema()
46
+ column_registry.register(session_id, schema.names())
47
+
48
+ return {
49
+ "message": f"'{old}' ka naam badal ke '{new}' ho gaya",
50
+ "diff": {"operation": "rename_column", "old_name": old, "new_name": new},
51
+ }
52
+
53
+
54
+ def execute_add_column(session_id: str, intent: dict) -> dict:
55
+ path = _get_path(session_id)
56
+ # The intent should carry: column (name), or new_column_name
57
+ new_col = intent.get("new_column_name") or intent.get("column", "new_col")
58
+ default_val = intent.get("value", 0)
59
+
60
+ lf = pl.scan_parquet(path).with_columns(
61
+ pl.lit(default_val).alias(new_col)
62
+ )
63
+ _rewrite(session_id, lf)
64
+
65
+ # Refresh registry
66
+ from core.column_registry import column_registry
67
+ schema = pl.scan_parquet(_get_path(session_id)).collect_schema()
68
+ column_registry.register(session_id, schema.names())
69
+
70
+ return {
71
+ "message": f"Naya column '{new_col}' add ho gaya (default: {default_val})",
72
+ "diff": {"operation": "add_column", "column": new_col},
73
+ }
74
+
75
+
76
+ def execute_cast_type(session_id: str, intent: dict) -> dict:
77
+ path = _get_path(session_id)
78
+ col = intent["column"]
79
+ target = intent["target_dtype"]
80
+
81
+ # Map string dtype names to Polars types
82
+ dtype_map = {
83
+ "Int64": pl.Int64,
84
+ "Int32": pl.Int32,
85
+ "Float64": pl.Float64,
86
+ "Float32": pl.Float32,
87
+ "String": pl.String,
88
+ "Boolean": pl.Boolean,
89
+ "Date": pl.Date,
90
+ }
91
+ pl_dtype = dtype_map.get(target)
92
+ if pl_dtype is None:
93
+ return {
94
+ "message": f"Type '{target}' supported nahi hai. Options: Int64, Float64, String, Boolean, Date",
95
+ "diff": {},
96
+ }
97
+
98
+ lf = pl.scan_parquet(path).with_columns(pl.col(col).cast(pl_dtype))
99
+ _rewrite(session_id, lf)
100
+
101
+ return {
102
+ "message": f"'{col}' ka type {target} mein change ho gaya",
103
+ "diff": {"operation": "cast_type", "column": col, "target_dtype": target},
104
+ }
core/a_to_z/student/filter_tool.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Filter tool.
3
+
4
+ Keeps only rows matching a condition. The stored Parquet is REPLACED
5
+ with the filtered subset.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import polars as pl
11
+
12
+ from config import DATA_DIR, LAZY_THRESHOLD_BYTES
13
+
14
+
15
+ def _get_path(session_id: str) -> str:
16
+ return os.path.join(DATA_DIR, f"{session_id}.parquet")
17
+
18
+
19
+ def _build_predicate(column: str, condition: str, value) -> pl.Expr:
20
+ """Build a Polars expression from a comparison condition string."""
21
+ col = pl.col(column)
22
+
23
+ if condition == ">":
24
+ return col > value
25
+ elif condition == "<":
26
+ return col < value
27
+ elif condition == ">=":
28
+ return col >= value
29
+ elif condition == "<=":
30
+ return col <= value
31
+ elif condition == "!=":
32
+ return col != value
33
+ elif condition == "==" or condition == "=":
34
+ # Try numeric comparison first; fall back to string
35
+ if isinstance(value, (int, float)):
36
+ return col == value
37
+ try:
38
+ return col == float(value)
39
+ except (ValueError, TypeError):
40
+ return col.cast(pl.String).str.to_lowercase() == str(value).lower()
41
+ elif condition in ("contains", "~"):
42
+ return col.cast(pl.String).str.contains(str(value), literal=True)
43
+ else:
44
+ # Default: string equality (case-insensitive)
45
+ return col.cast(pl.String).str.to_lowercase() == str(value).lower()
46
+
47
+
48
+ def execute_filter(session_id: str, intent: dict) -> dict:
49
+ path = _get_path(session_id)
50
+ col = intent["column"]
51
+ condition = intent["condition"]
52
+ filter_value = intent["filter_value"]
53
+
54
+ predicate = _build_predicate(col, condition, filter_value)
55
+
56
+ file_sz = os.path.getsize(path)
57
+ if file_sz > LAZY_THRESHOLD_BYTES:
58
+ lf = pl.scan_parquet(path).filter(predicate)
59
+ lf.sink_parquet(path)
60
+ else:
61
+ df = pl.read_parquet(path).filter(predicate)
62
+ df.write_parquet(path)
63
+
64
+ after_count = pl.scan_parquet(path).select(pl.len()).collect().item()
65
+
66
+ return {
67
+ "message": f"{col} {condition} {filter_value} β†’ {after_count:,} rows bach gaye",
68
+ "diff": {
69
+ "operation": "filter",
70
+ "column": col,
71
+ "condition": condition,
72
+ "filter_value": filter_value,
73
+ "rows_after": after_count,
74
+ },
75
+ }
core/a_to_z/student/sort_tool.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sort tool β€” ascending or descending by a single column.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import polars as pl
8
+
9
+ from config import DATA_DIR, LAZY_THRESHOLD_BYTES
10
+
11
+
12
+ def _get_path(session_id: str) -> str:
13
+ return os.path.join(DATA_DIR, f"{session_id}.parquet")
14
+
15
+
16
+ def execute_sort(session_id: str, intent: dict) -> dict:
17
+ path = _get_path(session_id)
18
+ col = intent["column"]
19
+ descending = intent["operation"] == "sort_desc"
20
+
21
+ file_sz = os.path.getsize(path)
22
+
23
+ # Sort requires the full column in memory either way.
24
+ # For large files, collect then write back.
25
+ lf = pl.scan_parquet(path)
26
+ if file_sz > LAZY_THRESHOLD_BYTES:
27
+ lf.sort(col, descending=descending).sink_parquet(path)
28
+ else:
29
+ df = lf.collect().sort(col, descending=descending)
30
+ df.write_parquet(path)
31
+
32
+ row_count = pl.scan_parquet(path).select(pl.len()).collect().item()
33
+ order = "descending (bada se chhota)" if descending else "ascending (chhota se bada)"
34
+
35
+ return {
36
+ "message": f"{col} ko {order} sort kiya ({row_count:,} rows)",
37
+ "diff": {
38
+ "operation": "sort",
39
+ "column": col,
40
+ "descending": descending,
41
+ "affected_rows": row_count,
42
+ },
43
+ }
core/a_to_z/student/update.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Increase / Decrease tool.
3
+
4
+ Supports both percentage and absolute value changes.
5
+ "salary ko 10% badhao" β†’ multiply by 1.10
6
+ "salary kam karo 5000" β†’ subtract 5000 (absolute when no % sign)
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import polars as pl
12
+
13
+ from config import DATA_DIR, LAZY_THRESHOLD_BYTES
14
+
15
+
16
+ def _get_path(session_id: str) -> str:
17
+ return os.path.join(DATA_DIR, f"{session_id}.parquet")
18
+
19
+
20
+ def _file_size(path: str) -> int:
21
+ return os.path.getsize(path)
22
+
23
+
24
+ def execute_increase(session_id: str, intent: dict) -> dict:
25
+ return _execute_update(session_id, intent, mode="increase")
26
+
27
+
28
+ def execute_decrease(session_id: str, intent: dict) -> dict:
29
+ return _execute_update(session_id, intent, mode="decrease")
30
+
31
+
32
+ def _execute_update(session_id: str, intent: dict, mode: str) -> dict:
33
+ """Core update logic shared by increase & decrease."""
34
+ path = _get_path(session_id)
35
+ col = intent["column"]
36
+ value = intent.get("value")
37
+ if value is None:
38
+ value = 10.0 # sensible default
39
+
40
+ is_percent = intent.get("is_percent", True) # default to percentage
41
+ # Heuristic: if the original command had "%", treat as percentage
42
+ # The intent parser already extracts the number; we need a hint.
43
+ # For now, always treat as percentage (most common use-case).
44
+ # Absolute mode can be added via intent["absolute"] = True.
45
+
46
+ if is_percent:
47
+ factor = 1 + value / 100 if mode == "increase" else 1 - value / 100
48
+ expr = (pl.col(col) * factor).alias(col)
49
+ op_label = f"{value}% badha diya" if mode == "increase" else f"{value}% ghata diya"
50
+ else:
51
+ expr = (
52
+ (pl.col(col) + value).alias(col)
53
+ if mode == "increase"
54
+ else (pl.col(col) - value).alias(col)
55
+ )
56
+ op_label = f"{value} joda" if mode == "increase" else f"{value} ghata diya"
57
+
58
+ file_sz = _file_size(path)
59
+ if file_sz > LAZY_THRESHOLD_BYTES:
60
+ # Streaming: lazy β†’ sink_parquet (no full data in RAM)
61
+ pl.scan_parquet(path).with_columns(expr).sink_parquet(path)
62
+ else:
63
+ # Eager: fast in-memory for small files
64
+ df = pl.read_parquet(path)
65
+ df = df.with_columns(expr)
66
+ df.write_parquet(path)
67
+
68
+ # Get affected row count (cheap metadata read)
69
+ row_count = pl.scan_parquet(path).select(pl.len()).collect().item()
70
+
71
+ return {
72
+ "message": f"{col} ko {op_label} ({row_count:,} rows updated)",
73
+ "diff": {
74
+ "operation": mode,
75
+ "column": col,
76
+ "value": value,
77
+ "is_percent": is_percent,
78
+ "affected_rows": row_count,
79
+ },
80
+ }
core/column_registry.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Column name registry.
3
+
4
+ Builds a bidirectional alias map per session so that repeated commands
5
+ resolve columns in O(1) time. Falls back to rapidfuzz only on first
6
+ encounter, then caches the result.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional
11
+ from rapidfuzz import process, fuzz
12
+
13
+ from config import FUZZY_THRESHOLD
14
+
15
+
16
+ class ColumnRegistry:
17
+ """Session-scoped column name resolver with fuzzy-match cache."""
18
+
19
+ def __init__(self) -> None:
20
+ # session_id -> {normalized_alias: real_column_name}
21
+ self._store: dict[str, dict[str, str]] = {}
22
+
23
+ # ── public API ──────────────────────────────────────────────────
24
+
25
+ def register(self, session_id: str, columns: list[str]) -> None:
26
+ """Build alias map for every column in the dataframe."""
27
+ mapping: dict[str, str] = {}
28
+ for col in columns:
29
+ normalized = col.lower().strip()
30
+ mapping[normalized] = col
31
+ # Common variations the user might type
32
+ mapping[col.replace("_", " ").lower()] = col
33
+ mapping[col.replace(" ", "_").lower()] = col
34
+ mapping[col.replace("-", "_").lower()] = col
35
+ mapping[col.replace("-", " ").lower()] = col
36
+ self._store[session_id] = mapping
37
+
38
+ def resolve(self, session_id: str, token: str) -> Optional[str]:
39
+ """Resolve a user-provided token to the real column name.
40
+
41
+ 1. Exact match on alias map (O(1)).
42
+ 2. rapidfuzz WRatio against all aliases.
43
+ 3. Tokenize the full command and try sliding-window bigrams.
44
+ """
45
+ mapping = self._store.get(session_id)
46
+ if not mapping:
47
+ return None
48
+
49
+ # Direct O(1) lookup
50
+ key = token.lower().strip()
51
+ if key in mapping:
52
+ return mapping[key]
53
+
54
+ # Single-token fuzzy
55
+ best = process.extractOne(key, list(mapping.keys()), scorer=fuzz.WRatio)
56
+ if best and best[1] >= FUZZY_THRESHOLD:
57
+ return mapping[best[0]]
58
+
59
+ # Sliding-window bigram from the original token (handles multi-word columns)
60
+ parts = token.split()
61
+ for i in range(len(parts) - 1):
62
+ bigram = f"{parts[i]} {parts[i + 1]}".lower()
63
+ if bigram in mapping:
64
+ return mapping[bigram]
65
+ best = process.extractOne(bigram, list(mapping.keys()), scorer=fuzz.WRatio)
66
+ if best and best[1] >= FUZZY_THRESHOLD:
67
+ return mapping[best[0]]
68
+
69
+ return None
70
+
71
+ def get_columns(self, session_id: str) -> list[str]:
72
+ """Return deduplicated real column names."""
73
+ return list(set(self._store.get(session_id, {}).values()))
74
+
75
+ def remove(self, session_id: str) -> None:
76
+ self._store.pop(session_id, None)
77
+
78
+
79
+ # Module-level singleton
80
+ column_registry = ColumnRegistry()
core/execution/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """
2
+ Execution package.
3
+ """
core/execution/mvcc_executor.py ADDED
@@ -0,0 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MVCC Executor.
3
+ Performs lock-free versioned operations on Parquet files using Polars LazyFrames.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import polars as pl
9
+
10
+ from config import DATA_DIR, LAZY_THRESHOLD_BYTES
11
+ from services.session_manager import session_manager
12
+ from core.column_registry import column_registry
13
+ from core.execution.polars_builder import build_polars_expression
14
+
15
+ def _build_predicate(column: str, condition: str, value) -> pl.Expr:
16
+ """Build a Polars expression from a comparison condition string."""
17
+ col = pl.col(column)
18
+ if condition == ">":
19
+ return col > value
20
+ elif condition == "<":
21
+ return col < value
22
+ elif condition == ">=":
23
+ return col >= value
24
+ elif condition == "<=":
25
+ return col <= value
26
+ elif condition == "!=":
27
+ return col != value
28
+ elif condition == "==" or condition == "=":
29
+ if isinstance(value, (int, float)):
30
+ return col == value
31
+ try:
32
+ return col == float(value)
33
+ except (ValueError, TypeError):
34
+ return col.cast(pl.String).str.to_lowercase() == str(value).lower()
35
+ elif condition in ("contains", "~"):
36
+ return col.cast(pl.String).str.contains(str(value), literal=True)
37
+ else:
38
+ return col.cast(pl.String).str.to_lowercase() == str(value).lower()
39
+
40
+ def _format_number(val) -> str:
41
+ """Pretty-print numbers with commas."""
42
+ if val is None:
43
+ return "N/A"
44
+ if isinstance(val, float):
45
+ if val == int(val):
46
+ return f"{int(val):,}"
47
+ return f"{val:,.2f}"
48
+ if isinstance(val, int):
49
+ return f"{val:,}"
50
+ return str(val)
51
+
52
+ def execute_mvcc_command(session_id: str, intent: dict) -> dict:
53
+ """Execute the command and return a result dict.
54
+
55
+ Updates metadata and creates a new parquet version on write.
56
+ """
57
+ meta = session_manager.get(session_id)
58
+ if meta is None:
59
+ return {
60
+ "status": "error",
61
+ "message": "Session nahi mila. Pehle file upload karo.",
62
+ "diff": {}
63
+ }
64
+
65
+ version = getattr(meta, "current_version", 0)
66
+
67
+ # Resolve read path
68
+ # If version is 0, we can fall back to the initial upload path (data/sessions/{session_id}.parquet).
69
+ v_path = os.path.join(DATA_DIR, f"{session_id}_v{version}.parquet")
70
+ if not os.path.exists(v_path):
71
+ if version == 0:
72
+ v_path = os.path.join(DATA_DIR, f"{session_id}.parquet")
73
+ if not os.path.exists(v_path):
74
+ raise FileNotFoundError(f"Initial Parquet file not found for session {session_id}")
75
+ else:
76
+ raise FileNotFoundError(f"Parquet file not found for version {version} of session {session_id}")
77
+
78
+ op = intent.get("operation")
79
+ if not op:
80
+ return {
81
+ "message": "Operation specifies nahi kiya gaya",
82
+ "diff": {}
83
+ }
84
+
85
+ # ── Read-only operations (Aggregates) ──
86
+ if op in ("sum", "average", "count", "min", "max"):
87
+ lf = pl.scan_parquet(v_path)
88
+ if op == "sum":
89
+ col = intent["column"]
90
+ result = lf.select(pl.col(col).sum()).collect().item()
91
+ return {
92
+ "message": f"{col} ka sum = {_format_number(result)}",
93
+ "diff": {"operation": "sum", "column": col, "result": result},
94
+ }
95
+ elif op == "average":
96
+ col = intent["column"]
97
+ result = lf.select(pl.col(col).mean()).collect().item()
98
+ return {
99
+ "message": f"{col} ka average = {_format_number(result)}",
100
+ "diff": {"operation": "average", "column": col, "result": result},
101
+ }
102
+ elif op == "count":
103
+ col = intent.get("column")
104
+ if col:
105
+ result = lf.select(pl.col(col).count()).collect().item()
106
+ return {
107
+ "message": f"{col} mein {_format_number(result)} non-null values hain",
108
+ "diff": {"operation": "count", "column": col, "result": result},
109
+ }
110
+ else:
111
+ result = lf.select(pl.len()).collect().item()
112
+ return {
113
+ "message": f"Total {_format_number(result)} rows hain",
114
+ "diff": {"operation": "count", "column": None, "result": result},
115
+ }
116
+ elif op == "min":
117
+ col = intent["column"]
118
+ result = lf.select(pl.col(col).min()).collect().item()
119
+ return {
120
+ "message": f"{col} ka minimum = {_format_number(result)}",
121
+ "diff": {"operation": "min", "column": col, "result": result},
122
+ }
123
+ elif op == "max":
124
+ col = intent["column"]
125
+ result = lf.select(pl.col(col).max()).collect().item()
126
+ return {
127
+ "message": f"{col} ka maximum = {_format_number(result)}",
128
+ "diff": {"operation": "max", "column": col, "result": result},
129
+ }
130
+
131
+ # ── Write operations ──
132
+ lf = pl.scan_parquet(v_path)
133
+ new_version = version + 1
134
+ new_path = os.path.join(DATA_DIR, f"{session_id}_v{new_version}.parquet")
135
+
136
+ before_count = lf.select(pl.len()).collect().item()
137
+
138
+ if op in ("increase", "decrease"):
139
+ col = intent["column"]
140
+ value = intent.get("value")
141
+ if value is None:
142
+ value = 10.0
143
+ is_percent = intent.get("is_percent", True)
144
+
145
+ expr = build_polars_expression(op, col, {"value": value, "is_percent": is_percent})
146
+ lf = lf.with_columns(expr)
147
+
148
+ elif op == "cast_type":
149
+ col = intent["column"]
150
+ target = intent["target_dtype"]
151
+ expr = build_polars_expression("cast_type", col, {"target_dtype": target})
152
+ lf = lf.with_columns(expr)
153
+
154
+ elif op == "find_replace":
155
+ col = intent["column"]
156
+ old_val = intent["old_value"]
157
+ new_val = intent["new_value"]
158
+
159
+ schema = lf.collect_schema()
160
+ dtype = schema[col]
161
+ is_numeric = dtype in (pl.Float64, pl.Int64, pl.Int32, pl.Float32)
162
+
163
+ if is_numeric:
164
+ try:
165
+ float(old_val)
166
+ float(new_val)
167
+ except (ValueError, TypeError):
168
+ return {
169
+ "message": f"'{col}' numeric hai, replace values bhi numbers hone chahiye",
170
+ "diff": {},
171
+ }
172
+
173
+ expr = build_polars_expression("find_replace", col, {
174
+ "old_value": old_val,
175
+ "new_value": new_val,
176
+ "is_numeric": is_numeric
177
+ })
178
+ lf = lf.with_columns(expr)
179
+
180
+ elif op == "delete_column":
181
+ col = intent["column"]
182
+ lf = lf.drop(col)
183
+
184
+ elif op == "rename_column":
185
+ old = intent["column"]
186
+ new = intent["new_name"]
187
+ lf = lf.rename({old: new})
188
+
189
+ elif op == "add_column":
190
+ new_col = intent.get("new_column_name") or intent.get("column", "new_col")
191
+ default_val = intent.get("value", 0)
192
+ lf = lf.with_columns(pl.lit(default_val).alias(new_col))
193
+
194
+ elif op == "remove_duplicates":
195
+ col = intent.get("column")
196
+ if col:
197
+ lf = lf.unique(subset=[col], keep="first")
198
+ else:
199
+ lf = lf.unique(keep="first")
200
+
201
+ elif op == "filter":
202
+ col = intent["column"]
203
+ condition = intent["condition"]
204
+ filter_value = intent["filter_value"]
205
+ predicate = _build_predicate(col, condition, filter_value)
206
+ lf = lf.filter(predicate)
207
+
208
+ elif op in ("sort_asc", "sort_desc"):
209
+ col = intent["column"]
210
+ descending = (op == "sort_desc")
211
+ lf = lf.sort(col, descending=descending)
212
+
213
+ else:
214
+ return {
215
+ "message": f"'{op}' operation supported nahi hai",
216
+ "diff": {},
217
+ }
218
+
219
+ # Lock-free versioned write
220
+ prev_size = os.path.getsize(v_path)
221
+ try:
222
+ if prev_size > LAZY_THRESHOLD_BYTES:
223
+ lf.sink_parquet(new_path)
224
+ else:
225
+ lf.collect().write_parquet(new_path)
226
+ except Exception:
227
+ # Fallback to eager write if streaming sink is not supported for this query plan (e.g. sort/unique)
228
+ lf.collect().write_parquet(new_path)
229
+
230
+ # Read updated metadata
231
+ lf_new = pl.scan_parquet(new_path)
232
+ new_schema = lf_new.collect_schema()
233
+ after_count = lf_new.select(pl.len()).collect().item()
234
+ columns_meta = [{"name": name, "dtype": str(dtype)} for name, dtype in new_schema.items()]
235
+
236
+ # Format result message and diff
237
+ message = ""
238
+ diff = {}
239
+
240
+ if op in ("increase", "decrease"):
241
+ col = intent["column"]
242
+ value = intent.get("value")
243
+ if value is None:
244
+ value = 10.0
245
+ is_percent = intent.get("is_percent", True)
246
+ op_label = f"{value}% badha diya" if op == "increase" else f"{value}% ghata diya"
247
+ if not is_percent:
248
+ op_label = f"{value} joda" if op == "increase" else f"{value} ghata diya"
249
+ message = f"{col} ko {op_label} ({after_count:,} rows updated)"
250
+ diff = {
251
+ "operation": op,
252
+ "column": col,
253
+ "value": value,
254
+ "is_percent": is_percent,
255
+ "affected_rows": after_count,
256
+ }
257
+
258
+ elif op == "cast_type":
259
+ col = intent["column"]
260
+ target = intent["target_dtype"]
261
+ message = f"'{col}' ka type {target} mein change ho gaya"
262
+ diff = {"operation": "cast_type", "column": col, "target_dtype": target}
263
+
264
+ elif op == "find_replace":
265
+ col = intent["column"]
266
+ old_val = intent["old_value"]
267
+ new_val = intent["new_value"]
268
+
269
+ dtype = new_schema[col]
270
+ is_numeric = dtype in (pl.Float64, pl.Int64, pl.Int32, pl.Float32)
271
+ if is_numeric:
272
+ try:
273
+ count = lf_new.filter(pl.col(col) == float(new_val)).select(pl.len()).collect().item()
274
+ except (ValueError, TypeError):
275
+ count = "N/A"
276
+ else:
277
+ count = lf_new.filter(pl.col(col).cast(pl.String) == str(new_val)).select(pl.len()).collect().item()
278
+
279
+ message = f"'{col}' mein '{old_val}' ko '{new_val}' se replace kiya ({count} rows changed)"
280
+ diff = {
281
+ "operation": "find_replace",
282
+ "column": col,
283
+ "old_value": old_val,
284
+ "new_value": new_val,
285
+ "affected_rows": count,
286
+ }
287
+
288
+ elif op == "delete_column":
289
+ col = intent["column"]
290
+ message = f"'{col}' column delete ho gaya"
291
+ diff = {"operation": "delete_column", "column": col}
292
+
293
+ elif op == "rename_column":
294
+ old = intent["column"]
295
+ new = intent["new_name"]
296
+ message = f"'{old}' ka naam badal ke '{new}' ho gaya"
297
+ diff = {"operation": "rename_column", "old_name": old, "new_name": new}
298
+
299
+ elif op == "add_column":
300
+ new_col = intent.get("new_column_name") or intent.get("column", "new_col")
301
+ default_val = intent.get("value", 0)
302
+ message = f"Naya column '{new_col}' add ho gaya (default: {default_val})"
303
+ diff = {"operation": "add_column", "column": new_col}
304
+
305
+ elif op == "remove_duplicates":
306
+ col = intent.get("column")
307
+ removed = before_count - after_count
308
+ if col:
309
+ message = f"'{col}' ke duplicate rows hata diye ({removed} rows removed, {after_count:,} remaining)"
310
+ else:
311
+ message = f"Duplicate rows hata diye ({removed} rows removed, {after_count:,} remaining)"
312
+ diff = {
313
+ "operation": "remove_duplicates",
314
+ "column": col,
315
+ "rows_removed": removed,
316
+ "rows_remaining": after_count,
317
+ }
318
+
319
+ elif op == "filter":
320
+ col = intent["column"]
321
+ condition = intent["condition"]
322
+ filter_value = intent["filter_value"]
323
+ message = f"{col} {condition} {filter_value} β†’ {after_count:,} rows bach gaye"
324
+ diff = {
325
+ "operation": "filter",
326
+ "column": col,
327
+ "condition": condition,
328
+ "filter_value": filter_value,
329
+ "rows_after": after_count,
330
+ }
331
+
332
+ elif op in ("sort_asc", "sort_desc"):
333
+ col = intent["column"]
334
+ descending = (op == "sort_desc")
335
+ order = "descending (bada se chhota)" if descending else "ascending (chhota se bada)"
336
+ message = f"{col} ko {order} sort kiya ({after_count:,} rows)"
337
+ diff = {
338
+ "operation": "sort",
339
+ "column": col,
340
+ "descending": descending,
341
+ "affected_rows": after_count,
342
+ }
343
+
344
+ # Update session metadata in session_manager
345
+ meta.current_version = new_version
346
+ meta.row_count = after_count
347
+ meta.columns = columns_meta
348
+ meta.touch()
349
+
350
+ # Update column registry
351
+ column_registry.register(session_id, [c["name"] for c in columns_meta])
352
+
353
+ # Clean up the previous intermediate versioned file
354
+ if version > 0:
355
+ prev_version_path = os.path.join(DATA_DIR, f"{session_id}_v{version}.parquet")
356
+ if os.path.exists(prev_version_path):
357
+ try:
358
+ os.remove(prev_version_path)
359
+ except Exception as e:
360
+ print(f"[MVCC Executor] Warning cleaning up version {version}: {e}")
361
+
362
+ return {
363
+ "message": message,
364
+ "diff": diff,
365
+ }
core/execution/polars_builder.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Polars Expression Builder.
3
+ Builds programmatic expressions securely without raw eval/exec.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import polars as pl
8
+
9
+ def build_polars_expression(operation: str, column: str, params: dict) -> pl.Expr:
10
+ """Build programmatic expressions for increase, decrease, cast_type, and find_replace."""
11
+ if operation == "increase":
12
+ value = params.get("value", 10.0)
13
+ is_percent = params.get("is_percent", True)
14
+ if is_percent:
15
+ return (pl.col(column) * (1 + value / 100)).alias(column)
16
+ else:
17
+ return (pl.col(column) + value).alias(column)
18
+
19
+ elif operation == "decrease":
20
+ value = params.get("value", 10.0)
21
+ is_percent = params.get("is_percent", True)
22
+ if is_percent:
23
+ return (pl.col(column) * (1 - value / 100)).alias(column)
24
+ else:
25
+ return (pl.col(column) - value).alias(column)
26
+
27
+ elif operation == "cast_type":
28
+ target = params.get("target_dtype")
29
+ dtype_map = {
30
+ "Int64": pl.Int64,
31
+ "Int32": pl.Int32,
32
+ "Float64": pl.Float64,
33
+ "Float32": pl.Float32,
34
+ "String": pl.String,
35
+ "Boolean": pl.Boolean,
36
+ "Date": pl.Date,
37
+ }
38
+ pl_dtype = dtype_map.get(target)
39
+ if pl_dtype is None:
40
+ raise ValueError(f"Unsupported target dtype: {target}")
41
+ return pl.col(column).cast(pl_dtype).alias(column)
42
+
43
+ elif operation == "find_replace":
44
+ old_val = params.get("old_value")
45
+ new_val = params.get("new_value")
46
+ is_numeric = params.get("is_numeric", False)
47
+
48
+ if is_numeric:
49
+ try:
50
+ old_num = float(old_val)
51
+ new_num = float(new_val)
52
+ return pl.when(pl.col(column) == old_num).then(pl.lit(new_num)).otherwise(pl.col(column)).alias(column)
53
+ except (ValueError, TypeError):
54
+ raise ValueError("Values must be numeric for a numeric column replacement.")
55
+ else:
56
+ return (
57
+ pl.when(pl.col(column).cast(pl.String) == str(old_val))
58
+ .then(pl.lit(str(new_val)))
59
+ .otherwise(pl.col(column))
60
+ .alias(column)
61
+ )
62
+
63
+ else:
64
+ raise ValueError(f"Unsupported operation for programmatic expression building: {operation}")
core/intent_parser.py ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Intent parser β€” regex + keyword + fuzzy column resolution.
3
+
4
+ No LLM anywhere. Covers Student + Business tier operations:
5
+ increase, decrease, filter, sort_asc, sort_desc,
6
+ sum, average, count, min, max,
7
+ find_replace, delete_column, rename_column, add_column,
8
+ remove_duplicates, cast_type
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from typing import Optional
14
+
15
+ from rapidfuzz import process as rf_process, fuzz as rf_fuzz
16
+ from core.column_registry import column_registry
17
+
18
+ # ── Operation keyword table ─────────────────────────────────────────
19
+ # Longer, more specific keywords score higher so "badhao" beats "bada"
20
+ # when both appear in a command.
21
+ OPERATION_KEYWORDS: dict[str, list[str]] = {
22
+ "increase": [
23
+ "badhao", "badha do", "increase", "barha do", "barhao",
24
+ "zyada karo", "bada karo", "grow", "raise", "badha dijiye",
25
+ ],
26
+ "decrease": [
27
+ "ghatao", "ghata do", "decrease", "kam karo", "kam kar do",
28
+ "chhota karo", "reduce", "cut", "minus karo", "ghata dijiye",
29
+ ],
30
+ "filter": [
31
+ "sirf", "only", "filter", "dikhao", "show only", "show me",
32
+ "bas", "wale dikhao", "where", "jitne", "laao",
33
+ ],
34
+ "sort_asc": [
35
+ "chota se bada", "ascending", "a to z", "low to high",
36
+ "smallest first", "ascending order", "a-z", "asc",
37
+ ],
38
+ "sort_desc": [
39
+ "bada se chota", "descending", "z to a", "high to low",
40
+ "largest first", "descending order", "z-a", "desc",
41
+ "bade se chhote",
42
+ ],
43
+ "sum": [
44
+ "sum", "total", "jod", "yog", "add up", "total batao",
45
+ "kul", "jama",
46
+ ],
47
+ "average": [
48
+ "average", "avg", "mean", "samanya", "average nikalo",
49
+ ],
50
+ "count": [
51
+ "count", "ginti", "kitne", "kitni rows", "count karo",
52
+ "kitni", "rows kitne", "total rows",
53
+ ],
54
+ "min": [
55
+ "minimum", "min", "sabse chhota", "lowest", "kam se kam",
56
+ ],
57
+ "max": [
58
+ "maximum", "max", "sabse bada", "highest", "zyada se zyada",
59
+ ],
60
+ "find_replace": [
61
+ "replace", "badlo", "change", "find", "dhundho",
62
+ "substitute", "replace karo", "change karo", "naye se badlo",
63
+ ],
64
+ "delete_column": [
65
+ "delete column", "column hatao", "column delete karo",
66
+ "remove column", "column remove karo", "column hatado",
67
+ "column drop karo",
68
+ ],
69
+ "rename_column": [
70
+ "rename column", "column ka naam badlo", "column rename karo",
71
+ "name change karo", "naam badlo", "rename karo",
72
+ ],
73
+ "add_column": [
74
+ "add column", "naya column banao", "column add karo",
75
+ "new column", "column create karo",
76
+ ],
77
+ "remove_duplicates": [
78
+ "duplicate hatao", "duplicates remove karo", "unique rakho",
79
+ "duplicate remove", "repeat hatao",
80
+ ],
81
+ "cast_type": [
82
+ "type badlo", "data type change", "convert type",
83
+ "type convert karo", "numeric banao", "string banao",
84
+ ],
85
+ }
86
+
87
+ # ── Compiled regexes ───────────────────────────────────────────────
88
+ PERCENT_RE = re.compile(r"(\d+\.?\d*)\s*%", re.IGNORECASE)
89
+ NUMBER_RE = re.compile(r"(\d+\.?\d*)")
90
+
91
+ # Comparison operators (symbols)
92
+ _CMP_SYMBOLS = re.compile(r"([><=!]+)\s*([\d.]+|[^\s]+)")
93
+
94
+ HINDI_CMP_MAP: dict[str, str] = {
95
+ "se zyada": ">",
96
+ "se kam": "<",
97
+ "ke barabar": "==",
98
+ "se zyada ya barabar": ">=",
99
+ "se kam ya barabar": "<=",
100
+ "se bada": ">",
101
+ "se chhota": "<",
102
+ "ke equal": "==",
103
+ "ke hi": "==",
104
+ }
105
+
106
+ # Words to strip when extracting filter values
107
+ _STOP_WORDS = [
108
+ "sirf", "only", "filter", "dikhao", "show", "show only",
109
+ "bas", "wale", "laao", "bhai", "ko", "ka", "ke", "ki",
110
+ "mein", "hai", "hain", "karo", "karein", "sort",
111
+ "bada", "chhota", "se", "nikalo", "batao",
112
+ ]
113
+
114
+
115
+ # ══════════════════════════════════════════════════════════════════════
116
+ # Public API
117
+ # ══════════════════════════════════════════════════════════════════════
118
+
119
+ def parse_intent(session_id: str, command: str) -> Optional[dict]:
120
+ """Parse a natural-language command into a structured intent dict.
121
+
122
+ Returns ``None`` when nothing can be resolved (the caller should
123
+ return an *unresolved* response with column suggestions).
124
+ """
125
+ columns = column_registry.get_columns(session_id)
126
+ if not columns:
127
+ return None
128
+
129
+ operation = _match_operation(command)
130
+ if operation is None:
131
+ return None
132
+
133
+ # ── Operations that need special parsing ──────────────────────
134
+ if operation == "remove_duplicates":
135
+ col = _best_column(session_id, command, columns)
136
+ return {"operation": "remove_duplicates", "column": col}
137
+
138
+ if operation == "delete_column":
139
+ col = _best_column(session_id, command, columns)
140
+ if col is None:
141
+ return None
142
+ return {"operation": "delete_column", "column": col}
143
+
144
+ if operation == "rename_column":
145
+ return _parse_rename(session_id, command, columns)
146
+
147
+ if operation == "find_replace":
148
+ return _parse_find_replace(session_id, command, columns)
149
+
150
+ if operation == "filter":
151
+ return _parse_filter(session_id, command, columns)
152
+
153
+ if operation == "cast_type":
154
+ return _parse_cast(session_id, command, columns)
155
+
156
+ # ── Standard: operation + column + optional value ────────────
157
+ col = _best_column(session_id, command, columns)
158
+ if col is None:
159
+ return None
160
+
161
+ value = _parse_value(command)
162
+
163
+ return {"operation": operation, "column": col, "value": value}
164
+
165
+
166
+ # ══════════════════════════════════════════════════════════════════════
167
+ # Internal helpers
168
+ # ══════════════════════════════════════════════════════════════════════
169
+
170
+ def _match_operation(command: str) -> Optional[str]:
171
+ """Pick the operation with the highest keyword-match score."""
172
+ cmd = command.lower()
173
+ scores: dict[str, int] = {}
174
+ for op, keywords in OPERATION_KEYWORDS.items():
175
+ for kw in keywords:
176
+ if kw in cmd:
177
+ # Weight by keyword length so specific phrases beat short ones
178
+ scores[op] = scores.get(op, 0) + len(kw)
179
+ if not scores:
180
+ return None
181
+ return max(scores, key=scores.get) # type: ignore[arg-type]
182
+
183
+
184
+ # Flat set of all operation keywords β€” used to skip them during column resolution
185
+ _ALL_OP_KEYWORDS: set[str] = set()
186
+ for _kws in OPERATION_KEYWORDS.values():
187
+ _ALL_OP_KEYWORDS.update(_kws)
188
+ _ALL_OP_KEYWORDS.update(_STOP_WORDS)
189
+ _ALL_OP_KEYWORDS.update(["ko", "ka", "ke", "ki", "karo", "nikalo", "batao",
190
+ "hai", "hain", "mein", "se", "do", "dijiye"])
191
+
192
+
193
+ def _best_column(session_id: str, command: str, columns: list[str]) -> Optional[str]:
194
+ """Fuzzy-resolve the best column from the command text.
195
+
196
+ 1. Try column_registry (O(1) alias + cached fuzzy).
197
+ 2. Fall back to direct rapidfuzz scan.
198
+
199
+ Skips tokens that are known operation keywords (e.g. "average" won't
200
+ false-match to column "Age").
201
+ """
202
+ tokens = command.split()
203
+
204
+ # Filter out keyword tokens and pure-number tokens
205
+ clean_tokens = [
206
+ t for t in tokens
207
+ if not re.fullmatch(r"[\d.]+%?", t)
208
+ and t.lower() not in _ALL_OP_KEYWORDS
209
+ ]
210
+ candidates = clean_tokens + [
211
+ " ".join(clean_tokens[i : i + 2]) for i in range(len(clean_tokens) - 1)
212
+ ]
213
+
214
+ # 1. Registry first (O(1) alias + cached fuzzy)
215
+ for token in candidates:
216
+ resolved = column_registry.resolve(session_id, token)
217
+ if resolved:
218
+ return resolved
219
+
220
+ # 2. Direct rapidfuzz scan as fallback
221
+ best_match, best_score = None, 0
222
+ for cand in candidates:
223
+ hit = rf_process.extractOne(cand, columns, scorer=rf_fuzz.WRatio)
224
+ if hit and hit[1] > best_score:
225
+ best_match, best_score = hit[0], hit[1]
226
+ return best_match if best_score >= 78 else None
227
+
228
+
229
+ def _parse_value(command: str) -> Optional[float]:
230
+ """Extract a numeric value. Percentage wins over absolute."""
231
+ m = PERCENT_RE.search(command)
232
+ if m:
233
+ return float(m.group(1))
234
+ m = NUMBER_RE.search(command)
235
+ if m:
236
+ return float(m.group(1))
237
+ return None
238
+
239
+
240
+ def _parse_filter(session_id: str, command: str, columns: list[str]) -> Optional[dict]:
241
+ """Resolve a filter command into {column, condition, filter_value}."""
242
+ col = _best_column(session_id, command, columns)
243
+ if col is None:
244
+ return None
245
+
246
+ cmd_lower = command.lower()
247
+
248
+ # 1. Symbol comparison: salary > 50000
249
+ sym = _CMP_SYMBOLS.search(cmd_lower)
250
+ if sym and col.lower() in cmd_lower:
251
+ op_str, val_str = sym.group(1), sym.group(2)
252
+ try:
253
+ fval: str | float = float(val_str)
254
+ except ValueError:
255
+ fval = val_str.strip("'\"")
256
+ return {
257
+ "operation": "filter",
258
+ "column": col,
259
+ "condition": op_str,
260
+ "filter_value": fval,
261
+ }
262
+
263
+ # 2. Hindi comparison: salary 50000 se zyada
264
+ for hindi_op, symbol in HINDI_CMP_MAP.items():
265
+ if hindi_op in cmd_lower:
266
+ num_match = re.search(
267
+ r"(\d+\.?\d*)\s+" + re.escape(hindi_op), cmd_lower
268
+ )
269
+ if num_match:
270
+ return {
271
+ "operation": "filter",
272
+ "column": col,
273
+ "condition": symbol,
274
+ "filter_value": float(num_match.group(1)),
275
+ }
276
+
277
+ # 3. Equality by presence: "city Mumbai dikhao" β†’ city == Mumbai
278
+ stripped = cmd_lower
279
+ for kw in _STOP_WORDS:
280
+ stripped = stripped.replace(kw, "")
281
+ stripped = stripped.replace(col.lower(), "", 1).strip()
282
+ if stripped:
283
+ stripped = re.sub(r"^[><=!]+\s*", "", stripped).strip()
284
+ return {
285
+ "operation": "filter",
286
+ "column": col,
287
+ "condition": "==",
288
+ "filter_value": stripped,
289
+ }
290
+
291
+ return {"operation": "filter", "column": col, "condition": None, "filter_value": None}
292
+
293
+
294
+ def _parse_find_replace(session_id: str, command: str, columns: list[str]) -> Optional[dict]:
295
+ """Extract old_value and new_value for find & replace."""
296
+ col = _best_column(session_id, command, columns)
297
+ if col is None:
298
+ return None
299
+
300
+ # Try quoted values first
301
+ quoted = re.findall(r"""['"]([^'"]+)['"]""", command)
302
+ if len(quoted) >= 2:
303
+ return {
304
+ "operation": "find_replace",
305
+ "column": col,
306
+ "old_value": quoted[0],
307
+ "new_value": quoted[1],
308
+ }
309
+
310
+ # Try "X ko Y se badlo" / "replace X with Y" / "X ko Y replace karo"
311
+ m = re.search(
312
+ r"(\S+)\s+ko\s+(\S+)\s+(?:se\s+)?badlo"
313
+ r"|replace\s+(\S+)\s+with\s+(\S+)"
314
+ r"|(\S+)\s+ko\s+(\S+)\s+replace",
315
+ command,
316
+ re.IGNORECASE,
317
+ )
318
+ if m:
319
+ groups = [g for g in m.groups() if g is not None]
320
+ if len(groups) >= 2:
321
+ return {
322
+ "operation": "find_replace",
323
+ "column": col,
324
+ "old_value": groups[0],
325
+ "new_value": groups[1],
326
+ }
327
+
328
+ return None
329
+
330
+
331
+ def _parse_rename(session_id: str, command: str, columns: list[str]) -> Optional[dict]:
332
+ """Extract current column and desired new name."""
333
+ col = _best_column(session_id, command, columns)
334
+ if col is None:
335
+ return None
336
+
337
+ m = re.search(
338
+ r"(?:naam|name)\s+(?:ko\s+)?(\S+)\s+(?:se\s+)?badlo"
339
+ r"|rename\s+\S+\s+to\s+(\S+)",
340
+ command,
341
+ re.IGNORECASE,
342
+ )
343
+ if m:
344
+ new_name = m.group(1) or m.group(2)
345
+ if new_name:
346
+ return {
347
+ "operation": "rename_column",
348
+ "column": col,
349
+ "new_name": new_name.strip("'\" "),
350
+ }
351
+ return None
352
+
353
+
354
+ def _parse_cast(session_id: str, command: str, columns: list[str]) -> Optional[dict]:
355
+ """Extract column and target type for type casting."""
356
+ col = _best_column(session_id, command, columns)
357
+ if col is None:
358
+ return None
359
+
360
+ cmd_lower = command.lower()
361
+ target_dtype: str | None = None
362
+ if "int" in cmd_lower or "numeric" in cmd_lower or "number" in cmd_lower:
363
+ target_dtype = "Int64"
364
+ elif "float" in cmd_lower or "decimal" in cmd_lower:
365
+ target_dtype = "Float64"
366
+ elif "str" in cmd_lower or "string" in cmd_lower or "text" in cmd_lower:
367
+ target_dtype = "String"
368
+ elif "bool" in cmd_lower:
369
+ target_dtype = "Boolean"
370
+ elif "date" in cmd_lower or "datetime" in cmd_lower:
371
+ target_dtype = "Date"
372
+
373
+ if target_dtype:
374
+ return {"operation": "cast_type", "column": col, "target_dtype": target_dtype}
375
+ return None
core/parser/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """
2
+ Parser package.
3
+ """
core/parser/fallback.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Fallback parsing logic.
3
+ Attempts fast regex-based parsing first, falling back to local heuristic/embedding parser,
4
+ then to local Qwen GGUF model, and finally to the cloud Gemini LLM parser.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import json
10
+ import urllib.request
11
+ from typing import Any
12
+
13
+ from core.intent_parser import parse_intent
14
+ from core.parser.local_parser.parser import LocalIntentParser
15
+
16
+ # Light weight high quality Qwen2.5 GGUF model url
17
+ QWEN_GGUF_URL = "https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct-GGUF/resolve/main/qwen2.5-0.5b-instruct-q4_k_m.gguf"
18
+
19
+ _local_parser: LocalIntentParser | None = None
20
+ _qwen_model: Any | None = None
21
+
22
+ def get_local_parser() -> LocalIntentParser:
23
+ """Lazily load the local heuristic & embedding parser."""
24
+ global _local_parser
25
+ if _local_parser is None:
26
+ _local_parser = LocalIntentParser()
27
+ return _local_parser
28
+
29
+ def get_qwen_model(models_dir: str) -> Any | None:
30
+ """Lazily download and load the llama-cpp-python Qwen GGUF model."""
31
+ global _qwen_model
32
+ if _qwen_model is not None:
33
+ return _qwen_model
34
+
35
+ try:
36
+ from llama_cpp import Llama
37
+ except ImportError:
38
+ print("[Local Qwen] llama-cpp-python is not installed. Skipping local GGUF parser.")
39
+ return None
40
+
41
+ gguf_path = os.path.join(models_dir, "qwen2.5-0.5b-instruct-q4_k_m.gguf")
42
+ if not os.path.exists(gguf_path):
43
+ print(f"[Local Qwen] Downloading GGUF model to {gguf_path}...")
44
+ try:
45
+ os.makedirs(models_dir, exist_ok=True)
46
+ # Use urllib.request to avoid extra dependencies or cert issues
47
+ urllib.request.urlretrieve(QWEN_GGUF_URL, gguf_path)
48
+ print("[Local Qwen] GGUF model download complete.")
49
+ except Exception as e:
50
+ print(f"[Local Qwen] Failed to download GGUF model: {e}")
51
+ return None
52
+
53
+ try:
54
+ print(f"[Local Qwen] Loading model from {gguf_path}...")
55
+ # Load Qwen model with silent verbose logging and standard context window
56
+ _qwen_model = Llama(model_path=gguf_path, n_ctx=2048, verbose=False)
57
+ return _qwen_model
58
+ except Exception as e:
59
+ print(f"[Local Qwen] Error loading Llama model: {e}")
60
+ return None
61
+
62
+ def parse_with_qwen(session_id: str, command: str) -> dict[str, Any] | None:
63
+ """Run local inference using Qwen GGUF via llama-cpp-python."""
64
+ from core.column_registry import column_registry
65
+ columns = column_registry.get_columns(session_id) or []
66
+
67
+ prompt = f"""You are a precise data engineering intent parser. Your task is to convert a user's natural language command into a structured JSON payload that represents a dataframe operation.
68
+
69
+ Available Columns: {json.dumps(columns)}
70
+
71
+ Your output must be a single valid JSON object. Choose the correct operation and populate the required keys. Do NOT wrap output in markdown formatting.
72
+
73
+ Supported Operations & schemas:
74
+ - "increase": {{"operation": "increase", "column": "<col>", "value": <val>, "is_percent": <bool>}}
75
+ - "decrease": {{"operation": "decrease", "column": "<col>", "value": <val>, "is_percent": <bool>}}
76
+ - "filter": {{"operation": "filter", "column": "<col>", "condition": "<op>", "filter_value": <val>}}
77
+ - "sort_asc": {{"operation": "sort_asc", "column": "<col>"}}
78
+ - "sort_desc": {{"operation": "sort_desc", "column": "<col>"}}
79
+ - "sum": {{"operation": "sum", "column": "<col>"}}
80
+ - "average": {{"operation": "average", "column": "<col>"}}
81
+ - "count": {{"operation": "count", "column": "<col_or_null>"}}
82
+ - "min": {{"operation": "min", "column": "<col>"}}
83
+ - "max": {{"operation": "max", "column": "<col>"}}
84
+ - "find_replace": {{"operation": "find_replace", "column": "<col>", "old_value": <val>, "new_value": <val>}}
85
+ - "delete_column": {{"operation": "delete_column", "column": "<col>"}}
86
+ - "rename_column": {{"operation": "rename_column", "column": "<col>", "new_name": "<str>"}}
87
+ - "add_column": {{"operation": "add_column", "column": "<col_name>", "value": <val>}}
88
+ - "remove_duplicates": {{"operation": "remove_duplicates", "column": "<col_or_null>"}}
89
+ - "cast_type": {{"operation": "cast_type", "column": "<col>", "target_dtype": "<Int64|Float64|String|Boolean|Date>"}}
90
+
91
+ Command: "{command}"
92
+ JSON:"""
93
+
94
+ base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
95
+ models_dir = os.path.join(base_dir, "zero-llm-engine", "models")
96
+ if not os.path.exists(models_dir):
97
+ try:
98
+ os.makedirs(models_dir, exist_ok=True)
99
+ except Exception:
100
+ models_dir = os.path.join("/tmp", "models")
101
+ os.makedirs(models_dir, exist_ok=True)
102
+
103
+ llm = get_qwen_model(models_dir)
104
+ if llm is None:
105
+ return None
106
+
107
+ try:
108
+ response = llm.create_chat_completion(
109
+ messages=[
110
+ {"role": "system", "content": "You are a precise data engineering intent parser. You output only raw, valid JSON."},
111
+ {"role": "user", "content": prompt}
112
+ ],
113
+ temperature=0.1,
114
+ max_tokens=256
115
+ )
116
+ content = response["choices"][0]["message"]["content"].strip()
117
+
118
+ # Clean markdown code block formatting if present
119
+ if content.startswith("```"):
120
+ lines = content.splitlines()
121
+ if lines[0].startswith("```"):
122
+ lines = lines[1:]
123
+ if lines and lines[-1].strip() == "```":
124
+ lines = lines[:-1]
125
+ content = "\n".join(lines).strip()
126
+
127
+ return json.loads(content)
128
+ except Exception as e:
129
+ print(f"[Local Qwen] GGUF Parsing failed or timed out: {e}")
130
+ return None
131
+
132
+ def parse_intent_hybrid(session_id: str, command: str) -> dict | None:
133
+ """Parse user command using fast regex path, local orchestrator parser, local Qwen GGUF, or cloud Gemini."""
134
+ # 1. Fast Path: Regex / keyword resolution
135
+ fast_result = parse_intent(session_id, command)
136
+ if fast_result is not None:
137
+ print(f"[Hybrid Parser] Fast path success: {fast_result}")
138
+ return fast_result
139
+
140
+ # 2. Local Heuristic / Spelling / Synonym / Embeddings Parser
141
+ try:
142
+ local_parser = get_local_parser()
143
+ local_result = local_parser.parse_intent(session_id, command)
144
+ if local_result is not None:
145
+ confidence = local_result.pop("confidence", "low")
146
+ if confidence == "high":
147
+ print(f"[Hybrid Parser] Local parser high-confidence success: {local_result}")
148
+ return local_result
149
+ else:
150
+ print(f"[Hybrid Parser] Local parser yielded low confidence: {local_result}")
151
+ except Exception as e:
152
+ print(f"[Hybrid Parser] Exception in local parser: {e}")
153
+
154
+ # 3. Local Qwen GGUF Model Fallback
155
+ try:
156
+ qwen_result = parse_with_qwen(session_id, command)
157
+ if qwen_result is not None:
158
+ print(f"[Hybrid Parser] Local Qwen GGUF success: {qwen_result}")
159
+ return qwen_result
160
+ except Exception as e:
161
+ print(f"[Hybrid Parser] Exception in local Qwen fallback: {e}")
162
+
163
+ print("[Hybrid Parser] All local paths failed. Returning None.")
164
+ return None
core/parser/local_parser/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Local parser package.
3
+ Provides offline, high-accuracy spelling correction, synonym mapping, ONNX embeddings column matching, and safe AST math evaluation.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from core.parser.local_parser.spelling import SymSpell
8
+ from core.parser.local_parser.synonyms import SynonymMapper
9
+ from core.parser.local_parser.embeddings import EmbeddingModel
10
+ from core.parser.local_parser.ast_extractor import SafeMathEvaluator
11
+ from core.parser.local_parser.parser import LocalIntentParser
core/parser/local_parser/ast_extractor.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Safe math evaluator using Python's ast library.
3
+ Allows evaluation of mathematical and logical operations without exposing system to arbitrary code execution risks.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import ast
8
+ import operator as op
9
+ from typing import Any
10
+
11
+ class SafeMathEvaluator:
12
+ """Safely evaluates mathematical and logical expressions using AST parsing."""
13
+ _operators = {
14
+ # Binary operators
15
+ ast.Add: op.add,
16
+ ast.Sub: op.sub,
17
+ ast.Mult: op.mul,
18
+ ast.Div: op.truediv,
19
+ ast.FloorDiv: op.floordiv,
20
+ ast.Mod: op.mod,
21
+ ast.Pow: op.pow,
22
+ # Unary operators
23
+ ast.USub: op.neg,
24
+ ast.UAdd: op.pos,
25
+ # Comparison operators
26
+ ast.Gt: op.gt,
27
+ ast.Lt: op.lt,
28
+ ast.GtE: op.ge,
29
+ ast.LtE: op.le,
30
+ ast.Eq: op.eq,
31
+ ast.NotEq: op.ne,
32
+ # Logical operators
33
+ ast.And: lambda a, b: a and b,
34
+ ast.Or: lambda a, b: a or b,
35
+ ast.Not: op.not_,
36
+ }
37
+
38
+ def __init__(self, variables: dict[str, Any] | None = None):
39
+ self.variables = variables or {}
40
+
41
+ def evaluate(self, expression: str) -> Any:
42
+ """Parse and evaluate a safe mathematical/logical expression."""
43
+ if not expression:
44
+ return None
45
+ try:
46
+ # Parse expression in 'eval' mode (expects a single expression)
47
+ tree = ast.parse(expression.strip(), mode="eval")
48
+ return self._eval(tree.body)
49
+ except Exception as e:
50
+ raise ValueError(f"Failed to evaluate expression '{expression}': {e}")
51
+
52
+ def _eval(self, node: ast.AST) -> Any:
53
+ """Recursively evaluate the AST node."""
54
+ if isinstance(node, ast.Num): # Python < 3.8
55
+ return node.n
56
+ elif isinstance(node, ast.Constant): # Python >= 3.8
57
+ return node.value
58
+ elif isinstance(node, ast.Name):
59
+ # Check variables dictionary
60
+ if node.id in self.variables:
61
+ return self.variables[node.id]
62
+ # Special boolean constants
63
+ if node.id == "True":
64
+ return True
65
+ if node.id == "False":
66
+ return False
67
+ if node.id == "None":
68
+ return None
69
+ raise NameError(f"Variable '{node.id}' is not defined or is not allowed.")
70
+ elif isinstance(node, ast.BinOp):
71
+ left = self._eval(node.left)
72
+ right = self._eval(node.right)
73
+ op_type = type(node.op)
74
+ if op_type in self._operators:
75
+ return self._operators[op_type](left, right)
76
+ raise TypeError(f"Unsupported binary operator: {op_type}")
77
+ elif isinstance(node, ast.UnaryOp):
78
+ operand = self._eval(node.operand)
79
+ op_type = type(node.op)
80
+ if op_type in self._operators:
81
+ return self._operators[op_type](operand)
82
+ raise TypeError(f"Unsupported unary operator: {op_type}")
83
+ elif isinstance(node, ast.Compare):
84
+ left = self._eval(node.left)
85
+ for operation, comparator in zip(node.ops, node.comparators):
86
+ right = self._eval(comparator)
87
+ op_type = type(operation)
88
+ if op_type in self._operators:
89
+ if not self._operators[op_type](left, right):
90
+ return False
91
+ left = right
92
+ else:
93
+ raise TypeError(f"Unsupported comparison operator: {op_type}")
94
+ return True
95
+ elif isinstance(node, ast.BoolOp):
96
+ op_type = type(node.op)
97
+ if op_type not in self._operators:
98
+ raise TypeError(f"Unsupported logical operator: {op_type}")
99
+ values = [self._eval(val) for val in node.values]
100
+ if not values:
101
+ return False
102
+ result = values[0]
103
+ for val in values[1:]:
104
+ result = self._operators[op_type](result, val)
105
+ return result
106
+ else:
107
+ raise TypeError(f"Unsupported expression node: {type(node)}")
core/parser/local_parser/embeddings.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Embedding module using a quantized ONNX MiniLM model and a pure Python WordPiece tokenizer.
3
+ Calculates cosine similarity and caches column embeddings for performance.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import urllib.request
9
+ import numpy as np
10
+ import onnxruntime as ort
11
+ import re
12
+ from typing import Sequence
13
+
14
+ # Default Hugging Face URLs for the quantized all-MiniLM-L6-v2 model and vocab
15
+ MODEL_URL = "https://huggingface.co/onnx-community/all-MiniLM-L6-v2-ONNX/resolve/main/onnx/model_quantized.onnx"
16
+ VOCAB_URL = "https://huggingface.co/onnx-community/all-MiniLM-L6-v2-ONNX/resolve/main/vocab.txt"
17
+
18
+ class WordPieceTokenizer:
19
+ """Pure Python implementation of a WordPiece tokenizer."""
20
+ def __init__(self, vocab_path: str):
21
+ self.vocab: dict[str, int] = {}
22
+ with open(vocab_path, "r", encoding="utf-8") as f:
23
+ for i, line in enumerate(f):
24
+ token = line.strip()
25
+ self.vocab[token] = i
26
+
27
+ self.unk_token = "[UNK]"
28
+ self.cls_token = "[CLS]"
29
+ self.sep_token = "[SEP]"
30
+
31
+ self.unk_id = self.vocab.get(self.unk_token, 100)
32
+ self.cls_id = self.vocab.get(self.cls_token, 101)
33
+ self.sep_id = self.vocab.get(self.sep_token, 102)
34
+
35
+ def tokenize_word(self, word: str) -> list[str]:
36
+ """Tokenize a single word into WordPiece subwords."""
37
+ if word in self.vocab:
38
+ return [word]
39
+
40
+ tokens = []
41
+ start = 0
42
+ is_bad = False
43
+ while start < len(word):
44
+ end = len(word)
45
+ cur_substr = None
46
+ while start < end:
47
+ substr = word[start:end]
48
+ if start > 0:
49
+ substr = "##" + substr
50
+ if substr in self.vocab:
51
+ cur_substr = substr
52
+ break
53
+ end -= 1
54
+ if cur_substr is None:
55
+ is_bad = True
56
+ break
57
+ tokens.append(cur_substr)
58
+ start = end
59
+
60
+ if is_bad:
61
+ return [self.unk_token]
62
+ return tokens
63
+
64
+ def encode(self, text: str, max_length: int = 128) -> dict[str, np.ndarray]:
65
+ """Encode text into model inputs (input_ids, attention_mask, token_type_ids)."""
66
+ text = text.lower()
67
+ # Basic word and punctuation splitter
68
+ words = re.findall(r"\w+|[^\w\s]", text, re.UNICODE)
69
+ tokens = []
70
+ for word in words:
71
+ tokens.extend(self.tokenize_word(word))
72
+
73
+ # Truncate
74
+ if len(tokens) > max_length - 2:
75
+ tokens = tokens[:max_length - 2]
76
+
77
+ # Build token IDs
78
+ input_ids = [self.cls_id] + [self.vocab.get(t, self.unk_id) for t in tokens] + [self.sep_id]
79
+ attention_mask = [1] * len(input_ids)
80
+ token_type_ids = [0] * len(input_ids)
81
+
82
+ # Pad to max_length
83
+ padding_len = max_length - len(input_ids)
84
+ if padding_len > 0:
85
+ input_ids.extend([0] * padding_len)
86
+ attention_mask.extend([0] * padding_len)
87
+ token_type_ids.extend([0] * padding_len)
88
+
89
+ # Convert to numpy arrays of type int64 (as expected by ONNX model)
90
+ return {
91
+ "input_ids": np.array([input_ids], dtype=np.int64),
92
+ "attention_mask": np.array([attention_mask], dtype=np.int64),
93
+ "token_type_ids": np.array([token_type_ids], dtype=np.int64),
94
+ }
95
+
96
+ def cosine_similarity(v1: np.ndarray, v2: np.ndarray) -> float:
97
+ """Calculate the cosine similarity between two 1D vectors."""
98
+ dot = np.dot(v1, v2)
99
+ norm1 = np.linalg.norm(v1)
100
+ norm2 = np.linalg.norm(v2)
101
+ if norm1 == 0 or norm2 == 0:
102
+ return 0.0
103
+ return float(dot / (norm1 * norm2))
104
+
105
+ class EmbeddingModel:
106
+ """ONNX-based text embedding generator with built-in WordPiece tokenization."""
107
+ def __init__(self, cache_dir: str | None = None):
108
+ if cache_dir is None:
109
+ # First try zero-llm-engine/models directory, fallback to temp dir /tmp/models
110
+ base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
111
+ cache_dir = os.path.join(base_dir, "zero-llm-engine", "models", "onnx_cache")
112
+ if not os.path.exists(cache_dir):
113
+ try:
114
+ os.makedirs(cache_dir, exist_ok=True)
115
+ except Exception:
116
+ # Fallback if the folder is read-only (e.g. some system settings or Docker environments)
117
+ cache_dir = os.path.join("/tmp", "onnx_cache")
118
+ os.makedirs(cache_dir, exist_ok=True)
119
+
120
+ self.cache_dir = cache_dir
121
+ self.model_path = os.path.join(cache_dir, "model_quantized.onnx")
122
+ self.vocab_path = os.path.join(cache_dir, "vocab.txt")
123
+ self.session: ort.InferenceSession | None = None
124
+ self.tokenizer: WordPieceTokenizer | None = None
125
+
126
+ # In-memory embedding cache: maps text string -> np.ndarray embedding
127
+ self._embedding_cache: dict[str, np.ndarray] = {}
128
+
129
+ def ensure_model_files(self) -> None:
130
+ """Download model and vocabulary files if they do not exist locally."""
131
+ if not os.path.exists(self.vocab_path):
132
+ print(f"[Embeddings] Downloading vocabulary to {self.vocab_path}...")
133
+ urllib.request.urlretrieve(VOCAB_URL, self.vocab_path)
134
+ if not os.path.exists(self.model_path):
135
+ print(f"[Embeddings] Downloading quantized ONNX model to {self.model_path}...")
136
+ urllib.request.urlretrieve(MODEL_URL, self.model_path)
137
+
138
+ def load_model(self) -> None:
139
+ """Ensure files are downloaded and load the ONNX session and tokenizer."""
140
+ if self.session is not None and self.tokenizer is not None:
141
+ return
142
+
143
+ self.ensure_model_files()
144
+
145
+ # Initialize tokenization & ONNX session
146
+ self.tokenizer = WordPieceTokenizer(self.vocab_path)
147
+ # Using CPU execution provider by default for maximum compatibility
148
+ self.session = ort.InferenceSession(self.model_path, providers=["CPUExecutionProvider"])
149
+
150
+ def get_embedding(self, text: str) -> np.ndarray:
151
+ """Generate a 1D mean-pooled normalized embedding vector for the text."""
152
+ self.load_model()
153
+ text_key = text.lower().strip()
154
+ if text_key in self._embedding_cache:
155
+ return self._embedding_cache[text_key]
156
+
157
+ assert self.tokenizer is not None
158
+ assert self.session is not None
159
+
160
+ # Tokenize and format inputs
161
+ inputs = self.tokenizer.encode(text_key)
162
+
163
+ # Run ONNX inference
164
+ outputs = self.session.run(None, inputs)
165
+ # The first output contains the token embeddings [batch_size, seq_len, hidden_dim]
166
+ token_embeddings = outputs[0]
167
+ attention_mask = inputs["attention_mask"]
168
+
169
+ # Perform mean pooling over the active tokens
170
+ input_mask_expanded = np.expand_dims(attention_mask, axis=-1)
171
+ input_mask_expanded = np.broadcast_to(input_mask_expanded, token_embeddings.shape)
172
+
173
+ sum_embeddings = np.sum(token_embeddings * input_mask_expanded, axis=1)
174
+ sum_mask = np.clip(np.sum(input_mask_expanded, axis=1), a_min=1e-9, a_max=None)
175
+
176
+ # Calculate mean pooled embedding (vector shape: [hidden_dim])
177
+ mean_pooled = (sum_embeddings / sum_mask)[0]
178
+
179
+ # L2 Normalize
180
+ norm = np.linalg.norm(mean_pooled)
181
+ if norm > 0:
182
+ mean_pooled = mean_pooled / norm
183
+
184
+ self._embedding_cache[text_key] = mean_pooled
185
+ return mean_pooled
186
+
187
+ def match_column(self, text: str, columns: Sequence[str], threshold: float = 0.4) -> tuple[str | None, float]:
188
+ """Match query text to the best column name using cosine similarity."""
189
+ if not columns:
190
+ return None, 0.0
191
+
192
+ query_emb = self.get_embedding(text)
193
+ best_col = None
194
+ best_sim = -1.0
195
+
196
+ for col in columns:
197
+ col_emb = self.get_embedding(col)
198
+ sim = cosine_similarity(query_emb, col_emb)
199
+ if sim > best_sim:
200
+ best_sim = sim
201
+ best_col = col
202
+
203
+ if best_sim >= threshold:
204
+ return best_col, best_sim
205
+ return None, best_sim
core/parser/local_parser/parser.py ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Orchestration parser module.
3
+ Integrates spelling correction, synonym mapping, ONNX embeddings matching, and parameter extraction.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from typing import Any
9
+ from core.column_registry import column_registry
10
+ from core.parser.local_parser.spelling import SymSpell
11
+ from core.parser.local_parser.synonyms import SynonymMapper
12
+ from core.parser.local_parser.embeddings import EmbeddingModel
13
+ from core.parser.local_parser.ast_extractor import SafeMathEvaluator
14
+
15
+ # Standard operation keywords (from intent_parser.py)
16
+ OPERATION_KEYWORDS: dict[str, list[str]] = {
17
+ "increase": ["badhao", "badha do", "increase", "barha do", "barhao", "zyada karo", "bada karo", "grow", "raise", "badha dijiye"],
18
+ "decrease": ["ghatao", "ghata do", "decrease", "kam karo", "kam kar do", "chhota karo", "reduce", "cut", "minus karo", "ghata dijiye"],
19
+ "filter": ["sirf", "only", "filter", "dikhao", "show only", "show me", "bas", "wale dikhao", "where", "jitne", "laao"],
20
+ "sort_asc": ["chota se bada", "ascending", "a to z", "low to high", "smallest first", "ascending order", "a-z", "asc"],
21
+ "sort_desc": ["bada se chota", "descending", "z to a", "high to low", "largest first", "descending order", "z-a", "desc", "bade se chhote"],
22
+ "sum": ["sum", "total", "jod", "yog", "add up", "total batao", "kul", "jama"],
23
+ "average": ["average", "avg", "mean", "samanya", "average nikalo"],
24
+ "count": ["count", "ginti", "kitne", "kitni rows", "count karo", "kitni", "rows kitne", "total rows"],
25
+ "min": ["minimum", "min", "sabse chhota", "lowest", "kam se kam"],
26
+ "max": ["maximum", "max", "sabse bada", "highest", "zyada se zyada"],
27
+ "find_replace": ["replace", "badlo", "change", "find", "dhundho", "substitute", "replace karo", "change karo", "naye se badlo"],
28
+ "delete_column": ["delete column", "column hatao", "column delete karo", "remove column", "column remove karo", "column hatado", "column drop karo"],
29
+ "rename_column": ["rename column", "column ka naam badlo", "column rename karo", "name change karo", "naam badlo", "rename karo"],
30
+ "add_column": ["add column", "naya column banao", "column add karo", "new column", "column create karo"],
31
+ "remove_duplicates": ["duplicate hatao", "duplicates remove karo", "unique rakho", "duplicate remove", "repeat hatao"],
32
+ "cast_type": ["type badlo", "data type change", "convert type", "type convert karo", "numeric banao", "string banao"],
33
+ }
34
+
35
+ class LocalIntentParser:
36
+ """Orchestrates local heuristic parsing using spelling correction, synonyms, embeddings, and AST."""
37
+ def __init__(self):
38
+ self.synonym_mapper = SynonymMapper()
39
+ self.embedding_model = EmbeddingModel()
40
+ self.math_evaluator = SafeMathEvaluator()
41
+
42
+ # Load spelling corrector with keywords
43
+ self.sym_spell = SymSpell(max_edit_distance=2)
44
+ for op_list in OPERATION_KEYWORDS.values():
45
+ for kw in op_list:
46
+ # Add word tokens to spelling index
47
+ for token in re.findall(r'[a-zA-Z]+', kw):
48
+ self.sym_spell.add_word(token)
49
+
50
+ def _get_columns(self, session_id: str) -> list[str]:
51
+ return column_registry.get_columns(session_id) or []
52
+
53
+ def parse_intent(self, session_id: str, command: str) -> dict[str, Any] | None:
54
+ """Parse natural language command into structured dataframe operation JSON."""
55
+ columns = self._get_columns(session_id)
56
+ if not columns:
57
+ return None
58
+
59
+ # 1. Spelling correction
60
+ # Build a spelling corrector with current column names dynamically included
61
+ local_sym_spell = SymSpell(max_edit_distance=2)
62
+ # copy keywords
63
+ for w in self.sym_spell.words:
64
+ local_sym_spell.add_word(w)
65
+ # Add column names
66
+ for col in columns:
67
+ local_sym_spell.add_word(col)
68
+ for token in re.findall(r'[a-zA-Z]+', col):
69
+ local_sym_spell.add_word(token)
70
+
71
+ corrected_cmd = local_sym_spell.correct_query(command)
72
+
73
+ # 2. Synonym mapping and Hinglish normalization
74
+ norm_cmd = self.synonym_mapper.normalize_text(corrected_cmd)
75
+
76
+ # 3. Match operations
77
+ operation = self._match_operation(norm_cmd)
78
+ if not operation:
79
+ return None
80
+
81
+ # 4. Column matching (Exact or Semantic)
82
+ column_match, confidence = self._resolve_column(norm_cmd, columns)
83
+
84
+ # 5. Parameter extraction based on matched operation
85
+ result: dict[str, Any] = {"operation": operation}
86
+
87
+ if operation in ["remove_duplicates", "count"]:
88
+ # Column is optional for remove_duplicates and count
89
+ if column_match:
90
+ result["column"] = column_match
91
+ else:
92
+ result["column"] = None
93
+ result["confidence"] = "high"
94
+ return result
95
+
96
+ if operation == "delete_column":
97
+ if not column_match:
98
+ return None
99
+ result["column"] = column_match
100
+ result["confidence"] = "high"
101
+ return result
102
+
103
+ if operation == "rename_column":
104
+ # Extract new name from patterns like "rename X to Y"
105
+ rename_match = re.search(r"rename\s+(?:column\s+)?(\w+)\s+(?:to|as)\s+(\w+)", norm_cmd, re.IGNORECASE)
106
+ if rename_match:
107
+ old_name_cand = rename_match.group(1)
108
+ new_name = rename_match.group(2)
109
+ # Resolve old name using columns list
110
+ resolved_old, _ = self._resolve_column(old_name_cand, columns)
111
+ result["column"] = resolved_old or column_match
112
+ result["new_name"] = new_name
113
+ result["confidence"] = "high" if result["column"] else "low"
114
+ return result
115
+ # Try fallback: split by "to" or "as"
116
+ parts = re.split(r"\b(?:to|as)\b", norm_cmd)
117
+ if len(parts) >= 2:
118
+ new_name = parts[-1].strip().split()[-1]
119
+ result["column"] = column_match
120
+ result["new_name"] = new_name
121
+ result["confidence"] = "high" if column_match else "low"
122
+ return result
123
+ return None
124
+
125
+ if operation == "find_replace":
126
+ # Look for patterns: "replace A with B"
127
+ replace_match = re.search(r"replace\s+(.+?)\s+with\s+(.+)", norm_cmd, re.IGNORECASE)
128
+ if replace_match:
129
+ old_val = replace_match.group(1).strip()
130
+ new_val = replace_match.group(2).strip()
131
+ # Remove column name references from old_val if present
132
+ if column_match and old_val.startswith(column_match.lower()):
133
+ old_val = old_val[len(column_match):].strip()
134
+
135
+ result["column"] = column_match
136
+ result["old_value"] = self._try_parse_numeric(old_val)
137
+ result["new_value"] = self._try_parse_numeric(new_val)
138
+ result["confidence"] = "high" if column_match else "low"
139
+ return result
140
+ return None
141
+
142
+ if operation == "add_column":
143
+ # Patterns: "add column X with value Y", "new column X = Y"
144
+ add_match = re.search(r"(?:add|new)\s+column\s+(\w+)(?:\s+(?:with|value|=)\s+(.+))?", norm_cmd, re.IGNORECASE)
145
+ if add_match:
146
+ col_name = add_match.group(1)
147
+ default_val_str = add_match.group(2)
148
+ default_val = self._try_parse_numeric(default_val_str) if default_val_str else None
149
+ result["column"] = col_name
150
+ result["value"] = default_val
151
+ result["confidence"] = "high"
152
+ return result
153
+ return None
154
+
155
+ if operation == "cast_type":
156
+ # Check target datatype
157
+ target_dtype = self._resolve_dtype(norm_cmd)
158
+ if not target_dtype:
159
+ return None
160
+ result["column"] = column_match
161
+ result["target_dtype"] = target_dtype
162
+ result["confidence"] = "high" if column_match else "low"
163
+ return result
164
+
165
+ if operation in ["increase", "decrease"]:
166
+ if not column_match:
167
+ return None
168
+ # Check if percentage
169
+ is_percent = "%" in command or "percent" in norm_cmd
170
+ # Find numbers
171
+ num_match = re.search(r"(\d+(?:\.\d+)?)", norm_cmd)
172
+ value = float(num_match.group(1)) if num_match else 0.0
173
+
174
+ result["column"] = column_match
175
+ result["value"] = value
176
+ result["is_percent"] = is_percent
177
+ result["confidence"] = "high"
178
+ return result
179
+
180
+ if operation == "filter":
181
+ if not column_match:
182
+ return None
183
+
184
+ # Resolve comparison operator
185
+ condition = "=="
186
+ for op_sym in [">=", "<=", ">", "<", "!=", "=="]:
187
+ if op_sym in norm_cmd:
188
+ condition = op_sym
189
+ break
190
+ else:
191
+ if "contains" in norm_cmd or "like" in norm_cmd:
192
+ condition = "contains"
193
+ elif "equal" in norm_cmd:
194
+ condition = "=="
195
+ elif "greater" in norm_cmd:
196
+ condition = ">"
197
+ elif "less" in norm_cmd:
198
+ condition = "<"
199
+
200
+ # Try to extract filter value
201
+ # Split query by operator or column name to isolate the value
202
+ filter_val_str = ""
203
+ if condition in norm_cmd:
204
+ parts = norm_cmd.split(condition, 1)
205
+ if len(parts) == 2:
206
+ filter_val_str = parts[1].strip()
207
+ else:
208
+ # Fallback to finding numeric or text token at the end
209
+ words = norm_cmd.split()
210
+ if words:
211
+ filter_val_str = words[-1]
212
+
213
+ # Clean filter val string
214
+ filter_val_str = re.sub(r"\b(?:karo|dikhao|bas|only|show|hai|hain)\b", "", filter_val_str).strip()
215
+ # Remove any trailing periods
216
+ filter_val_str = filter_val_str.rstrip(".")
217
+
218
+ filter_value = self._try_parse_numeric(filter_val_str)
219
+
220
+ result["column"] = column_match
221
+ result["condition"] = condition
222
+ result["filter_value"] = filter_value
223
+ result["confidence"] = "high"
224
+ return result
225
+
226
+ # Standard aggregation / simple operations (sum, average, min, max, sort_asc, sort_desc)
227
+ if not column_match:
228
+ return None
229
+
230
+ result["column"] = column_match
231
+ result["confidence"] = "high" if confidence >= 0.5 else "low"
232
+ return result
233
+
234
+ def _match_operation(self, command: str) -> str | None:
235
+ """Choose the operation with the highest keyword match score."""
236
+ cmd = command.lower()
237
+ scores: dict[str, int] = {}
238
+ for op, keywords in OPERATION_KEYWORDS.items():
239
+ for kw in keywords:
240
+ if kw in cmd:
241
+ # Weight by keyword length to favor more specific matches
242
+ scores[op] = scores.get(op, 0) + len(kw)
243
+ if not scores:
244
+ return None
245
+ return max(scores, key=scores.get)
246
+
247
+ def _resolve_column(self, command: str, columns: list[str]) -> tuple[str | None, float]:
248
+ """Match the query to a column name, supporting exact and semantic resolution."""
249
+ cmd_lower = command.lower()
250
+
251
+ # 1. Exact / Substring Match (Case-insensitive)
252
+ for col in columns:
253
+ if col.lower() in cmd_lower:
254
+ return col, 1.0
255
+
256
+ # 2. Semantic Similarity Fallback
257
+ # Extract keywords to reduce noise in the query string
258
+ clean_text = cmd_lower
259
+ for op_list in OPERATION_KEYWORDS.values():
260
+ for kw in op_list:
261
+ clean_text = re.sub(rf'\b{re.escape(kw)}\b', "", clean_text)
262
+
263
+ # Clean extra spaces
264
+ clean_text = " ".join(clean_text.split())
265
+ if not clean_text:
266
+ clean_text = cmd_lower
267
+
268
+ try:
269
+ return self.embedding_model.match_column(clean_text, columns, threshold=0.4)
270
+ except Exception as e:
271
+ print(f"[Local Parser] Semantic matching failed: {e}")
272
+ # Fallback to first column or None
273
+ return None, 0.0
274
+
275
+ def _try_parse_numeric(self, val_str: str) -> Any:
276
+ """Helper to cast string to int or float if applicable, strip quotes if string."""
277
+ val_str = val_str.strip().strip("'\"")
278
+ try:
279
+ if "." in val_str:
280
+ return float(val_str)
281
+ return int(val_str)
282
+ except ValueError:
283
+ # Check boolean values
284
+ if val_str.lower() == "true":
285
+ return True
286
+ if val_str.lower() == "false":
287
+ return False
288
+ return val_str
289
+
290
+ def _resolve_dtype(self, command: str) -> str | None:
291
+ """Match string to target datatype name."""
292
+ cmd = command.lower()
293
+ if "int" in cmd or "integer" in cmd or "numeric" in cmd or "number" in cmd:
294
+ return "Int64"
295
+ if "float" in cmd or "double" in cmd or "decimal" in cmd:
296
+ return "Float64"
297
+ if "string" in cmd or "text" in cmd or "character" in cmd:
298
+ return "String"
299
+ if "bool" in cmd or "boolean" in cmd:
300
+ return "Boolean"
301
+ if "date" in cmd or "time" in cmd:
302
+ return "Date"
303
+ return None
core/parser/local_parser/spelling.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Spelling correction module using SymSpell (Symmetric Delete Spelling Correction).
3
+ Optimized for O(1) delete lookups to match column names and operation keywords.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import re
8
+
9
+ def _get_deletes(word: str, max_edit_distance: int = 2) -> set[str]:
10
+ """Generate deletes for a word up to a maximum edit distance."""
11
+ deletes = set()
12
+ queue = {word}
13
+ for _ in range(max_edit_distance):
14
+ next_queue = set()
15
+ for w in queue:
16
+ if len(w) > 1:
17
+ for i in range(len(w)):
18
+ del_w = w[:i] + w[i+1:]
19
+ deletes.add(del_w)
20
+ next_queue.add(del_w)
21
+ queue = next_queue
22
+ return deletes
23
+
24
+ def _lev_dist(s1: str, s2: str) -> int:
25
+ """Calculate the Levenshtein distance between two strings."""
26
+ if len(s1) < len(s2):
27
+ return _lev_dist(s2, s1)
28
+ if len(s2) == 0:
29
+ return len(s1)
30
+ previous_row = list(range(len(s2) + 1))
31
+ for i, c1 in enumerate(s1):
32
+ current_row = [i + 1]
33
+ for j, c2 in enumerate(s2):
34
+ insertions = previous_row[j + 1] + 1
35
+ deletions = current_row[j] + 1
36
+ substitutions = previous_row[j] + (c1 != c2)
37
+ current_row.append(min(insertions, deletions, substitutions))
38
+ previous_row = current_row
39
+ return previous_row[-1]
40
+
41
+ class SymSpell:
42
+ """A lightweight symmetric delete spelling corrector."""
43
+ def __init__(self, max_edit_distance: int = 2):
44
+ self.max_edit_distance = max_edit_distance
45
+ # Maps delete_item -> set of original_words
46
+ self.deletes: dict[str, set[str]] = {}
47
+ self.words: set[str] = set()
48
+
49
+ def add_word(self, word: str) -> None:
50
+ """Index a word and its deletes for O(1) spelling lookup."""
51
+ word = word.lower().strip()
52
+ if not word or word in self.words:
53
+ return
54
+ self.words.add(word)
55
+
56
+ # Index word itself
57
+ if word not in self.deletes:
58
+ self.deletes[word] = set()
59
+ self.deletes[word].add(word)
60
+
61
+ # Index deletions
62
+ for delete in _get_deletes(word, self.max_edit_distance):
63
+ if delete not in self.deletes:
64
+ self.deletes[delete] = set()
65
+ self.deletes[delete].add(word)
66
+
67
+ def lookup(self, word: str) -> list[str]:
68
+ """Find candidate words matching the spelling of input word."""
69
+ word = word.lower().strip()
70
+ if not word:
71
+ return []
72
+ if word in self.words:
73
+ return [word]
74
+
75
+ candidates: set[str] = set()
76
+ # 1. Direct delete match
77
+ if word in self.deletes:
78
+ candidates.update(self.deletes[word])
79
+
80
+ # 2. Deletes of word match
81
+ for delete in _get_deletes(word, self.max_edit_distance):
82
+ if delete in self.deletes:
83
+ candidates.update(self.deletes[delete])
84
+ if delete in self.words:
85
+ candidates.add(delete)
86
+
87
+ # Score and rank candidates by Levenshtein distance
88
+ scored = []
89
+ for cand in candidates:
90
+ dist = _lev_dist(word, cand)
91
+ if dist <= self.max_edit_distance:
92
+ scored.append((cand, dist))
93
+
94
+ # Sort by distance first, then length (longer words first for ties)
95
+ scored.sort(key=lambda x: (x[1], -len(x[0])))
96
+ return [c for c, _ in scored]
97
+
98
+ def correct_query(self, query: str) -> str:
99
+ """Correct typos in words within the query string."""
100
+ # Find all alphabet-only words
101
+ words = re.findall(r'[a-zA-Z]+', query)
102
+ corrected = query
103
+ for w in words:
104
+ if len(w) > 2: # Only correct words longer than 2 characters
105
+ suggestions = self.lookup(w)
106
+ if suggestions:
107
+ # Match exact word boundary to prevent partial replacements
108
+ corrected = re.sub(rf'\b{re.escape(w)}\b', suggestions[0], corrected, flags=re.IGNORECASE)
109
+ return corrected
core/parser/local_parser/synonyms.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Synonyms and Hinglish normalization module.
3
+ Maps Hinglish phrases and synonyms to canonical DataFrame operations and column concepts.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import re
8
+
9
+ # Hinglish to English operation mappings
10
+ HINGLISH_TO_ENGLISH_OP: dict[str, str] = {
11
+ "badhao": "increase", "badha do": "increase", "barha do": "increase", "barhao": "increase",
12
+ "zyada karo": "increase", "bada karo": "increase", "badha dijiye": "increase",
13
+ "ghatao": "decrease", "ghata do": "decrease", "kam karo": "decrease", "kam kar do": "decrease",
14
+ "chhota karo": "decrease", "reduce": "decrease", "cut": "decrease", "minus karo": "decrease", "ghata dijiye": "decrease",
15
+ "sirf": "filter", "dikhao": "filter", "show only": "filter", "show me": "filter",
16
+ "bas": "filter", "wale dikhao": "filter", "jitne": "filter", "laao": "filter",
17
+ "chota se bada": "sort_asc", "ascending": "sort_asc", "low to high": "sort_asc", "smallest first": "sort_asc",
18
+ "bada se chota": "sort_desc", "descending": "sort_desc", "high to low": "sort_desc", "largest first": "sort_desc",
19
+ "jod": "sum", "yog": "sum", "add up": "sum", "total batao": "sum", "kul": "sum", "jama": "sum",
20
+ "samanya": "average", "average nikalo": "average", "avg": "average",
21
+ "ginti": "count", "kitne": "count", "kitni rows": "count", "count karo": "count", "kitni": "count",
22
+ "sabse chhota": "min", "lowest": "min", "kam se kam": "min",
23
+ "sabse bada": "max", "highest": "max", "zyada se zyada": "max",
24
+ "badlo": "find_replace", "change": "find_replace", "dhundho": "find_replace", "replace karo": "find_replace",
25
+ "column hatao": "delete_column", "column delete karo": "delete_column", "remove column": "delete_column",
26
+ "column ka naam badlo": "rename_column", "column rename karo": "rename_column", "naam badlo": "rename_column",
27
+ "naya column banao": "add_column", "column add karo": "add_column", "new column": "add_column",
28
+ "duplicate hatao": "remove_duplicates", "duplicates remove karo": "remove_duplicates", "unique rakho": "remove_duplicates",
29
+ "type badlo": "cast_type", "data type change": "cast_type", "convert type": "cast_type",
30
+ }
31
+
32
+ # Common column name synonym mappings
33
+ COMMON_COLUMN_SYNONYMS: dict[str, str] = {
34
+ "vetan": "salary", "kamai": "salary", "paisa": "salary", "income": "salary",
35
+ "umar": "age", "umra": "age",
36
+ "naam": "name",
37
+ "mulya": "price", "daam": "price", "keemat": "price", "cost": "price",
38
+ "tareekh": "date", "din": "date",
39
+ "shahar": "city", "shehar": "city",
40
+ "desh": "country",
41
+ "phone": "mobile", "mobile number": "mobile", "contact": "mobile",
42
+ }
43
+
44
+ class SynonymMapper:
45
+ """Handles mapping of synonym phrases and normalizes Hinglish queries."""
46
+ def __init__(self, custom_column_synonyms: dict[str, str] | None = None):
47
+ self.op_map = HINGLISH_TO_ENGLISH_OP
48
+ self.col_map = {**COMMON_COLUMN_SYNONYMS, **(custom_column_synonyms or {})}
49
+
50
+ def normalize_text(self, text: str) -> str:
51
+ """Normalize general Hinglish operations and column names to canonical terms."""
52
+ text_lower = text.lower().strip()
53
+
54
+ # 1. Normalize operations (longer/more specific phrases first to prevent partial match issues)
55
+ sorted_ops = sorted(self.op_map.keys(), key=len, reverse=True)
56
+ for h_op in sorted_ops:
57
+ e_op = self.op_map[h_op]
58
+ # Replace complete word/phrase boundaries where possible
59
+ if h_op in text_lower:
60
+ text_lower = re.sub(rf'\b{re.escape(h_op)}\b', e_op, text_lower)
61
+
62
+ # 2. Normalize columns
63
+ sorted_cols = sorted(self.col_map.keys(), key=len, reverse=True)
64
+ for h_col in sorted_cols:
65
+ e_col = self.col_map[h_col]
66
+ if h_col in text_lower:
67
+ text_lower = re.sub(rf'\b{re.escape(h_col)}\b', e_col, text_lower)
68
+
69
+ return text_lower
core/router.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Router β€” maps a validated intent dict to the correct tool function.
3
+
4
+ Pure dict lookup, zero overhead. Each tool function has the signature:
5
+ tool(session_id: str, intent: dict) -> dict
6
+ and returns {"message": str, "diff": dict}.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Callable, Optional
11
+
12
+ # Import all tools β€” they register themselves via TOOL_REGISTRY below
13
+ from core.a_to_z.student.update import execute_increase, execute_decrease
14
+ from core.a_to_z.student.filter_tool import execute_filter
15
+ from core.a_to_z.student.sort_tool import execute_sort
16
+ from core.a_to_z.student.aggregate import execute_sum, execute_average, execute_count, execute_min, execute_max
17
+ from core.a_to_z.student.column_ops import execute_delete_column, execute_rename_column, execute_add_column, execute_cast_type
18
+ from core.a_to_z.business.find_replace import execute_find_replace
19
+ from core.a_to_z.business.dedup import execute_remove_duplicates
20
+
21
+
22
+ TOOL_MAP: dict[str, Callable[[str, dict], dict]] = {
23
+ # Update
24
+ "increase": execute_increase,
25
+ "decrease": execute_decrease,
26
+ # Filter & Sort
27
+ "filter": execute_filter,
28
+ "sort_asc": execute_sort,
29
+ "sort_desc": execute_sort,
30
+ # Aggregate
31
+ "sum": execute_sum,
32
+ "average": execute_average,
33
+ "count": execute_count,
34
+ "min": execute_min,
35
+ "max": execute_max,
36
+ # Column operations
37
+ "delete_column": execute_delete_column,
38
+ "rename_column": execute_rename_column,
39
+ "add_column": execute_add_column,
40
+ "cast_type": execute_cast_type,
41
+ # Data cleaning
42
+ "find_replace": execute_find_replace,
43
+ "remove_duplicates": execute_remove_duplicates,
44
+ }
45
+
46
+
47
+ def dispatch(session_id: str, intent: dict) -> dict:
48
+ """Route intent to the matching tool. Returns tool's result dict."""
49
+ op = intent["operation"]
50
+ tool_fn = TOOL_MAP.get(op)
51
+ if tool_fn is None:
52
+ return {
53
+ "message": f"'{op}' operation supported nahi hai",
54
+ "diff": {},
55
+ }
56
+ return tool_fn(session_id, intent)
core/validator.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pre-execution validator.
3
+
4
+ Checks that a resolved intent is compatible with the dataframe schema
5
+ before any data gets touched. This is the safety net that stops
6
+ "increase the name column" from ever reaching Polars.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional
11
+ import polars as pl
12
+
13
+ from config import DATA_DIR
14
+ from core.column_registry import column_registry
15
+
16
+
17
+ # Operations that REQUIRE numeric dtype on the target column
18
+ NUMERIC_ONLY_OPS = {"increase", "decrease", "sum", "average", "min", "max"}
19
+
20
+
21
+ def get_schema(session_id: str) -> Optional[pl.Schema]:
22
+ """Read just the Parquet metadata to get column dtypes β€” no data loaded."""
23
+ import os
24
+ from services.session_manager import session_manager
25
+ pq_path = session_manager.get_filepath(session_id)
26
+ if not os.path.exists(pq_path):
27
+ return None
28
+ try:
29
+ schema = pl.scan_parquet(pq_path).collect_schema()
30
+ return schema
31
+ except Exception:
32
+ return None
33
+
34
+
35
+ def validate_intent(session_id: str, intent: dict) -> Optional[str]:
36
+ """Return an error string if the intent is invalid, else ``None``."""
37
+ schema = get_schema(session_id)
38
+ if schema is None:
39
+ return "Session ka data nahi mila β€” dobara upload karo"
40
+
41
+ col = intent.get("column")
42
+ op = intent.get("operation", "")
43
+
44
+ # ── Column existence (skip for operations that don't target a column) ──
45
+ no_column_ops = {"remove_duplicates"}
46
+ if op not in no_column_ops and col:
47
+ if col not in schema:
48
+ return f"'{col}' column nahi hai file mein"
49
+
50
+ # ── Numeric dtype check ────────────────────────────────────────────
51
+ if op in NUMERIC_ONLY_OPS and col:
52
+ dtype = schema[col]
53
+ if dtype not in (pl.Float64, pl.Int64, pl.Int32, pl.Float32, pl.UInt64, pl.UInt32):
54
+ return f"'{col}' numeric column nahi hai ({dtype}), ye operation apply nahi ho sakta"
55
+
56
+ # ── Filter: need condition + value ──────────────────────────────────
57
+ if op == "filter":
58
+ if intent.get("condition") is None or intent.get("filter_value") is None:
59
+ return "Filter ke liye condition aur value dono chahiye, jaise: 'salary > 50000 dikhao'"
60
+
61
+ # ── Find & Replace: need old + new ──────────────────────────────────
62
+ if op == "find_replace":
63
+ if not intent.get("old_value") or not intent.get("new_value"):
64
+ return "Find & Replace ke liye purana aur naya value dono chahiye, jaise: 'Active ko Inactive se badlo'"
65
+
66
+ # ── Rename: need new_name ───────────────────────────────────────────
67
+ if op == "rename_column":
68
+ if not intent.get("new_name"):
69
+ return "Rename ke liye naya naam chahiye, jaise: 'name ko full_name rename karo'"
70
+
71
+ return None # All good
main.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Zero-LLM Multi-Agent Excel/CSV Editing Engine
3
+ ==============================================
4
+
5
+ Production-ready FastAPI server. No LLM call anywhere.
6
+
7
+ Usage:
8
+ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
9
+
10
+ Endpoints:
11
+ POST /api/upload Upload CSV/Excel/Parquet
12
+ GET /api/download/{session_id} Download as CSV
13
+ GET /api/session/{session_id} Session metadata
14
+ DELETE /api/session/{session_id} Delete session
15
+ GET /api/history/{session_id} Command history
16
+ WS /ws/{session_id} Real-time command channel
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from contextlib import asynccontextmanager
21
+ from fastapi import FastAPI
22
+ from fastapi.middleware.cors import CORSMiddleware
23
+
24
+ from api.routes import router as rest_router
25
+ from api.websocket import websocket_handler
26
+ from services.audit_service import close as close_audit
27
+
28
+
29
+ @asynccontextmanager
30
+ async def lifespan(app: FastAPI):
31
+ """Startup / shutdown hooks."""
32
+ yield
33
+ await close_audit()
34
+
35
+
36
+ app = FastAPI(
37
+ title="Zero-LLM Data Engine",
38
+ description="Natural-language Excel/CSV editing β€” no LLM, pure Python + Polars",
39
+ version="1.0.0",
40
+ lifespan=lifespan,
41
+ )
42
+
43
+ # CORS β€” allow all origins for dev; restrict in production
44
+ app.add_middleware(
45
+ CORSMiddleware,
46
+ allow_origins=["*"],
47
+ allow_credentials=True,
48
+ allow_methods=["*"],
49
+ allow_headers=["*"],
50
+ )
51
+
52
+ # Mount REST routes under /api
53
+ app.include_router(rest_router, prefix="/api")
54
+
55
+ # WebSocket endpoint
56
+ from fastapi import WebSocket
57
+ @app.websocket("/ws/{session_id}")
58
+ async def ws_endpoint(ws: WebSocket, session_id: str):
59
+ await websocket_handler(ws, session_id)
60
+
61
+
62
+ # ── Health check ────────────────────────────────────────────────────
63
+
64
+ @app.get("/", tags=["health"])
65
+ async def root():
66
+ return {"status": "running", "engine": "zero-llm", "version": "1.0.0"}
models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # models package
models/schemas.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pydantic schemas for request / response payloads.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from typing import Optional, Any
7
+ from pydantic import BaseModel
8
+
9
+
10
+ # ── Requests ────────────────────────────────────────────────────────────
11
+
12
+ class CommandRequest(BaseModel):
13
+ command: str
14
+
15
+
16
+ # ── Responses ───────────────────────────────────────────────────────────
17
+
18
+ class CommandResponse(BaseModel):
19
+ status: str # success | error | unresolved
20
+ message: str
21
+ diff: Optional[dict[str, Any]] = None
22
+ suggestions: Optional[list[str]] = None
23
+
24
+
25
+ class SessionInfo(BaseModel):
26
+ session_id: str
27
+ file_name: str
28
+ file_size_bytes: int
29
+ columns: list[dict[str, str]] # [{"name": "salary", "dtype": "Float64"}]
30
+ row_count: int
31
+ status: str # active | processing
32
+
33
+
34
+ class UploadResponse(BaseModel):
35
+ session_id: str
36
+ file_name: str
37
+ rows: int
38
+ columns: list[str]
39
+ size_human: str
40
+
41
+
42
+ class ErrorResponse(BaseModel):
43
+ detail: str
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.0
3
+ python-multipart==0.0.9
4
+ polars==1.0.0
5
+ rapidfuzz==3.9.0
6
+ pydantic==2.9.0
7
+ websockets==12.0
8
+ aiosqlite==0.20.0
9
+ httpx==0.27.0
10
+ onnxruntime>=1.16.0
11
+ numpy>=1.24.0
12
+ llama-cpp-python>=0.2.85
services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # services package
services/audit_service.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Audit service β€” SQLite-backed command log.
3
+
4
+ Every executed command is recorded with enough info to reconstruct
5
+ the inverse operation (for undo). Uses aiosqlite so it doesn't
6
+ block the async event loop.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import aiosqlite
12
+
13
+ from config import AUDIT_DB_PATH
14
+
15
+ _CREATE_TABLE = """
16
+ CREATE TABLE IF NOT EXISTS audit_log (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ session_id TEXT NOT NULL,
19
+ command TEXT NOT NULL,
20
+ intent TEXT NOT NULL,
21
+ diff TEXT,
22
+ created_at TEXT DEFAULT (datetime('now'))
23
+ );
24
+ """
25
+
26
+ _db: aiosqlite.Connection | None = None
27
+
28
+
29
+ async def _get_db() -> aiosqlite.Connection:
30
+ global _db
31
+ if _db is None:
32
+ _db = await aiosqlite.connect(AUDIT_DB_PATH)
33
+ await _db.execute(_CREATE_TABLE)
34
+ await _db.commit()
35
+ return _db
36
+
37
+
38
+ async def log_command(
39
+ session_id: str,
40
+ command: str,
41
+ intent: dict | None,
42
+ diff: dict | None,
43
+ ) -> None:
44
+ """Insert an audit row."""
45
+ db = await _get_db()
46
+ await db.execute(
47
+ "INSERT INTO audit_log (session_id, command, intent, diff) VALUES (?, ?, ?, ?)",
48
+ (
49
+ session_id,
50
+ command,
51
+ json.dumps(intent or {}, ensure_ascii=False),
52
+ json.dumps(diff or {}, ensure_ascii=False),
53
+ ),
54
+ )
55
+ await db.commit()
56
+
57
+
58
+ async def get_history(session_id: str, limit: int = 50) -> list[dict]:
59
+ """Fetch recent commands for a session (for undo UI)."""
60
+ db = await _get_db()
61
+ cursor = await db.execute(
62
+ "SELECT id, command, intent, diff, created_at FROM audit_log "
63
+ "WHERE session_id = ? ORDER BY id DESC LIMIT ?",
64
+ (session_id, limit),
65
+ )
66
+ rows = await cursor.fetchall()
67
+ return [
68
+ {
69
+ "id": r[0],
70
+ "command": r[1],
71
+ "intent": json.loads(r[2]),
72
+ "diff": json.loads(r[3]),
73
+ "created_at": r[4],
74
+ }
75
+ for r in rows
76
+ ]
77
+
78
+
79
+ async def close() -> None:
80
+ global _db
81
+ if _db is not None:
82
+ await _db.close()
83
+ _db = None
services/file_manager.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ File manager — upload, CSV→Parquet conversion, download, cleanup.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import uuid
8
+ import shutil
9
+ import polars as pl
10
+
11
+ from config import DATA_DIR, UPLOAD_DIR, LAZY_THRESHOLD_BYTES, MAX_UPLOAD_BYTES
12
+ from services.session_manager import session_manager
13
+ from core.column_registry import column_registry
14
+
15
+
16
+ def _human_size(n: int) -> str:
17
+ for unit in ("B", "KB", "MB", "GB"):
18
+ if n < 1024:
19
+ return f"{n:.1f} {unit}"
20
+ n /= 1024
21
+ return f"{n:.1f} TB"
22
+
23
+
24
+ def _detect_separator(path: str) -> str:
25
+ """Sniff the separator from the first few KB."""
26
+ with open(path, "r", errors="replace") as f:
27
+ head = f.read(8192)
28
+ for sep in (",", ";", "\t", "|"):
29
+ if sep in head:
30
+ return sep
31
+ return ","
32
+
33
+
34
+ def handle_upload(file_content: bytes, file_name: str) -> dict:
35
+ """Save uploaded file, convert to Parquet, create session."""
36
+ # Size check
37
+ if len(file_content) > MAX_UPLOAD_BYTES:
38
+ raise ValueError(f"File too large. Max {MAX_UPLOAD_BYTES // (1024**2)} MB allowed.")
39
+
40
+ # Generate session
41
+ session_id = uuid.uuid4().hex[:12]
42
+
43
+ # Save raw upload
44
+ ext = os.path.splitext(file_name)[1].lower()
45
+ raw_path = os.path.join(UPLOAD_DIR, f"{session_id}{ext}")
46
+ with open(raw_path, "wb") as f:
47
+ f.write(file_content)
48
+
49
+ # Determine file type and read
50
+ if ext in (".csv", ".tsv", ".txt"):
51
+ sep = _detect_separator(raw_path)
52
+ lf = pl.scan_csv(raw_path, separator=sep, try_parse_dates=True)
53
+ elif ext in (".xlsx", ".xls"):
54
+ # Polars can read Excel but needs the feature flag.
55
+ # Fall back to eager read for Excel.
56
+ df = pl.read_excel(raw_path) # type: ignore[attr-defined]
57
+ parquet_path = os.path.join(DATA_DIR, f"{session_id}.parquet")
58
+ df.write_parquet(parquet_path)
59
+ os.remove(raw_path)
60
+ elif ext == ".parquet":
61
+ shutil.copy2(raw_path, os.path.join(DATA_DIR, f"{session_id}.parquet"))
62
+ os.remove(raw_path)
63
+ lf = pl.scan_parquet(os.path.join(DATA_DIR, f"{session_id}.parquet"))
64
+ else:
65
+ os.remove(raw_path)
66
+ raise ValueError(f"Unsupported file format: {ext}")
67
+
68
+ # For CSV: stream-convert to Parquet
69
+ if ext in (".csv", ".tsv", ".txt"):
70
+ parquet_path = os.path.join(DATA_DIR, f"{session_id}.parquet")
71
+ lf.sink_parquet(parquet_path)
72
+ os.remove(raw_path)
73
+ lf = pl.scan_parquet(parquet_path)
74
+
75
+ # Read metadata (no full data load)
76
+ schema = lf.collect_schema()
77
+ row_count = lf.select(pl.len()).collect().item()
78
+ columns = [{"name": name, "dtype": str(dtype)} for name, dtype in schema.items()]
79
+
80
+ # Register in session manager
81
+ session_manager.create(
82
+ session_id=session_id,
83
+ file_name=file_name,
84
+ file_size_bytes=len(file_content),
85
+ columns=columns,
86
+ row_count=row_count,
87
+ )
88
+
89
+ # Register columns for fuzzy resolution
90
+ column_registry.register(session_id, schema.names())
91
+
92
+ return {
93
+ "session_id": session_id,
94
+ "file_name": file_name,
95
+ "rows": row_count,
96
+ "columns": schema.names(),
97
+ "size_human": _human_size(len(file_content)),
98
+ }
99
+
100
+
101
+ def get_download_path(session_id: str) -> Optional[str]:
102
+ """Return path to the Parquet file, or None if session doesn't exist."""
103
+ path = session_manager.get_filepath(session_id)
104
+ if os.path.exists(path) and session_manager.get(session_id):
105
+ return path
106
+ return None
107
+
108
+
109
+ def export_to_csv(session_id: str) -> Optional[str]:
110
+ """Export Parquet to a temporary CSV file and return its path."""
111
+ pq_path = session_manager.get_filepath(session_id)
112
+ if not os.path.exists(pq_path):
113
+ return None
114
+
115
+ csv_path = os.path.join(UPLOAD_DIR, f"{session_id}.csv")
116
+ lf = pl.scan_parquet(pq_path)
117
+ if os.path.getsize(pq_path) > LAZY_THRESHOLD_BYTES:
118
+ lf.sink_csv(csv_path)
119
+ else:
120
+ lf.collect().write_csv(csv_path)
121
+ return csv_path
122
+
123
+
124
+ def delete_session(session_id: str) -> bool:
125
+ """Remove session metadata, Parquet file, and column registry."""
126
+ import glob
127
+ pattern = os.path.join(DATA_DIR, f"{session_id}*.parquet")
128
+ removed = False
129
+ for pq_path in glob.glob(pattern):
130
+ try:
131
+ os.remove(pq_path)
132
+ removed = True
133
+ except Exception:
134
+ pass
135
+ session_manager.remove(session_id)
136
+ column_registry.remove(session_id)
137
+ return removed
services/session_manager.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Session manager β€” lightweight in-memory session metadata.
3
+
4
+ In production, swap this dict for Redis. Each entry holds only
5
+ metadata (not the dataframe itself). The actual data lives on
6
+ disk as a Parquet file under DATA_DIR/{session_id}.parquet.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ import threading
12
+ from dataclasses import dataclass, field
13
+ from typing import Optional
14
+
15
+ from config import DATA_DIR, SESSION_TTL_MINUTES
16
+
17
+
18
+ @dataclass
19
+ class SessionMeta:
20
+ session_id: str
21
+ file_name: str
22
+ file_size_bytes: int
23
+ columns: list[dict[str, str]] # [{"name": ..., "dtype": ...}]
24
+ row_count: int
25
+ status: str = "active"
26
+ current_version: int = 0
27
+ created_at: float = field(default_factory=time.time)
28
+ last_active: float = field(default_factory=time.time)
29
+
30
+ def touch(self) -> None:
31
+ self.last_active = time.time()
32
+
33
+
34
+ class SessionManager:
35
+ """Thread-safe session store."""
36
+
37
+ def __init__(self) -> None:
38
+ self._sessions: dict[str, SessionMeta] = {}
39
+ self._lock = threading.Lock()
40
+
41
+ def create(
42
+ self,
43
+ session_id: str,
44
+ file_name: str,
45
+ file_size_bytes: int,
46
+ columns: list[dict[str, str]],
47
+ row_count: int,
48
+ ) -> SessionMeta:
49
+ meta = SessionMeta(
50
+ session_id=session_id,
51
+ file_name=file_name,
52
+ file_size_bytes=file_size_bytes,
53
+ columns=columns,
54
+ row_count=row_count,
55
+ )
56
+ with self._lock:
57
+ self._sessions[session_id] = meta
58
+ return meta
59
+
60
+ def get(self, session_id: str) -> Optional[SessionMeta]:
61
+ with self._lock:
62
+ return self._sessions.get(session_id)
63
+
64
+ def get_filepath(self, session_id: str) -> str:
65
+ """Get the absolute filepath of the current version of the Parquet file."""
66
+ meta = self.get(session_id)
67
+ if meta:
68
+ version = getattr(meta, "current_version", 0)
69
+ if version > 0:
70
+ v_path = os.path.join(DATA_DIR, f"{session_id}_v{version}.parquet")
71
+ if os.path.exists(v_path):
72
+ return v_path
73
+ # Version 0 or fallback: check v0 path first
74
+ v0_path = os.path.join(DATA_DIR, f"{session_id}_v0.parquet")
75
+ if os.path.exists(v0_path):
76
+ return v0_path
77
+ return os.path.join(DATA_DIR, f"{session_id}.parquet")
78
+
79
+ def touch(self, session_id: str) -> None:
80
+ meta = self.get(session_id)
81
+ if meta:
82
+ meta.touch()
83
+
84
+ def remove(self, session_id: str) -> None:
85
+ with self._lock:
86
+ self._sessions.pop(session_id, None)
87
+
88
+ def list_active(self) -> list[SessionMeta]:
89
+ """Return sessions that haven't expired."""
90
+ now = time.time()
91
+ cutoff = now - SESSION_TTL_MINUTES * 60
92
+ with self._lock:
93
+ return [
94
+ m for m in self._sessions.values()
95
+ if m.last_active > cutoff
96
+ ]
97
+
98
+ def cleanup_expired(self) -> int:
99
+ """Remove expired sessions and their Parquet files. Returns count removed."""
100
+ import os
101
+ import glob
102
+ now = time.time()
103
+ cutoff = now - SESSION_TTL_MINUTES * 60
104
+ removed = 0
105
+ with self._lock:
106
+ expired = [sid for sid, m in self._sessions.items() if m.last_active <= cutoff]
107
+ for sid in expired:
108
+ del self._sessions[sid]
109
+ pattern = os.path.join(DATA_DIR, f"{sid}*.parquet")
110
+ for pq in glob.glob(pattern):
111
+ try:
112
+ os.remove(pq)
113
+ except Exception:
114
+ pass
115
+ removed += 1
116
+ return removed
117
+
118
+
119
+ # Module-level singleton
120
+ session_manager = SessionManager()
test_local_parser.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for zero-llm-engine local parser components.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import sys
8
+
9
+ # Insert current dir to path for imports
10
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
11
+
12
+ from core.parser.local_parser.spelling import SymSpell
13
+ from core.parser.local_parser.synonyms import SynonymMapper
14
+ from core.parser.local_parser.ast_extractor import SafeMathEvaluator
15
+ from core.parser.local_parser.embeddings import WordPieceTokenizer, EmbeddingModel, cosine_similarity
16
+ from core.parser.local_parser.parser import LocalIntentParser
17
+ from core.column_registry import column_registry
18
+ from services.file_manager import handle_upload, delete_session
19
+
20
+ def test_spelling():
21
+ print("Testing spelling correction...")
22
+ sym = SymSpell(max_edit_distance=2)
23
+ sym.add_word("salary")
24
+ sym.add_word("average")
25
+ sym.add_word("decrease")
26
+
27
+ # Simple typo correction
28
+ assert sym.lookup("salry")[0] == "salary"
29
+ assert sym.lookup("avrage")[0] == "average"
30
+ assert sym.lookup("decres")[0] == "decrease"
31
+
32
+ # Sentence correction
33
+ corrected = sym.correct_query("decres the salry")
34
+ assert "decrease" in corrected
35
+ assert "salary" in corrected
36
+ print("βœ… Spelling tests passed!")
37
+
38
+ def test_synonyms():
39
+ print("Testing synonyms and Hinglish normalization...")
40
+ mapper = SynonymMapper()
41
+
42
+ # Hinglish mapping
43
+ res = mapper.normalize_text("vetan ko 10% badhao")
44
+ assert "salary" in res
45
+ assert "increase" in res
46
+
47
+ res2 = mapper.normalize_text("sabse chhota umar")
48
+ assert "min" in res2
49
+ assert "age" in res2
50
+ print("βœ… Synonym tests passed!")
51
+
52
+ def test_ast():
53
+ print("Testing safe AST evaluator...")
54
+ evaluator = SafeMathEvaluator(variables={"x": 10, "y": 20})
55
+
56
+ # Math operations
57
+ assert evaluator.evaluate("x + y * 2") == 50
58
+ assert evaluator.evaluate("(y - x) / 2") == 5.0
59
+
60
+ # Comparison and logic
61
+ assert evaluator.evaluate("x > 5 and y < 30") is True
62
+ assert evaluator.evaluate("x == 10 or y == 5") is True
63
+
64
+ # Name error / safety check
65
+ try:
66
+ evaluator.evaluate("import os")
67
+ assert False, "Should raise exception for imports"
68
+ except Exception:
69
+ pass
70
+
71
+ try:
72
+ evaluator.evaluate("x + z")
73
+ assert False, "Should raise exception for undefined variable"
74
+ except Exception:
75
+ pass
76
+ print("βœ… AST tests passed!")
77
+
78
+ def test_tokenizer_and_embeddings():
79
+ print("Testing tokenizer and embeddings...")
80
+ # Initialize embedding model (downloads if necessary)
81
+ model = EmbeddingModel()
82
+ model.load_model()
83
+
84
+ # WordPiece encoding tests
85
+ tokenizer = model.tokenizer
86
+ encoded = tokenizer.encode("salary")
87
+ assert "input_ids" in encoded
88
+ assert encoded["input_ids"].shape == (1, 128)
89
+
90
+ # Embedding generation
91
+ emb1 = model.get_embedding("salary")
92
+ emb2 = model.get_embedding("vetan")
93
+ emb3 = model.get_embedding("country")
94
+
95
+ # L2 Normalized shape check
96
+ assert len(emb1.shape) == 1
97
+ assert emb1.shape[0] > 0
98
+
99
+ # Cosine similarities
100
+ sim_salary = cosine_similarity(emb1, emb2)
101
+ sim_diff = cosine_similarity(emb1, emb3)
102
+
103
+ print(f"Similarity (salary, vetan): {sim_salary}")
104
+ print(f"Similarity (salary, country): {sim_diff}")
105
+
106
+ # Match column
107
+ cols = ["Age", "Salary", "Name", "Country"]
108
+ best_col, score = model.match_column("payrate", cols, threshold=0.3)
109
+ assert best_col == "Salary"
110
+ print("βœ… Tokenizer and Embedding tests passed!")
111
+
112
+ def test_orchestration():
113
+ print("Testing orchestration parser...")
114
+ # Register columns for test session
115
+ sid = "test_parser_session"
116
+ column_registry.register_columns(sid, [
117
+ {"name": "Name", "dtype": "String"},
118
+ {"name": "City", "dtype": "String"},
119
+ {"name": "Salary", "dtype": "Float64"},
120
+ {"name": "Age", "dtype": "Int64"},
121
+ ])
122
+
123
+ parser = LocalIntentParser()
124
+
125
+ # 1. Test increase
126
+ res1 = parser.parse_intent(sid, "salry ko 10% badhao")
127
+ assert res1 is not None
128
+ assert res1["operation"] == "increase"
129
+ assert res1["column"] == "Salary"
130
+ assert res1["value"] == 10.0
131
+ assert res1["is_percent"] is True
132
+
133
+ # 2. Test filter
134
+ res2 = parser.parse_intent(sid, "salary se zyada 50000 dikhao")
135
+ # "se zyada" gets normalized, condition becomes >
136
+ assert res2 is not None
137
+ assert res2["operation"] == "filter"
138
+ assert res2["column"] == "Salary"
139
+ assert res2["condition"] in [">", "=="] # depends on normalization
140
+
141
+ # Clean up
142
+ column_registry.clear_session(sid)
143
+ print("βœ… Orchestration tests passed!")
144
+
145
+ if __name__ == "__main__":
146
+ test_spelling()
147
+ test_synonyms()
148
+ test_ast()
149
+ try:
150
+ test_tokenizer_and_embeddings()
151
+ except Exception as e:
152
+ print(f"⚠️ Embeddings/Tokenizer test skipped or failed due to env: {e}")
153
+ test_orchestration()
154
+ print("\nπŸŽ‰ ALL TESTS COMPLETED SUCCESSFULY!")
test_smoke.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ End-to-end smoke test for the Zero-LLM engine.
3
+ Creates a sample CSV, uploads it, runs commands, verifies results.
4
+ """
5
+ import sys
6
+ import os
7
+
8
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
9
+
10
+ import polars as pl
11
+ from services.file_manager import handle_upload, delete_session
12
+ from services.session_manager import session_manager
13
+ from core.column_registry import column_registry
14
+ from core.intent_parser import parse_intent
15
+ from core.validator import validate_intent
16
+ from core.router import dispatch
17
+
18
+
19
+ def create_test_csv():
20
+ df = pl.DataFrame({
21
+ "Name": ["Rahul", "Priya", "Amit", "Sneha", "Vikram", "Neha", "Ravi", "Pooja"],
22
+ "City": ["Mumbai", "Delhi", "Mumbai", "Bangalore", "Delhi", "Mumbai", "Bangalore", "Delhi"],
23
+ "Salary": [50000, 60000, 55000, 70000, 65000, 48000, 72000, 58000],
24
+ "Age": [28, 32, 25, 35, 30, 27, 40, 29],
25
+ "Department": ["Engineering", "Marketing", "Engineering", "Design", "Marketing", "Engineering", "Design", "Marketing"],
26
+ })
27
+ base_dir = os.path.dirname(os.path.abspath(__file__))
28
+ uploads_dir = os.path.join(base_dir, "data", "uploads")
29
+ os.makedirs(uploads_dir, exist_ok=True)
30
+ path = os.path.join(uploads_dir, "test_sample.csv")
31
+ df.write_csv(path)
32
+ return path
33
+
34
+
35
+ def run_tests():
36
+ print("=" * 60)
37
+ print("ZERO-LLM ENGINE β€” SMOKE TEST")
38
+ print("=" * 60)
39
+
40
+ # ── 1. Upload ───────────────────────────────────────────────
41
+ print("\n[1] Creating + uploading test CSV...")
42
+ csv_path = create_test_csv()
43
+ with open(csv_path, "rb") as f:
44
+ result = handle_upload(f.read(), "test_sample.csv")
45
+ sid = result["session_id"]
46
+ print(f" Session: {sid}")
47
+ print(f" Rows: {result['rows']}, Columns: {result['columns']}")
48
+
49
+ # ── 2. Intent Parsing Tests ─────────────────────────────────
50
+ test_commands = [
51
+ # (command, expected_operation, expected_column_or_None)
52
+ ("salary ko 10% badhao", "increase", "Salary"),
53
+ ("salary ghatao 15%", "decrease", "Salary"),
54
+ ("salary 50000 se zyada dikhao","filter", "Salary"),
55
+ ("name sort karo chota se bada","sort_asc", "Name"),
56
+ ("total salary batao", "sum", "Salary"),
57
+ ("average age nikalo", "average", "Age"),
58
+ ("duplicate hatao", "remove_duplicates", None),
59
+ ("age column hatao", "delete_column", "Age"),
60
+ ]
61
+
62
+ print("\n[2] Intent parsing tests:")
63
+ passed = 0
64
+ for cmd, expected_op, expected_col in test_commands:
65
+ intent = parse_intent(sid, cmd)
66
+ op = intent["operation"] if intent else "NONE"
67
+ col = intent.get("column") if intent else None
68
+ op_ok = op == expected_op
69
+ col_ok = (expected_col is None and col is None) or col == expected_col
70
+ status = "βœ…" if op_ok and col_ok else "❌"
71
+ if op_ok and col_ok:
72
+ passed += 1
73
+ print(f" {status} '{cmd}' β†’ op={op}, col={col}")
74
+
75
+ print(f" Parsing: {passed}/{len(test_commands)} passed")
76
+
77
+ # ── 3. Execution Tests ──────────────────────────────────────
78
+ exec_commands = [
79
+ "salary ko 10% badhao",
80
+ "total salary batao",
81
+ "average salary nikalo",
82
+ "name sort karo chota se bada",
83
+ "sabse bada salary batao",
84
+ "sabse chhota salary batao",
85
+ ]
86
+
87
+ print("\n[3] Execution tests:")
88
+ for cmd in exec_commands:
89
+ intent = parse_intent(sid, cmd)
90
+ if intent is None:
91
+ print(f" ❌ '{cmd}' β†’ unresolved")
92
+ continue
93
+ error = validate_intent(sid, intent)
94
+ if error:
95
+ print(f" ❌ '{cmd}' β†’ validation: {error}")
96
+ continue
97
+ try:
98
+ result = dispatch(sid, intent)
99
+ print(f" βœ… '{cmd}' β†’ {result['message']}")
100
+ except Exception as e:
101
+ print(f" ❌ '{cmd}' β†’ error: {e}")
102
+
103
+ # ── 4. Filter test ──────────────────────────────────────────
104
+ print("\n[4] Filter test:")
105
+ intent = parse_intent(sid, "city Mumbai dikhao")
106
+ if intent:
107
+ error = validate_intent(sid, intent)
108
+ if error:
109
+ print(f" ❌ Validation: {error}")
110
+ else:
111
+ result = dispatch(sid, intent)
112
+ print(f" βœ… {result['message']}")
113
+
114
+ # ── 5. Verify final state ───────────────────────────────────
115
+ print("\n[5] Final data state:")
116
+ pq_path = f"/home/z/my-project/zero-llm-engine/data/sessions/{sid}.parquet"
117
+ if os.path.exists(pq_path):
118
+ df = pl.read_parquet(pq_path)
119
+ print(f" Rows: {len(df)}, Columns: {df.columns}")
120
+ print(df)
121
+
122
+ # ── Cleanup ─────────────────────────────────────────────────
123
+ delete_session(sid)
124
+ if os.path.exists(csv_path):
125
+ os.remove(csv_path)
126
+ print("\n[6] Cleanup done.")
127
+
128
+ # ── Server start instructions ───────────────────────────────
129
+ print("\n" + "=" * 60)
130
+ print("To start the server:")
131
+ print(" cd /home/z/my-project/zero-llm-engine")
132
+ print(" source venv/bin/activate")
133
+ print(" uvicorn main:app --host 0.0.0.0 --port 8000 --reload")
134
+ print("=" * 60)
135
+
136
+
137
+ if __name__ == "__main__":
138
+ run_tests()