From 86c60f6339ebfea3850816be77a00c0f8860b95f Mon Sep 17 00:00:00 2001 From: franco ayala Date: Thu, 28 May 2026 22:23:14 -0400 Subject: [PATCH 01/12] feat: implement multi-agent natural language to SQL conversion tool with schema parsing, intent classification, and validation pipeline --- app/src/lib/tools/sql-converter.ts | 44 +++--- .../tools/sql-converter/intent_classifier.py | 96 +++++++++++++ .../tools/sql-converter/llm_client.py | 59 ++++++++ .../tools/sql-converter/requirements.txt | 3 + .../tools/sql-converter/schema_parser.py | 113 +++++++++++++++ .../tools/sql-converter/sql_generator.py | 80 +++++++++++ .../tools/sql-converter/sql_refiner.py | 73 ++++++++++ .../tools/sql-converter/sql_validator.py | 101 +++++++++++++ .../python-tools/tools/sql-converter/tool.py | 136 ++++++++++++++++++ 9 files changed, 685 insertions(+), 20 deletions(-) create mode 100644 services/python-tools/tools/sql-converter/intent_classifier.py create mode 100644 services/python-tools/tools/sql-converter/llm_client.py create mode 100644 services/python-tools/tools/sql-converter/requirements.txt create mode 100644 services/python-tools/tools/sql-converter/schema_parser.py create mode 100644 services/python-tools/tools/sql-converter/sql_generator.py create mode 100644 services/python-tools/tools/sql-converter/sql_refiner.py create mode 100644 services/python-tools/tools/sql-converter/sql_validator.py create mode 100644 services/python-tools/tools/sql-converter/tool.py diff --git a/app/src/lib/tools/sql-converter.ts b/app/src/lib/tools/sql-converter.ts index d9b4f49..e5a0bec 100644 --- a/app/src/lib/tools/sql-converter.ts +++ b/app/src/lib/tools/sql-converter.ts @@ -1,31 +1,23 @@ -import type { ToolDefinition } from "@/types"; +import type { ToolDefinition } from "@/types"; export const sqlConverter: ToolDefinition = { id: "sql-converter", name: "Natural Language to SQL", - description: "Convert plain English to dialect-aware SQL queries", + description: + "Multi-agent pipeline: parses schema, classifies intent, generates validated SQL.", category: "data", icon: "Database", status: "active", + outputFormat: "streaming-text", - defaultModel: "qwen-3-coder-30b", - requiredFields: ["query"], - buildSystemPrompt: ({ dialect, schema }) => - `You are an expert SQL developer. Convert natural language descriptions into optimized SQL queries. + // Routes requests to services/python-tools/tools/sql-converter/tool.py + tier: "tier2", -Rules: -- Target dialect: ${dialect || "PostgreSQL"} -- Output the SQL query in a fenced code block -- Include comments explaining complex parts -- Optimize for performance (proper indexing hints, JOINs over subqueries) -- If a schema is provided, respect its table/column names exactly -${schema ? `\nAvailable schema:\n\`\`\`sql\n${schema}\n\`\`\`` : ""} - -After the query, provide: -1. **Explanation** - What the query does step by step -2. **Performance Notes** - Any indexing or optimization suggestions -3. **Variations** - Alternative approaches if applicable`, - buildUserPrompt: ({ query, dialect }) => `Convert this to ${dialect || "SQL"}:\n\n${query}`, + requiredFields: ["query"], + defaultModel: "qwen-3-coder-30b", + buildSystemPrompt: () => "", + buildUserPrompt: ({ query, dialect, schema, schemaFile }) => + JSON.stringify({ query, dialect, schema, schemaFile }), inputs: [ { @@ -45,15 +37,27 @@ After the query, provide: { value: "mysql", label: "MySQL" }, { value: "sqlite", label: "SQLite" }, { value: "mssql", label: "SQL Server" }, + { value: "bigquery", label: "BigQuery" }, ], }, + { + key: "schemaFile", + label: "Upload schema file (optional)", + type: "files", + accept: ".sql,.ddl,.txt", + maxFiles: 1, + maxSizeMb: 5, + helperText: + "Drop a .sql or .ddl file here. If provided, this overrides the text field below.", + }, { key: "schema", - label: "Table schema (optional)", + label: "Or paste schema manually (optional)", type: "code", placeholder: "CREATE TABLE users (\n id SERIAL PRIMARY KEY,\n email VARCHAR(255),\n created_at TIMESTAMP\n);", rows: 6, + helperText: "Ignored when a file is uploaded above.", }, ], }; diff --git a/services/python-tools/tools/sql-converter/intent_classifier.py b/services/python-tools/tools/sql-converter/intent_classifier.py new file mode 100644 index 0000000..0ec25d0 --- /dev/null +++ b/services/python-tools/tools/sql-converter/intent_classifier.py @@ -0,0 +1,96 @@ +""" +Intent Classifier — analyses natural language and returns a structured query plan. +Model: deepseek-v3.2 (fast, good at structured JSON extraction) +""" +import json +import re + +from llm_client import call_oxlo_chat + +INTENT_MODEL = "deepseek-v3.2" + +_SYSTEM = """\ +You are an expert at analysing natural language database queries. +Respond ONLY with a valid JSON object — no markdown fences, no explanation. +The JSON must have these keys: + - "target_tables": list of table names the query touches + - "operation": one of "SELECT" | "INSERT" | "UPDATE" | "DELETE" | "DDL" + - "filters": list of filter condition descriptions (plain English) + - "aggregations": list of aggregation descriptions (e.g. "COUNT of orders", "SUM of amount") + - "joins": list of join descriptions (e.g. "users JOIN orders ON users.id = orders.user_id") + - "ordering": list of ordering descriptions + - "grouping": list of columns to group by + - "limit": integer or null + - "subquery_needed": boolean + - "complexity": one of "simple" | "moderate" | "complex" + - "ambiguities": list of strings describing unclear aspects (empty list if none) +""" + + +def _extract_json(text: str) -> dict: + text = text.strip() + text = re.sub(r"^```[a-zA-Z0-9_-]*\n?", "", text) + text = re.sub(r"```$", "", text.strip()) + try: + return json.loads(text) + except json.JSONDecodeError: + m = re.search(r"\{.*\}", text, re.DOTALL) + if m: + try: + return json.loads(m.group(0)) + except json.JSONDecodeError: + return {} + return {} + + +async def classify_intent(query: str, schema_info: dict, dialect: str) -> dict: + """ + Args: + query: Natural language query from the user. + schema_info: Output of schema_parser.parse_schema(). + dialect: Target SQL dialect (e.g. "postgresql"). + Returns: + Structured query plan dict. Falls back to minimal defaults on error. + """ + table_summary = "" + if schema_info.get("table_names"): + lines = [] + for tname, tinfo in schema_info["tables"].items(): + col_names = list(tinfo["columns"].keys()) + lines.append(f" - {tname}: {', '.join(col_names)}") + table_summary = "Available tables:\n" + "\n".join(lines) + + user_prompt = ( + f"Natural language query: {query}\n\n" + f"Target dialect: {dialect or 'postgresql'}\n" + f"{table_summary}\n\n" + "Return the JSON query plan." + ) + + try: + raw = await call_oxlo_chat( + INTENT_MODEL, + _SYSTEM, + user_prompt, + max_tokens=700, + temperature=0.1, + ) + plan = _extract_json(raw) + except Exception: + plan = {} + + defaults = { + "target_tables": [], + "operation": "SELECT", + "filters": [], + "aggregations": [], + "joins": [], + "ordering": [], + "grouping": [], + "limit": None, + "subquery_needed": False, + "complexity": "simple", + "ambiguities": [], + } + defaults.update({k: v for k, v in plan.items() if v is not None}) + return defaults diff --git a/services/python-tools/tools/sql-converter/llm_client.py b/services/python-tools/tools/sql-converter/llm_client.py new file mode 100644 index 0000000..a5efa1d --- /dev/null +++ b/services/python-tools/tools/sql-converter/llm_client.py @@ -0,0 +1,59 @@ +import os + +import httpx + +OXLO_BASE_URL = os.getenv("OXLO_BASE_URL", "https://api.oxlo.ai/v1") +OXLO_API_KEY = os.getenv("OXLO_API_KEY", "") +_CLIENT: httpx.AsyncClient | None = None + + +class OxloError(RuntimeError): + pass + + +def _get_client() -> httpx.AsyncClient: + global _CLIENT + if _CLIENT is None: + _CLIENT = httpx.AsyncClient() + return _CLIENT + + +async def call_oxlo_chat( + model: str, + system_prompt: str, + user_prompt: str, + max_tokens: int = 2048, + temperature: float = 0.3, +) -> str: + if not OXLO_API_KEY: + raise OxloError("OXLO_API_KEY not configured") + + payload = { + "model": model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "temperature": temperature, + "max_tokens": max_tokens, + } + + client = _get_client() + resp = await client.post( + f"{OXLO_BASE_URL}/chat/completions", + headers={"Authorization": f"Bearer {OXLO_API_KEY}"}, + json=payload, + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + choices = data.get("choices") + if not choices or not isinstance(choices, list): + raise OxloError(f"Unexpected API response: no choices returned. Response: {data}") + + message = choices[0].get("message", {}) if isinstance(choices[0], dict) else {} + content = message.get("content") + if content is None: + raise OxloError(f"Unexpected API response: content is None. Message: {message}") + + return content.strip() diff --git a/services/python-tools/tools/sql-converter/requirements.txt b/services/python-tools/tools/sql-converter/requirements.txt new file mode 100644 index 0000000..981f282 --- /dev/null +++ b/services/python-tools/tools/sql-converter/requirements.txt @@ -0,0 +1,3 @@ +sqlglot>=25.0.0 +pydantic>=2.0.0 +httpx>=0.27.0 diff --git a/services/python-tools/tools/sql-converter/schema_parser.py b/services/python-tools/tools/sql-converter/schema_parser.py new file mode 100644 index 0000000..372846b --- /dev/null +++ b/services/python-tools/tools/sql-converter/schema_parser.py @@ -0,0 +1,113 @@ +""" +Programmatic schema parser — no LLM call. +Parses CREATE TABLE statements using sqlglot to extract: + - table names + - column names and data types + - primary keys, foreign keys, NOT NULL constraints +""" +import sqlglot +from sqlglot import exp + + +def _extract_fk(fk: exp.ForeignKey, columns: list[str]) -> dict: + ref_table = None + ref_columns: list[str] = [] + ref = fk.args.get("reference") + if isinstance(ref, exp.Reference): + ref_table_expr = ref.this if isinstance(ref.this, exp.Table) else ref.find(exp.Table) + if ref_table_expr is not None: + ref_table = ref_table_expr.name.lower() + ref_columns = [c.name.lower() for c in ref.find_all(exp.Column)] + + return { + "columns": columns, + "references": {"table": ref_table, "columns": ref_columns}, + } + + +def parse_schema(ddl: str) -> dict: + """ + Args: + ddl: Raw DDL string (one or more CREATE TABLE statements). + Returns: + { + "tables": { + "table_name": { + "columns": { + "column_name": { + "type": "VARCHAR(255)", + "not_null": True, + }, + }, + "primary_keys": ["id"], + "foreign_keys": [ + {"columns": ["user_id"], "references": {"table": "users", "columns": ["id"]}}, + ], + }, + }, + "table_names": ["table_name"], + "raw_ddl": "..." + } + Returns {"tables": {}, "table_names": [], "raw_ddl": ddl} on parse failure. + """ + result = {"tables": {}, "table_names": [], "raw_ddl": ddl or ""} + if not ddl or not ddl.strip(): + return result + + try: + statements = sqlglot.parse(ddl) + except Exception: + return result + + for stmt in statements: + if not isinstance(stmt, exp.Create): + continue + table_expr = stmt.find(exp.Table) + if table_expr is None: + continue + table_name = table_expr.name.lower() + columns: dict = {} + pks: list[str] = [] + fks: list[dict] = [] + + for col_def in stmt.find_all(exp.ColumnDef): + col_name = col_def.name.lower() + data_type_expr = col_def.args.get("kind") + data_type = data_type_expr.sql() if data_type_expr is not None else "" + not_null = False + + for constraint in col_def.find_all(exp.ColumnConstraint): + c = constraint.this + if isinstance(c, exp.NotNullColumnConstraint): + not_null = True + elif isinstance(c, exp.PrimaryKeyColumnConstraint): + if col_name not in pks: + pks.append(col_name) + elif isinstance(c, exp.ForeignKey): + fks.append(_extract_fk(c, [col_name])) + + columns[col_name] = { + "type": data_type, + "not_null": not_null, + } + + # Table-level PRIMARY KEY / FOREIGN KEY constraints + for constraint in stmt.find_all(exp.PrimaryKey): + for col_expr in constraint.find_all(exp.Column): + col_name = col_expr.name.lower() + if col_name not in pks: + pks.append(col_name) + + for fk in stmt.find_all(exp.ForeignKey): + fk_cols = [c.name.lower() for c in fk.find_all(exp.Column)] + if fk_cols: + fks.append(_extract_fk(fk, fk_cols)) + + result["tables"][table_name] = { + "columns": columns, + "primary_keys": pks, + "foreign_keys": fks, + } + + result["table_names"] = list(result["tables"].keys()) + return result diff --git a/services/python-tools/tools/sql-converter/sql_generator.py b/services/python-tools/tools/sql-converter/sql_generator.py new file mode 100644 index 0000000..c3db0c3 --- /dev/null +++ b/services/python-tools/tools/sql-converter/sql_generator.py @@ -0,0 +1,80 @@ +""" +SQL Generator — produces dialect-specific SQL from the structured query plan. + +Model routing: + - simple / moderate → qwen-3-coder-30b (fast, accurate for standard SQL) + - complex → deepseek-r1-0528 (reasoning model for analytical queries) +""" +from llm_client import call_oxlo_chat + +GENERATOR_MODEL_DEFAULT = "qwen-3-coder-30b" +GENERATOR_MODEL_COMPLEX = "deepseek-r1-0528" + +_SYSTEM_TEMPLATE = """\ +You are an expert {dialect} developer. +Generate a single, correct SQL query for the following request. + +Rules: +1. Output ONLY a fenced SQL code block — nothing else before the opening fence. +2. Use {dialect}-specific syntax strictly (e.g. ILIKE for PostgreSQL, IFNULL for MySQL). +3. If a schema is provided, use ONLY the table and column names that appear in it. +4. Add inline comments for non-obvious logic. +5. After the code block, output: + ## Explanation + (step-by-step description) + ## Performance Notes + (indexing suggestions, join order, etc.) + ## Dialect Notes + (any {dialect}-specific caveats) +""" + + +async def generate_sql( + query: str, + intent: dict, + schema_info: dict, + dialect: str, +) -> str: + """ + Args: + query: Original natural language request. + intent: Output of intent_classifier.classify_intent(). + schema_info: Output of schema_parser.parse_schema(). + dialect: Target dialect string. + Returns: + Raw model response (SQL block + explanation text). + """ + complexity = intent.get("complexity", "simple") + model = GENERATOR_MODEL_COMPLEX if complexity == "complex" else GENERATOR_MODEL_DEFAULT + + schema_section = "" + if schema_info.get("raw_ddl", "").strip(): + schema_section = f"\nAvailable schema:\n```sql\n{schema_info['raw_ddl']}\n```\n" + + intent_section = ( + f"Query plan:\n" + f" Operation: {intent.get('operation', 'SELECT')}\n" + f" Tables: {', '.join(intent.get('target_tables', []))}\n" + f" Filters: {'; '.join(intent.get('filters', []))}\n" + f" Aggregations: {'; '.join(intent.get('aggregations', []))}\n" + f" Joins: {'; '.join(intent.get('joins', []))}\n" + f" Ordering: {'; '.join(intent.get('ordering', []))}\n" + f" Grouping: {'; '.join(intent.get('grouping', []))}\n" + f" Limit: {intent.get('limit')}\n" + ) + + user_prompt = ( + f"Natural language request:\n{query}\n\n" + f"{schema_section}\n" + f"{intent_section}\n" + "Generate the SQL query." + ) + + system = _SYSTEM_TEMPLATE.format(dialect=dialect or "PostgreSQL") + return await call_oxlo_chat( + model, + system, + user_prompt, + max_tokens=2048, + temperature=0.2, + ) diff --git a/services/python-tools/tools/sql-converter/sql_refiner.py b/services/python-tools/tools/sql-converter/sql_refiner.py new file mode 100644 index 0000000..69f6c6d --- /dev/null +++ b/services/python-tools/tools/sql-converter/sql_refiner.py @@ -0,0 +1,73 @@ +""" +SQL Refiner — feeds validation errors back to the generator and retries. +Max 2 refinement attempts. Model: qwen-3-coder-30b. +""" +from llm_client import call_oxlo_chat +from sql_validator import validate_sql + +REFINER_MODEL = "qwen-3-coder-30b" + +_SYSTEM = """\ +You are an expert SQL developer fixing a broken SQL query. +You will receive the original request, the broken SQL, and the specific errors. +Output ONLY a corrected fenced SQL code block, followed by the same ## Explanation, +## Performance Notes, and ## Dialect Notes sections as before. +Do NOT repeat the errors. Just output the corrected SQL and sections. +""" + + +async def refine_sql( + original_query: str, + previous_response: str, + validation: dict, + dialect: str, + schema_info: dict, + max_retries: int = 2, +) -> tuple[str, dict]: + """ + Args: + original_query: User's natural language request. + previous_response: Last generator output (SQL + explanation). + validation: Output of validate_sql() for the previous response. + dialect: Target dialect. + schema_info: Parsed schema. + max_retries: Maximum number of refinement attempts (default 2). + Returns: + (final_response, final_validation) — best available result after retries. + """ + response = previous_response + val = validation + + for _ in range(max_retries): + if val["valid"]: + break + + errors = val["syntax_errors"] + val["schema_errors"] + error_summary = "\n".join(f" - {e}" for e in errors) + + schema_section = "" + if schema_info.get("raw_ddl", "").strip(): + schema_section = f"\nSchema:\n```sql\n{schema_info['raw_ddl']}\n```" + + user_prompt = ( + f"Original request: {original_query}\n\n" + f"Previous SQL output:\n{response}\n\n" + f"Errors:\n{error_summary}\n" + f"Target dialect: {dialect}\n" + f"{schema_section}\n\n" + "Output the corrected SQL query." + ) + + try: + response = await call_oxlo_chat( + REFINER_MODEL, + _SYSTEM, + user_prompt, + max_tokens=2048, + temperature=0.2, + ) + val = validate_sql(response, dialect, schema_info) + except Exception: + break + + return response, val diff --git a/services/python-tools/tools/sql-converter/sql_validator.py b/services/python-tools/tools/sql-converter/sql_validator.py new file mode 100644 index 0000000..bb9b568 --- /dev/null +++ b/services/python-tools/tools/sql-converter/sql_validator.py @@ -0,0 +1,101 @@ +""" +SQL Validator — two-phase validation: + Phase 1: sqlglot syntax parse for the target dialect. + Phase 2: Column reference check against the parsed schema (if schema provided). +""" +import re + +import sqlglot +from sqlglot import exp + +# Map our dialect strings to sqlglot dialect names +_DIALECT_MAP = { + "postgresql": "postgres", + "mysql": "mysql", + "sqlite": "sqlite", + "mssql": "tsql", + "bigquery": "bigquery", +} + + +def _extract_sql(response: str) -> str: + """Pull the first SQL code block out of a markdown response.""" + m = re.search(r"```(?:sql)?\s*\n(.*?)```", response, re.DOTALL | re.IGNORECASE) + if m: + return m.group(1).strip() + lines = response.split("\n") + sql_lines = [] + for line in lines: + if line.startswith("##"): + break + sql_lines.append(line) + return "\n".join(sql_lines).strip() + + +def validate_sql(response: str, dialect: str, schema_info: dict) -> dict: + """ + Args: + response: Raw generator output (may include markdown fences + explanation). + dialect: Target dialect string (e.g. "postgresql"). + schema_info: Output of schema_parser.parse_schema(). + Returns: + { + "valid": bool, + "sql": "...", + "syntax_errors": ["..."], + "schema_errors": ["..."], + "warnings": [], + } + """ + sql = _extract_sql(response) + result = { + "valid": False, + "sql": sql, + "syntax_errors": [], + "schema_errors": [], + "warnings": [], + } + + if not sql: + result["syntax_errors"].append("No SQL found in generator output.") + return result + + dialect_key = _DIALECT_MAP.get(dialect.lower() if dialect else "", "postgres") + try: + parsed = sqlglot.parse(sql, dialect=dialect_key, error_level=sqlglot.ErrorLevel.RAISE) + if not parsed: + result["syntax_errors"].append("sqlglot returned no AST (empty parse result).") + return result + except sqlglot.errors.ParseError as exc: + result["syntax_errors"] = [str(e) for e in exc.errors] + return result + + if schema_info and schema_info.get("table_names"): + known_tables = schema_info["tables"] + all_columns: set[str] = set() + for tinfo in known_tables.values(): + all_columns.update(tinfo["columns"].keys()) + + for statement in parsed: + for col in statement.find_all(exp.Column): + col_name = col.name.lower() if col.name else "" + table_name = col.table + if table_name: + table_key = table_name.lower() + if table_key in known_tables: + if col_name and col_name not in known_tables[table_key]["columns"]: + result["schema_errors"].append( + f"Unknown column '{table_key}.{col_name}'" + ) + else: + result["warnings"].append( + f"Unrecognized table or alias '{table_name}' for column '{col.name}'" + ) + else: + if col_name and col_name not in all_columns: + result["warnings"].append( + f"Unqualified column '{col.name}' not found in schema" + ) + + result["valid"] = not result["syntax_errors"] and not result["schema_errors"] + return result diff --git a/services/python-tools/tools/sql-converter/tool.py b/services/python-tools/tools/sql-converter/tool.py new file mode 100644 index 0000000..48dc0fb --- /dev/null +++ b/services/python-tools/tools/sql-converter/tool.py @@ -0,0 +1,136 @@ +""" +SQL Converter — Multi-Agent Tool Entry Point +============================================= +Pipeline: + 1. Schema Parser — programmatic DDL parsing (sqlglot) + 2. Intent Classifier — LLM: deepseek-v3.2 + 3. SQL Generator — LLM: qwen-3-coder-30b (simple/moderate) + deepseek-r1-0528 (complex) + 4. SQL Validator — programmatic: sqlglot + schema column check + 5. SQL Refiner — LLM: qwen-3-coder-30b (max 2 retries if validation fails) +""" + +from intent_classifier import classify_intent +from schema_parser import parse_schema +from sql_generator import GENERATOR_MODEL_COMPLEX, GENERATOR_MODEL_DEFAULT, generate_sql +from sql_refiner import refine_sql +from sql_validator import validate_sql + +MANIFEST = { + "id": "sql-converter", + "name": "Natural Language to SQL", + "description": "Multi-agent pipeline: schema-aware, validated, dialect-correct SQL generation.", + "author": "Franci-343", + "version": "1.0.1", +} + + +async def run(data: dict): + query = (data.get("query") or "").strip() + dialect = (data.get("dialect") or "postgresql").strip() + + schema_ddl = (data.get("schema") or "").strip() + schema_file_raw = (data.get("schemaFile") or "").strip() + + if schema_file_raw: + # The "files" input type in page.tsx prepends a header line: + # --- FILE: my_schema.sql --- + # + # Strip that header before parsing. + lines = schema_file_raw.split("\n") + if lines and lines[0].startswith("--- FILE:"): + schema_ddl = "\n".join(lines[1:]).strip() + else: + schema_ddl = schema_file_raw + + async def stream(): + if not query: + yield "[ERROR] Query cannot be empty.\n" + return + + yield "[1/5] Parsing schema...\n" + try: + schema_info = parse_schema(schema_ddl) + except Exception as exc: + yield f"[WARN] Schema parser failed: {exc}. Continuing without schema.\n" + schema_info = {"tables": {}, "table_names": [], "raw_ddl": ""} + + yield "[2/5] Classifying intent...\n" + try: + intent = await classify_intent(query, schema_info, dialect) + except Exception as exc: + yield f"[WARN] Intent classifier failed: {exc}. Using defaults.\n" + intent = { + "target_tables": [], + "operation": "SELECT", + "filters": [], + "aggregations": [], + "joins": [], + "ordering": [], + "grouping": [], + "limit": None, + "subquery_needed": False, + "complexity": "simple", + "ambiguities": [], + } + + model_used = ( + GENERATOR_MODEL_COMPLEX + if intent.get("complexity") == "complex" + else GENERATOR_MODEL_DEFAULT + ) + yield f"[3/5] Generating SQL with {model_used}...\n" + try: + raw_response = await generate_sql(query, intent, schema_info, dialect) + except Exception as exc: + yield f"[ERROR] SQL generator failed: {exc}\n" + return + + yield "[4/5] Validating SQL...\n" + try: + validation = validate_sql(raw_response, dialect, schema_info) + except Exception as exc: + yield f"[WARN] Validator failed: {exc}. Returning unvalidated result.\n" + validation = { + "valid": True, + "sql": "", + "syntax_errors": [], + "schema_errors": [], + "warnings": [], + } + + if validation["syntax_errors"] or validation["schema_errors"]: + total_errors = len(validation["syntax_errors"]) + len(validation["schema_errors"]) + yield f" -> {total_errors} error(s) found. Entering refinement loop.\n" + else: + yield " -> Validation passed.\n" + + for w in validation.get("warnings", []): + yield f" [WARN] {w}\n" + + final_response = raw_response + final_validation = validation + + if not validation["valid"]: + yield "[5/5] Refining (max 2 attempts)...\n" + try: + final_response, final_validation = await refine_sql( + query, + raw_response, + validation, + dialect, + schema_info, + ) + except Exception as exc: + yield f"[WARN] Refiner failed: {exc}. Returning best available result.\n" + else: + yield "[5/5] No refinement needed.\n" + + if final_validation.get("warnings"): + for w in final_validation["warnings"]: + yield f" [WARN] {w}\n" + + yield "\n---RESULT---\n" + yield final_response + + return stream() From 66f5754f42559650b5e1c3c02458b683537034fd Mon Sep 17 00:00:00 2001 From: franco ayala Date: Mon, 1 Jun 2026 00:12:06 -0400 Subject: [PATCH 02/12] feat: add sandbox mode for SQL execution with mock data support --- app/src/lib/tools/sql-converter.ts | 14 +- .../tools/sql-converter/sql_sandbox.py | 296 ++++++++++++++++++ .../python-tools/tools/sql-converter/tool.py | 42 +++ 3 files changed, 350 insertions(+), 2 deletions(-) create mode 100644 services/python-tools/tools/sql-converter/sql_sandbox.py diff --git a/app/src/lib/tools/sql-converter.ts b/app/src/lib/tools/sql-converter.ts index e5a0bec..02113d6 100644 --- a/app/src/lib/tools/sql-converter.ts +++ b/app/src/lib/tools/sql-converter.ts @@ -16,8 +16,8 @@ export const sqlConverter: ToolDefinition = { requiredFields: ["query"], defaultModel: "qwen-3-coder-30b", buildSystemPrompt: () => "", - buildUserPrompt: ({ query, dialect, schema, schemaFile }) => - JSON.stringify({ query, dialect, schema, schemaFile }), + buildUserPrompt: ({ query, dialect, schema, schemaFile, sandboxQuery }) => + JSON.stringify({ query, dialect, schema, schemaFile, sandboxQuery }), inputs: [ { @@ -59,5 +59,15 @@ export const sqlConverter: ToolDefinition = { rows: 6, helperText: "Ignored when a file is uploaded above.", }, + { + key: "sandboxQuery", + label: "Sandbox test query (optional)", + type: "code", + placeholder: + "Paste the generated SELECT query here to test it against mock data created from your schema.", + rows: 8, + helperText: + "If filled, the tool skips AI generation and runs this query safely against generated mock data.", + }, ], }; diff --git a/services/python-tools/tools/sql-converter/sql_sandbox.py b/services/python-tools/tools/sql-converter/sql_sandbox.py new file mode 100644 index 0000000..d0bacbb --- /dev/null +++ b/services/python-tools/tools/sql-converter/sql_sandbox.py @@ -0,0 +1,296 @@ +""" +SQLite sandbox for generated SQL. + +Creates an in-memory database from the parsed schema, inserts deterministic +mock rows, translates the generated query to SQLite when possible, and runs +read-only statements only. +""" +from __future__ import annotations + +import json +import re +import sqlite3 +from datetime import datetime, timedelta +from typing import Any + +import sqlglot + +_DIALECT_MAP = { + "postgresql": "postgres", + "mysql": "mysql", + "sqlite": "sqlite", + "mssql": "tsql", + "bigquery": "bigquery", +} + + +def _extract_sql(sql: str) -> str: + text = (sql or "").strip() + match = re.search(r"```(?:sql)?\s*\n(.*?)```", text, re.DOTALL | re.IGNORECASE) + if match: + return match.group(1).strip() + return text + + +def _sqlite_type(raw_type: str) -> str: + t = (raw_type or "").upper() + if any(x in t for x in ("INT", "SERIAL", "BIGSERIAL")): + return "INTEGER" + if any(x in t for x in ("DECIMAL", "NUMERIC", "REAL", "DOUBLE", "FLOAT")): + return "REAL" + if any(x in t for x in ("BOOL",)): + return "INTEGER" + return "TEXT" + + +def _strip_identifier(name: str) -> str: + return re.sub(r"[^a-zA-Z0-9_]", "", name or "") + + +def _quote_identifier(name: str) -> str: + safe = _strip_identifier(name) + return f'"{safe}"' + + +def _is_read_only(sql: str, dialect: str) -> tuple[bool, str | None]: + text = (sql or "").strip() + if not text: + return False, "No SQL query was provided." + + blocked = re.compile( + r"\b(INSERT|UPDATE|DELETE|DROP|ALTER|TRUNCATE|CREATE|REPLACE|MERGE|GRANT|REVOKE|ATTACH|DETACH|PRAGMA|VACUUM)\b", + re.IGNORECASE, + ) + if blocked.search(text): + return False, "Sandbox only allows read-only SELECT/WITH queries." + + dialect_key = _DIALECT_MAP.get((dialect or "").lower(), "postgres") + try: + expressions = sqlglot.parse(text, read=dialect_key) + except Exception as exc: + return False, f"Could not parse SQL before sandbox execution: {exc}" + + for expr in expressions: + root = expr.key.upper() if getattr(expr, "key", None) else "" + if root not in {"SELECT", "WITH", "UNION"}: + return False, "Sandbox only allows SELECT/WITH style queries." + + return True, None + + +def _translate_to_sqlite(sql: str, dialect: str) -> tuple[str, str | None]: + if (dialect or "").lower() == "sqlite": + return sql, None + + dialect_key = _DIALECT_MAP.get((dialect or "").lower(), "postgres") + try: + translated = sqlglot.transpile(sql, read=dialect_key, write="sqlite") + if translated: + return ";\n".join(translated), None + except Exception as exc: + return sql, f"Could not fully translate {dialect} SQL to SQLite: {exc}" + + return sql, None + + +def _mock_value(table: str, column: str, raw_type: str, row_index: int) -> Any: + name = column.lower() + typ = (raw_type or "").upper() + + if name == "id" or name.endswith("_id"): + return row_index + if "email" in name: + return f"{table}{row_index}@example.test" + if "name" in name or "title" in name: + return f"{table.title()} {row_index}" + if "status" in name: + return ["active", "pending", "archived"][row_index % 3] + if "category" in name or "type" in name: + return ["standard", "premium", "trial"][row_index % 3] + if "created" in name or "updated" in name or "date" in name or "time" in name: + return (datetime(2026, 1, 1) + timedelta(days=row_index)).isoformat(sep=" ") + if "price" in name or "amount" in name or "total" in name or "salary" in name: + return round(19.5 + row_index * 7.25, 2) + if "count" in name or "qty" in name or "quantity" in name: + return row_index * 2 + if "BOOL" in typ: + return row_index % 2 + if any(x in typ for x in ("INT", "SERIAL")): + return row_index * 10 + if any(x in typ for x in ("DECIMAL", "NUMERIC", "REAL", "DOUBLE", "FLOAT")): + return round(row_index * 3.14, 2) + return f"{column}_{row_index}" + + +def _build_database(conn: sqlite3.Connection, schema_info: dict, rows_per_table: int) -> dict: + tables = schema_info.get("tables") or {} + preview: dict[str, list[dict[str, Any]]] = {} + + for table, info in tables.items(): + columns = info.get("columns") or {} + if not columns: + continue + + col_defs = [] + for col, cinfo in columns.items(): + col_defs.append(f"{_quote_identifier(col)} {_sqlite_type(cinfo.get('type', ''))}") + + conn.execute(f"CREATE TABLE {_quote_identifier(table)} ({', '.join(col_defs)})") + + for table, info in tables.items(): + columns = info.get("columns") or {} + if not columns: + continue + + column_names = list(columns.keys()) + placeholders = ", ".join(["?"] * len(column_names)) + insert_sql = ( + f"INSERT INTO {_quote_identifier(table)} " + f"({', '.join(_quote_identifier(c) for c in column_names)}) " + f"VALUES ({placeholders})" + ) + + rows = [] + for row_index in range(1, rows_per_table + 1): + row = { + col: _mock_value(table, col, columns[col].get("type", ""), row_index) + for col in column_names + } + rows.append(row) + conn.execute(insert_sql, [row[col] for col in column_names]) + preview[table] = rows[:3] + + conn.commit() + return preview + + +def run_sandbox(sql: str, dialect: str, schema_info: dict, rows_per_table: int = 8) -> dict: + sql = _extract_sql(sql) + safe, reason = _is_read_only(sql, dialect) + if not safe: + return { + "ok": False, + "sql": sql, + "sqliteSql": "", + "columns": [], + "rows": [], + "mockPreview": {}, + "warnings": [], + "error": reason, + } + + if not (schema_info.get("tables") or {}): + return { + "ok": False, + "sql": sql, + "sqliteSql": "", + "columns": [], + "rows": [], + "mockPreview": {}, + "warnings": [], + "error": "A schema is required to build the mock sandbox database.", + } + + sqlite_sql, translation_warning = _translate_to_sqlite(sql, dialect) + warnings = [translation_warning] if translation_warning else [] + + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + try: + preview = _build_database(conn, schema_info, rows_per_table) + cursor = conn.execute(sqlite_sql) + rows = [dict(row) for row in cursor.fetchmany(100)] + columns = [desc[0] for desc in cursor.description or []] + return { + "ok": True, + "sql": sql, + "sqliteSql": sqlite_sql, + "columns": columns, + "rows": rows, + "mockPreview": preview, + "warnings": warnings, + "error": "", + } + except Exception as exc: + return { + "ok": False, + "sql": sql, + "sqliteSql": sqlite_sql, + "columns": [], + "rows": [], + "mockPreview": {}, + "warnings": warnings, + "error": str(exc), + } + finally: + conn.close() + + +def _markdown_table(columns: list[str], rows: list[dict[str, Any]]) -> str: + if not columns: + return "_The query executed successfully and returned no columns._" + + def cell(value: Any) -> str: + return str(value if value is not None else "").replace("|", "\\|").replace("\n", " ") + + lines = [ + "| " + " | ".join(columns) + " |", + "| " + " | ".join("---" for _ in columns) + " |", + ] + for row in rows[:20]: + lines.append("| " + " | ".join(cell(row.get(column)) for column in columns) + " |") + return "\n".join(lines) + + +def sandbox_markdown(payload: dict) -> str: + status = "passed" if payload.get("ok") else "failed" + lines = [ + "\n\n## Sandbox Test", + f"Status: **{status}**", + ] + + if payload.get("error"): + lines.extend(["", f"Error: `{payload['error']}`"]) + + for warning in payload.get("warnings") or []: + lines.extend(["", f"Warning: {warning}"]) + + if payload.get("ok"): + rows = payload.get("rows") or [] + columns = payload.get("columns") or [] + lines.extend( + [ + "", + f"Rows returned from mock data: **{len(rows)}**", + "", + _markdown_table(columns, rows), + ] + ) + + preview = payload.get("mockPreview") or {} + if preview: + lines.extend(["", "### Mock Data Preview"]) + for table_name, rows in preview.items(): + lines.extend( + [ + "", + f"**{table_name}**", + "", + "```json", + json.dumps(rows, ensure_ascii=False, indent=2), + "```", + ] + ) + + if payload.get("sqliteSql") and payload.get("sqliteSql") != payload.get("sql"): + lines.extend( + [ + "", + "### SQLite Query Used In Sandbox", + "```sql", + payload["sqliteSql"], + "```", + ] + ) + + return "\n".join(lines) diff --git a/services/python-tools/tools/sql-converter/tool.py b/services/python-tools/tools/sql-converter/tool.py index 48dc0fb..eb8e52d 100644 --- a/services/python-tools/tools/sql-converter/tool.py +++ b/services/python-tools/tools/sql-converter/tool.py @@ -14,6 +14,7 @@ from schema_parser import parse_schema from sql_generator import GENERATOR_MODEL_COMPLEX, GENERATOR_MODEL_DEFAULT, generate_sql from sql_refiner import refine_sql +from sql_sandbox import run_sandbox, sandbox_markdown from sql_validator import validate_sql MANIFEST = { @@ -28,6 +29,8 @@ async def run(data: dict): query = (data.get("query") or "").strip() dialect = (data.get("dialect") or "postgresql").strip() + mode = (data.get("mode") or "generate").strip() + sandbox_query = (data.get("sandboxQuery") or "").strip() schema_ddl = (data.get("schema") or "").strip() schema_file_raw = (data.get("schemaFile") or "").strip() @@ -43,6 +46,38 @@ async def run(data: dict): else: schema_ddl = schema_file_raw + if mode == "sandbox": + try: + schema_info = parse_schema(schema_ddl) + except Exception: + schema_info = {"tables": {}, "table_names": [], "raw_ddl": ""} + return { + "result": run_sandbox( + data.get("sql") or query, + dialect, + schema_info, + int(data.get("rowsPerTable") or 8), + ) + } + + if sandbox_query: + async def sandbox_stream(): + yield "[1/2] Building mock sandbox database from schema...\n" + try: + schema_info = parse_schema(schema_ddl) + except Exception as exc: + yield f"[ERROR] Schema parser failed: {exc}\n" + return + + yield "[2/2] Running read-only SQL against mock data...\n" + payload = run_sandbox(sandbox_query, dialect, schema_info) + + yield "\n---RESULT---\n" + yield "# SQL Sandbox Result\n" + yield sandbox_markdown(payload) + + return sandbox_stream() + async def stream(): if not query: yield "[ERROR] Query cannot be empty.\n" @@ -130,7 +165,14 @@ async def stream(): for w in final_validation["warnings"]: yield f" [WARN] {w}\n" + sandbox_payload = run_sandbox( + final_validation.get("sql") or "", + dialect, + schema_info, + ) + yield "\n---RESULT---\n" yield final_response + yield sandbox_markdown(sandbox_payload) return stream() From 0117846439c7169f6ffb9d5930d5483a782d6ea4 Mon Sep 17 00:00:00 2001 From: franco ayala Date: Mon, 1 Jun 2026 17:21:21 -0400 Subject: [PATCH 03/12] improvements to the visual styles of the interface --- app/src/app/tools/[toolId]/page.tsx | 127 +++++++++++------- .../tools/sql-converter/sql_sandbox.py | 2 + 2 files changed, 81 insertions(+), 48 deletions(-) diff --git a/app/src/app/tools/[toolId]/page.tsx b/app/src/app/tools/[toolId]/page.tsx index 154a99e..17b5bfa 100644 --- a/app/src/app/tools/[toolId]/page.tsx +++ b/app/src/app/tools/[toolId]/page.tsx @@ -305,40 +305,51 @@ function InputField({ switch (config.type) { case "code": return ( -
- - +
+
+ + {config.helperText &&

{config.helperText}

} +
+
+ +
); case "textarea": return ( -
- +
+
+ + {config.helperText &&

{config.helperText}

} +