Spaces:
Sleeping
Sleeping
Commit Β·
b336134
0
Parent(s):
Deploy Local Hybrid Engine
Browse files- Dockerfile +33 -0
- README.md +29 -0
- api/__init__.py +1 -0
- api/routes.py +96 -0
- api/websocket.py +96 -0
- config.py +28 -0
- core/__init__.py +1 -0
- core/a_to_z/__init__.py +1 -0
- core/a_to_z/business/__init__.py +1 -0
- core/a_to_z/business/dedup.py +54 -0
- core/a_to_z/business/find_replace.py +86 -0
- core/a_to_z/student/__init__.py +1 -0
- core/a_to_z/student/aggregate.py +89 -0
- core/a_to_z/student/column_ops.py +104 -0
- core/a_to_z/student/filter_tool.py +75 -0
- core/a_to_z/student/sort_tool.py +43 -0
- core/a_to_z/student/update.py +80 -0
- core/column_registry.py +80 -0
- core/execution/__init__.py +3 -0
- core/execution/mvcc_executor.py +365 -0
- core/execution/polars_builder.py +64 -0
- core/intent_parser.py +375 -0
- core/parser/__init__.py +3 -0
- core/parser/fallback.py +164 -0
- core/parser/local_parser/__init__.py +11 -0
- core/parser/local_parser/ast_extractor.py +107 -0
- core/parser/local_parser/embeddings.py +205 -0
- core/parser/local_parser/parser.py +303 -0
- core/parser/local_parser/spelling.py +109 -0
- core/parser/local_parser/synonyms.py +69 -0
- core/router.py +56 -0
- core/validator.py +71 -0
- main.py +66 -0
- models/__init__.py +1 -0
- models/schemas.py +43 -0
- requirements.txt +12 -0
- services/__init__.py +1 -0
- services/audit_service.py +83 -0
- services/file_manager.py +137 -0
- services/session_manager.py +120 -0
- test_local_parser.py +154 -0
- test_smoke.py +138 -0
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()
|