| """Natural-language to SQL translation. |
| |
| Two strategies: |
| |
| 1. ``llm_translate`` — real LLM-backed translation. Calls OpenAI or |
| Anthropic over plain HTTP (``urllib``, no extra dependencies) when an |
| API key is configured. |
| 2. ``offline_translate`` — dependency-free rules-based parser that covers |
| common English question shapes against the database's real table names. |
| """ |
|
|
| import asyncio |
| import json |
| import re |
| import urllib.request |
| from dataclasses import dataclass |
|
|
| OPENAI_URL = "https://api.openai.com/v1/chat/completions" |
| ANTHROPIC_URL = "https://api.anthropic.com/v1/messages" |
| ANTHROPIC_VERSION = "2023-06-01" |
|
|
| DEFAULT_OPENAI_MODEL = "gpt-4o-mini" |
| DEFAULT_ANTHROPIC_MODEL = "claude-3-5-haiku-20241022" |
|
|
| LLM_TIMEOUT = 25.0 |
|
|
|
|
| @dataclass |
| class Translation: |
| sql: str | None |
| source: str |
| error: str | None = None |
|
|
|
|
| def _build_prompt(table_names: list[str]) -> str: |
| quoted = ", ".join(f'"{t}"' for t in table_names) |
| return ( |
| "You translate a user question into a single read-only SQL query. " |
| "The database contains these tables: " + quoted + ".\n\n" |
| "Rules:\n" |
| "- Output ONLY the SQL statement. No explanations, no markdown.\n" |
| "- Use SELECT (or WITH ... SELECT). Never INSERT/UPDATE/DELETE/DROP.\n" |
| "- Quote identifiers that need quoting, values must be quoted properly.\n" |
| "- Return plain text SQL ending with a newline." |
| ) |
|
|
|
|
| def _extract_sql(response: str) -> str | None: |
| text = response.strip() |
| if not text: |
| return None |
| |
| fenced = re.search(r"```(?:sql)?\s*(.*?)```", text, re.IGNORECASE | re.DOTALL) |
| if fenced: |
| text = fenced.group(1).strip() |
| else: |
| |
| for ln in text.splitlines(): |
| ln = ln.strip() |
| if re.match(r"^(SELECT|WITH)\b", ln, re.IGNORECASE): |
| text = ln |
| break |
| text = text.rstrip(";").strip() |
| if re.match(r"^(SELECT|WITH)\b", text, re.IGNORECASE): |
| return text |
| return None |
|
|
|
|
| def _call_openai(api_key: str, prompt: str, question: str, model: str) -> str: |
| body = { |
| "model": model, |
| "messages": [ |
| {"role": "system", "content": prompt}, |
| {"role": "user", "content": question}, |
| ], |
| "temperature": 0, |
| } |
| req = urllib.request.Request( |
| OPENAI_URL, |
| data=json.dumps(body).encode("utf-8"), |
| headers={ |
| "Content-Type": "application/json", |
| "Authorization": f"Bearer {api_key}", |
| }, |
| method="POST", |
| ) |
| with urllib.request.urlopen(req, timeout=LLM_TIMEOUT) as resp: |
| data = json.loads(resp.read().decode("utf-8")) |
| return data["choices"][0]["message"]["content"] |
|
|
|
|
| def _call_anthropic(api_key: str, prompt: str, question: str, model: str) -> str: |
| body = { |
| "model": model, |
| "max_tokens": 512, |
| "system": prompt, |
| "messages": [{"role": "user", "content": question}], |
| } |
| req = urllib.request.Request( |
| ANTHROPIC_URL, |
| data=json.dumps(body).encode("utf-8"), |
| headers={ |
| "Content-Type": "application/json", |
| "x-api-key": api_key, |
| "anthropic-version": ANTHROPIC_VERSION, |
| }, |
| method="POST", |
| ) |
| with urllib.request.urlopen(req, timeout=LLM_TIMEOUT) as resp: |
| data = json.loads(resp.read().decode("utf-8")) |
| content = data.get("content", []) |
| return "".join(block.get("text", "") for block in content) |
|
|
|
|
| async def llm_translate( |
| question: str, |
| table_names: list[str], |
| *, |
| openai_key: str | None = None, |
| anthropic_key: str | None = None, |
| openai_model: str = DEFAULT_OPENAI_MODEL, |
| anthropic_model: str = DEFAULT_ANTHROPIC_MODEL, |
| ) -> Translation: |
| """Translate via a real LLM API call (OpenAI preferred, then Anthropic).""" |
| prompt = _build_prompt(table_names) |
|
|
| if openai_key: |
| try: |
| raw = await asyncio.wait_for( |
| asyncio.to_thread(_call_openai, openai_key, prompt, question, openai_model), |
| timeout=LLM_TIMEOUT, |
| ) |
| sql = _extract_sql(raw) |
| if sql: |
| return Translation(sql=sql, source="llm") |
| return Translation(sql=None, source="llm", error="LLM returned no usable SQL") |
| except Exception as e: |
| return Translation(sql=None, source="llm", error=str(e)) |
|
|
| if anthropic_key: |
| try: |
| raw = await asyncio.wait_for( |
| asyncio.to_thread(_call_anthropic, anthropic_key, prompt, question, anthropic_model), |
| timeout=LLM_TIMEOUT, |
| ) |
| sql = _extract_sql(raw) |
| if sql: |
| return Translation(sql=sql, source="llm") |
| return Translation(sql=None, source="llm", error="LLM returned no usable SQL") |
| except Exception as e: |
| return Translation(sql=None, source="llm", error=str(e)) |
|
|
| return Translation(sql=None, source="llm", error="No API key configured") |
|
|
|
|
| def offline_translate(question: str, table_names: list[str]) -> Translation: |
| """Rule-based fallback. English-only patterns, matched against real tables.""" |
| table_map = {t.lower(): t for t in table_names} |
| q = question.lower().strip() |
|
|
| def table_match(words: tuple[str, ...]) -> tuple[str, list[str]] | None: |
| """Return (real_name, matched_words) if any consecutive table matches.""" |
| if not words: |
| return None |
| for i in range(len(words)): |
| for size in (2, 1): |
| if i + size > len(words): |
| continue |
| chunk = " ".join(words[i : i + size]) |
| candidates = {chunk} |
| folded = chunk.replace("_", "") |
| candidates.add(folded) |
| |
| candidates.add(chunk + "s") |
| if size == 1 and chunk.endswith("s"): |
| candidates.add(chunk[:-1]) |
| for cand in candidates: |
| if cand in table_map: |
| return table_map[cand], list(words[i : i + size]) |
| return None |
|
|
| words = re.findall(r"[\w_]+", q) |
|
|
| |
| m = re.search(r"\b(?:how many|count|number of)\b\s+([\w\s]+?)\s*$", q) |
| if m: |
| hit = table_match(tuple(m.group(1).split())) |
| if hit: |
| tbl = hit[0] |
| return Translation(sql=f'SELECT COUNT(*) AS count FROM "{tbl}"', source="offline") |
|
|
| |
| m = re.search( |
| r"\b(top|best|worst|highest|lowest|most expensive|least expensive|max|min)\b\s*" |
| r"(\d+)?\s*([\w_]+?)\s+(?:in|of|from)\s+([\w\s]+)", |
| q, |
| ) |
| if m: |
| order, num_s, col_part, tbl_part = m.groups() |
| hit = table_match(tuple(tbl_part.split())) |
| if hit: |
| tbl = hit[0] |
| col_cand = col_part.strip() |
| limit = int(num_s) if num_s else 10 |
| direction = "ASC" if order in ("lowest", "least expensive", "min") else "DESC" |
| return Translation( |
| sql=f'SELECT * FROM "{tbl}" ORDER BY "{col_cand}" {direction} LIMIT {limit}', |
| source="offline", |
| ) |
|
|
| |
| m = re.search(r"\bfrom\s+([\w\s]+)", q) |
| if m: |
| hit = table_match(tuple(m.group(2).split())) |
| if hit: |
| tbl = hit[0] |
| return Translation(sql=f'SELECT * FROM "{tbl}" LIMIT 100', source="offline") |
|
|
| |
| m = re.search(r"\bwhere\b\s+([\w_]+)\s*[=:]\s*['\"]?([\w\s.,%]+?)['\"]?$", q) |
| if m: |
| col_cand, val = m.groups() |
| hit = table_match(tuple(words)) |
| if hit: |
| tbl = hit[0] |
| if re.fullmatch(r"\d+(\.\d+)?", val.strip()): |
| return Translation( |
| sql=f'SELECT * FROM "{tbl}" WHERE "{col_cand}" = {val.strip()} LIMIT 100', |
| source="offline", |
| ) |
| return Translation( |
| sql=f'SELECT * FROM "{tbl}" WHERE "{col_cand}" = \'{val.strip()}\' LIMIT 100', |
| source="offline", |
| ) |
|
|
| |
| if words: |
| hit = table_match(tuple(words)) |
| if hit: |
| tbl = hit[0] |
| return Translation(sql=f'SELECT * FROM "{tbl}" LIMIT 100', source="offline") |
|
|
| return Translation(sql=None, source="offline") |
|
|
|
|
| async def translate( |
| question: str, |
| table_names: list[str], |
| *, |
| openai_key: str | None = None, |
| anthropic_key: str | None = None, |
| ) -> Translation: |
| """Prefer the LLM path when a key is available, otherwise use the parser.""" |
| llm = await llm_translate( |
| question, |
| table_names, |
| openai_key=openai_key, |
| anthropic_key=anthropic_key, |
| ) |
| if llm.sql: |
| return llm |
|
|
| offline = offline_translate(question, table_names) |
| if offline.sql: |
| return offline |
|
|
| if llm.error and llm.error != "No API key configured": |
| return Translation(sql=None, source="llm", error=llm.error) |
| return Translation(sql=None, source="offline") |