Spaces:
Runtime error
Runtime error
Upload folder using huggingface_hub
Browse files- .gitattributes +1 -0
- Dockerfile +26 -0
- app/server.py +189 -0
- app/static/app.js +141 -0
- app/static/index.html +108 -0
- app/static/style.css +253 -0
- demo_dbs/car_1/car_1.sqlite +0 -0
- demo_dbs/concert_singer/concert_singer.sqlite +0 -0
- demo_dbs/pets_1/pets_1.sqlite +0 -0
- demo_dbs/student_transcripts_tracking/student_transcripts_tracking.sqlite +0 -0
- demo_dbs/world_1/world_1.sqlite +3 -0
- requirements-serve.txt +8 -0
- sqlforge/__init__.py +3 -0
- sqlforge/cli.py +58 -0
- sqlforge/exec_eval.py +76 -0
- sqlforge/inference.py +100 -0
- sqlforge/prompts.py +105 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
demo_dbs/world_1/world_1.sqlite filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Minimal CPU image for the Hugging Face Space (no CUDA, no training deps).
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# CPU-only torch — keeps the image small (no multi-GB CUDA wheels)
|
| 5 |
+
RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
|
| 6 |
+
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
COPY requirements-serve.txt .
|
| 9 |
+
RUN pip install --no-cache-dir -r requirements-serve.txt
|
| 10 |
+
|
| 11 |
+
# non-root user (HF Spaces convention) with a writable home for the HF model cache
|
| 12 |
+
RUN useradd -m -u 1000 user
|
| 13 |
+
COPY --chown=user . /app
|
| 14 |
+
USER user
|
| 15 |
+
|
| 16 |
+
ENV HOME=/home/user \
|
| 17 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 18 |
+
HF_HOME=/home/user/.cache/huggingface \
|
| 19 |
+
HF_HUB_OFFLINE=0 \
|
| 20 |
+
SQLFORGE_BASE=Qwen/Qwen2.5-Coder-1.5B-Instruct \
|
| 21 |
+
SQLFORGE_ADAPTER=Abdullahkousa2/sqlforge-qwen2.5-coder-1.5b \
|
| 22 |
+
SQLFORGE_4BIT=0 \
|
| 23 |
+
SQLFORGE_DB_DIR=/app/demo_dbs
|
| 24 |
+
|
| 25 |
+
EXPOSE 8000
|
| 26 |
+
CMD ["uvicorn", "app.server:app", "--host", "0.0.0.0", "--port", "8000"]
|
app/server.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SQLForge — demo server.
|
| 3 |
+
|
| 4 |
+
A FastAPI app that loads the fine-tuned text-to-SQL model and turns a natural
|
| 5 |
+
-language question + database schema into a SQL query. When a real SQLite
|
| 6 |
+
database is available it also *runs* the query, shows the results, and uses
|
| 7 |
+
self-correction (feed the DB error back to the model and retry) so you can watch
|
| 8 |
+
the agent fix its own mistakes.
|
| 9 |
+
|
| 10 |
+
Run locally:
|
| 11 |
+
uvicorn app.server:app --reload --port 8000
|
| 12 |
+
Then open http://localhost:8000
|
| 13 |
+
"""
|
| 14 |
+
import os
|
| 15 |
+
import sqlite3
|
| 16 |
+
import threading
|
| 17 |
+
import time
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
# the model + tokenizer are local; don't reach for the (flaky) HF CDN at serve time.
|
| 21 |
+
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
| 22 |
+
|
| 23 |
+
from fastapi import FastAPI, HTTPException
|
| 24 |
+
from fastapi.responses import FileResponse
|
| 25 |
+
from fastapi.staticfiles import StaticFiles
|
| 26 |
+
from pydantic import BaseModel
|
| 27 |
+
|
| 28 |
+
from sqlforge.exec_eval import run_sql, schema_from_sqlite
|
| 29 |
+
from sqlforge.inference import generate_sql, generate_sql_with_retry, load_model
|
| 30 |
+
|
| 31 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 32 |
+
STATIC = Path(__file__).resolve().parent / "static"
|
| 33 |
+
|
| 34 |
+
# --- config (all overridable by env, so HF Spaces / CI can swap paths) ----------
|
| 35 |
+
BASE_MODEL = os.environ.get("SQLFORGE_BASE", "models/qwen2.5-coder-1.5b")
|
| 36 |
+
if (ROOT / BASE_MODEL).is_dir():
|
| 37 |
+
BASE_MODEL = str(ROOT / BASE_MODEL)
|
| 38 |
+
ADAPTER = os.environ.get("SQLFORGE_ADAPTER", str(ROOT / "outputs" / "qwen2.5-coder-1.5b-sql"))
|
| 39 |
+
FOUR_BIT = os.environ.get("SQLFORGE_4BIT", "1") == "1"
|
| 40 |
+
DB_DIR = Path(os.environ.get("SQLFORGE_DB_DIR",
|
| 41 |
+
str(ROOT / "data" / "spider_raw" / "spider_data" / "database")))
|
| 42 |
+
MAX_ROWS = 100 # cap result rows sent to the browser
|
| 43 |
+
GEN_TOKENS = 192 # SQL is short; cap new tokens for a snappier demo
|
| 44 |
+
MAX_RETRIES = 1 # one self-correction retry (worst case ~2 generations, not 3)
|
| 45 |
+
|
| 46 |
+
# curated example databases — every question below is verified to run cleanly
|
| 47 |
+
# against the real DB. The last car question deliberately triggers self-correction
|
| 48 |
+
# (a JOIN it fixes itself) to showcase the agentic recovery succeeding.
|
| 49 |
+
EXAMPLES = [
|
| 50 |
+
{"db_id": "concert_singer", "label": "Concerts & Singers",
|
| 51 |
+
"questions": ["How many singers do we have?",
|
| 52 |
+
"What is the average, minimum, and maximum age of all singers?",
|
| 53 |
+
"Show the name and country of all singers ordered by age from oldest to youngest."]},
|
| 54 |
+
{"db_id": "pets_1", "label": "Students & Pets",
|
| 55 |
+
"questions": ["How many pets are there?",
|
| 56 |
+
"What is the average weight of all pets?",
|
| 57 |
+
"How many students are there?"]},
|
| 58 |
+
{"db_id": "world_1", "label": "World (countries)",
|
| 59 |
+
"questions": ["What are the names of all countries that became independent after 1950?",
|
| 60 |
+
"How many countries have a republic as their form of government?",
|
| 61 |
+
"What is the average life expectancy of countries in Africa?"]},
|
| 62 |
+
{"db_id": "car_1", "label": "Cars",
|
| 63 |
+
"questions": ["How many continents are there?",
|
| 64 |
+
"What is the maximum horsepower of any car?",
|
| 65 |
+
"How many countries does each continent have? List continent id, name and count."]},
|
| 66 |
+
{"db_id": "student_transcripts_tracking", "label": "Student Transcripts",
|
| 67 |
+
"questions": ["How many courses in total are listed?",
|
| 68 |
+
"How many students are there?",
|
| 69 |
+
"List the first and last name of every student."]},
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
# --- model state (loaded in the background so the server starts instantly) ------
|
| 73 |
+
STATE = {"status": "loading", "model": ADAPTER, "device": None, "error": None}
|
| 74 |
+
_MODEL = {"model": None, "tok": None}
|
| 75 |
+
_LOCK = threading.Lock() # one generation at a time (single GPU)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _load():
|
| 79 |
+
try:
|
| 80 |
+
model, tok = load_model(BASE_MODEL, adapter_path=ADAPTER, four_bit=FOUR_BIT)
|
| 81 |
+
_MODEL["model"], _MODEL["tok"] = model, tok
|
| 82 |
+
STATE["device"] = str(getattr(model, "device", "cuda"))
|
| 83 |
+
STATE["status"] = "online"
|
| 84 |
+
print(f"[sqlforge] model online ({STATE['device']}, 4bit={FOUR_BIT})")
|
| 85 |
+
except Exception as exc: # noqa: BLE001
|
| 86 |
+
STATE["status"] = "error"
|
| 87 |
+
STATE["error"] = str(exc)
|
| 88 |
+
print(f"[sqlforge] model failed to load: {exc}")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
app = FastAPI(title="SQLForge", description="Fine-tuned text-to-SQL demo")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@app.on_event("startup")
|
| 95 |
+
def _startup():
|
| 96 |
+
threading.Thread(target=_load, daemon=True).start()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _db_path(db_id: str) -> Path | None:
|
| 100 |
+
"""Resolve a known example db_id to its .sqlite file (no path traversal)."""
|
| 101 |
+
if not db_id or "/" in db_id or "\\" in db_id or ".." in db_id:
|
| 102 |
+
return None
|
| 103 |
+
p = DB_DIR / db_id / f"{db_id}.sqlite"
|
| 104 |
+
return p if p.exists() else None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# --- API ------------------------------------------------------------------------
|
| 108 |
+
class GenerateRequest(BaseModel):
|
| 109 |
+
question: str
|
| 110 |
+
schema_text: str | None = None # raw CREATE TABLE text (custom mode)
|
| 111 |
+
db_id: str | None = None # example DB id (executes + self-corrects)
|
| 112 |
+
self_correct: bool = True
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@app.get("/api/health")
|
| 116 |
+
def health():
|
| 117 |
+
return STATE
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@app.get("/api/examples")
|
| 121 |
+
def examples():
|
| 122 |
+
return [e for e in EXAMPLES if _db_path(e["db_id"])]
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@app.get("/api/schema")
|
| 126 |
+
def schema(db_id: str):
|
| 127 |
+
p = _db_path(db_id)
|
| 128 |
+
if not p:
|
| 129 |
+
raise HTTPException(404, f"unknown database '{db_id}'")
|
| 130 |
+
return {"db_id": db_id, "schema": schema_from_sqlite(p)}
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@app.post("/api/generate")
|
| 134 |
+
def generate(req: GenerateRequest):
|
| 135 |
+
if STATE["status"] != "online":
|
| 136 |
+
raise HTTPException(503, f"model not ready ({STATE['status']})")
|
| 137 |
+
if not req.question.strip():
|
| 138 |
+
raise HTTPException(400, "question is required")
|
| 139 |
+
|
| 140 |
+
db_path = _db_path(req.db_id) if req.db_id else None
|
| 141 |
+
schema_text = req.schema_text
|
| 142 |
+
if db_path and not schema_text:
|
| 143 |
+
schema_text = schema_from_sqlite(db_path)
|
| 144 |
+
if not schema_text:
|
| 145 |
+
raise HTTPException(400, "provide a schema or pick an example database")
|
| 146 |
+
|
| 147 |
+
model, tok = _MODEL["model"], _MODEL["tok"]
|
| 148 |
+
trace: list = []
|
| 149 |
+
t0 = time.time()
|
| 150 |
+
with _LOCK:
|
| 151 |
+
if db_path and req.self_correct:
|
| 152 |
+
sql, attempts = generate_sql_with_retry(
|
| 153 |
+
model, tok, schema_text, req.question,
|
| 154 |
+
validate=lambda s: run_sql(db_path, s)[1],
|
| 155 |
+
max_retries=MAX_RETRIES, max_new_tokens=GEN_TOKENS, trace=trace)
|
| 156 |
+
else:
|
| 157 |
+
sql = generate_sql(model, tok, schema_text, req.question,
|
| 158 |
+
max_new_tokens=GEN_TOKENS)
|
| 159 |
+
attempts = 1
|
| 160 |
+
elapsed = round(time.time() - t0, 2)
|
| 161 |
+
|
| 162 |
+
resp = {"sql": sql, "attempts": attempts, "trace": trace,
|
| 163 |
+
"self_corrected": attempts > 1, "elapsed_s": elapsed,
|
| 164 |
+
"executed": False, "columns": None, "rows": None,
|
| 165 |
+
"row_count": None, "error": None}
|
| 166 |
+
|
| 167 |
+
# if we have the real DB, run the final query and return the result preview
|
| 168 |
+
if db_path:
|
| 169 |
+
conn = sqlite3.connect(str(db_path))
|
| 170 |
+
try:
|
| 171 |
+
cur = conn.execute(sql)
|
| 172 |
+
cols = [c[0] for c in cur.description] if cur.description else []
|
| 173 |
+
rows = cur.fetchmany(MAX_ROWS)
|
| 174 |
+
resp.update(executed=True, columns=cols,
|
| 175 |
+
rows=[list(r) for r in rows], row_count=len(rows))
|
| 176 |
+
except Exception as exc: # noqa: BLE001
|
| 177 |
+
resp.update(executed=True, error=str(exc))
|
| 178 |
+
finally:
|
| 179 |
+
conn.close()
|
| 180 |
+
return resp
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# --- static frontend (mounted last so /api/* wins) ------------------------------
|
| 184 |
+
@app.get("/")
|
| 185 |
+
def index():
|
| 186 |
+
return FileResponse(STATIC / "index.html")
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
app.mount("/", StaticFiles(directory=str(STATIC)), name="static")
|
app/static/app.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// SQLForge demo — frontend logic
|
| 2 |
+
const $ = (id) => document.getElementById(id);
|
| 3 |
+
const dbSelect = $("dbSelect"), questionEl = $("question"), examplesEl = $("examples"),
|
| 4 |
+
schemaView = $("schemaView"), runBtn = $("runBtn"),
|
| 5 |
+
placeholder = $("placeholder"), result = $("result"),
|
| 6 |
+
sqlOut = $("sqlOut"), badges = $("badges"),
|
| 7 |
+
traceBlock = $("traceBlock"), traceList = $("traceList"),
|
| 8 |
+
resultsBlock = $("resultsBlock"), resultsHead = $("resultsHead"),
|
| 9 |
+
resultsTable = $("resultsTable"), errorBlock = $("errorBlock"), errorOut = $("errorOut");
|
| 10 |
+
|
| 11 |
+
let EXAMPLES = [], modelReady = false;
|
| 12 |
+
|
| 13 |
+
// ---------- model status ----------
|
| 14 |
+
async function pollHealth() {
|
| 15 |
+
const el = $("status"), label = $("statusLabel");
|
| 16 |
+
try {
|
| 17 |
+
const h = await (await fetch("/api/health")).json();
|
| 18 |
+
if (h.status === "online") { el.dataset.state = "online"; label.textContent = "model online"; modelReady = true; }
|
| 19 |
+
else if (h.status === "loading") { el.dataset.state = "loading"; label.textContent = "loading model…"; modelReady = false; }
|
| 20 |
+
else { el.dataset.state = "error"; label.textContent = "model error"; modelReady = false; }
|
| 21 |
+
} catch { el.dataset.state = "offline"; label.textContent = "offline"; modelReady = false; }
|
| 22 |
+
updateRunBtn();
|
| 23 |
+
}
|
| 24 |
+
function updateRunBtn() {
|
| 25 |
+
runBtn.disabled = !modelReady || runBtn.classList.contains("loading");
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// ---------- examples + schema ----------
|
| 29 |
+
async function loadExamples() {
|
| 30 |
+
EXAMPLES = await (await fetch("/api/examples")).json();
|
| 31 |
+
dbSelect.innerHTML = EXAMPLES.map(e => `<option value="${e.db_id}">${e.label}</option>`).join("");
|
| 32 |
+
if (EXAMPLES.length) { await onDbChange(); }
|
| 33 |
+
}
|
| 34 |
+
async function onDbChange() {
|
| 35 |
+
const ex = EXAMPLES.find(e => e.db_id === dbSelect.value);
|
| 36 |
+
examplesEl.innerHTML = (ex?.questions || [])
|
| 37 |
+
.map(q => `<span class="chip" title="${esc(q)}">${esc(q)}</span>`).join("");
|
| 38 |
+
examplesEl.querySelectorAll(".chip").forEach((c, i) => {
|
| 39 |
+
c.onclick = () => { questionEl.value = ex.questions[i]; questionEl.focus(); };
|
| 40 |
+
});
|
| 41 |
+
try {
|
| 42 |
+
const s = await (await fetch(`/api/schema?db_id=${encodeURIComponent(dbSelect.value)}`)).json();
|
| 43 |
+
schemaView.textContent = s.schema;
|
| 44 |
+
} catch { schemaView.textContent = "—"; }
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// ---------- generate ----------
|
| 48 |
+
async function run() {
|
| 49 |
+
const question = questionEl.value.trim();
|
| 50 |
+
if (!question || !modelReady) return;
|
| 51 |
+
runBtn.classList.add("loading"); updateRunBtn();
|
| 52 |
+
runBtn.querySelector(".btn-label").textContent = "Generating…";
|
| 53 |
+
try {
|
| 54 |
+
const resp = await fetch("/api/generate", {
|
| 55 |
+
method: "POST", headers: { "Content-Type": "application/json" },
|
| 56 |
+
body: JSON.stringify({ question, db_id: dbSelect.value, self_correct: true }),
|
| 57 |
+
});
|
| 58 |
+
if (!resp.ok) { const e = await resp.json().catch(() => ({})); throw new Error(e.detail || resp.statusText); }
|
| 59 |
+
render(await resp.json());
|
| 60 |
+
} catch (err) {
|
| 61 |
+
placeholder.classList.add("hidden"); result.classList.remove("hidden");
|
| 62 |
+
showError("Request failed", err.message);
|
| 63 |
+
} finally {
|
| 64 |
+
runBtn.classList.remove("loading"); updateRunBtn();
|
| 65 |
+
runBtn.querySelector(".btn-label").textContent = "Generate SQL";
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function render(r) {
|
| 70 |
+
placeholder.classList.add("hidden"); result.classList.remove("hidden");
|
| 71 |
+
sqlOut.innerHTML = highlight(r.sql);
|
| 72 |
+
|
| 73 |
+
// badges
|
| 74 |
+
const b = [];
|
| 75 |
+
b.push(`<span class="badge">${r.elapsed_s}s</span>`);
|
| 76 |
+
if (r.self_corrected) b.push(`<span class="badge fix">🛠️ self-corrected (${r.attempts} tries)</span>`);
|
| 77 |
+
if (r.executed && !r.error) b.push(`<span class="badge good">✓ ran · ${r.row_count} row${r.row_count === 1 ? "" : "s"}</span>`);
|
| 78 |
+
if (r.executed && r.error) b.push(`<span class="badge warn">✕ execution error</span>`);
|
| 79 |
+
badges.innerHTML = b.join("");
|
| 80 |
+
|
| 81 |
+
// self-correction trace (only show if there was more than one attempt)
|
| 82 |
+
if (r.trace && r.trace.length > 1) {
|
| 83 |
+
traceList.innerHTML = r.trace.map(step => {
|
| 84 |
+
const ok = !step.error;
|
| 85 |
+
return `<div class="trace-step">
|
| 86 |
+
<div class="tlabel">Attempt ${step.attempt}
|
| 87 |
+
<span class="tag ${ok ? "ok" : "fail"}">${ok ? "ran clean" : "crashed"}</span></div>
|
| 88 |
+
<pre class="code sql mono">${highlight(step.sql)}</pre>
|
| 89 |
+
${step.error ? `<div class="terr">↳ ${esc(step.error)}</div>` : ""}
|
| 90 |
+
</div>`;
|
| 91 |
+
}).join("");
|
| 92 |
+
traceBlock.classList.remove("hidden");
|
| 93 |
+
} else { traceBlock.classList.add("hidden"); }
|
| 94 |
+
|
| 95 |
+
// results / error
|
| 96 |
+
errorBlock.classList.add("hidden"); resultsBlock.classList.add("hidden");
|
| 97 |
+
if (r.executed && r.error) { showError("Execution error", r.error); }
|
| 98 |
+
else if (r.executed) { renderTable(r.columns, r.rows, r.row_count); }
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function renderTable(cols, rows, n) {
|
| 102 |
+
if (!cols || !cols.length) { resultsBlock.classList.add("hidden"); return; }
|
| 103 |
+
resultsHead.textContent = `Query results — ${n} row${n === 1 ? "" : "s"}`;
|
| 104 |
+
const head = `<thead><tr>${cols.map(c => `<th>${esc(c)}</th>`).join("")}</tr></thead>`;
|
| 105 |
+
const body = `<tbody>${rows.map(row =>
|
| 106 |
+
`<tr>${row.map(c => `<td>${esc(c === null ? "NULL" : String(c))}</td>`).join("")}</tr>`).join("")}</tbody>`;
|
| 107 |
+
resultsTable.innerHTML = head + (rows.length ? body : `<tbody><tr><td colspan="${cols.length}" class="muted">(no rows returned)</td></tr></tbody>`);
|
| 108 |
+
resultsBlock.classList.remove("hidden");
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function showError(title, msg) {
|
| 112 |
+
document.querySelector("#errorBlock .block-head span").textContent = title;
|
| 113 |
+
errorOut.textContent = msg; errorBlock.classList.remove("hidden");
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// ---------- tiny SQL highlighter ----------
|
| 117 |
+
const KW = /\b(SELECT|FROM|WHERE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|AS|AND|OR|NOT|IN|IS|NULL|GROUP|BY|ORDER|HAVING|LIMIT|OFFSET|DISTINCT|UNION|ALL|INTERSECT|EXCEPT|INSERT|INTO|VALUES|UPDATE|SET|DELETE|CREATE|TABLE|LIKE|BETWEEN|ASC|DESC|CASE|WHEN|THEN|ELSE|END|EXISTS)\b/gi;
|
| 118 |
+
const FN = /\b(COUNT|SUM|AVG|MIN|MAX|ROUND|ABS|LENGTH|LOWER|UPPER|SUBSTR|COALESCE|CAST)\b/gi;
|
| 119 |
+
function highlight(sql) {
|
| 120 |
+
let h = esc(sql || "");
|
| 121 |
+
h = h.replace(/'([^']*)'/g, '<span class="str">\'$1\'</span>');
|
| 122 |
+
h = h.replace(/\b(\d+(\.\d+)?)\b/g, '<span class="num">$1</span>');
|
| 123 |
+
h = h.replace(FN, (m) => `<span class="fn">${m}</span>`);
|
| 124 |
+
h = h.replace(KW, (m) => `<span class="kw">${m}</span>`);
|
| 125 |
+
return h;
|
| 126 |
+
}
|
| 127 |
+
function esc(s) { return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
| 128 |
+
|
| 129 |
+
// ---------- wire up ----------
|
| 130 |
+
dbSelect.onchange = onDbChange;
|
| 131 |
+
runBtn.onclick = run;
|
| 132 |
+
questionEl.addEventListener("keydown", (e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) run(); });
|
| 133 |
+
$("copyBtn").onclick = () => {
|
| 134 |
+
navigator.clipboard.writeText(sqlOut.textContent).then(() => {
|
| 135 |
+
const b = $("copyBtn"); b.textContent = "copied!"; setTimeout(() => b.textContent = "copy", 1200);
|
| 136 |
+
});
|
| 137 |
+
};
|
| 138 |
+
|
| 139 |
+
loadExamples();
|
| 140 |
+
pollHealth();
|
| 141 |
+
setInterval(pollHealth, 4000);
|
app/static/index.html
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>SQLForge — Text-to-SQL</title>
|
| 7 |
+
<link rel="stylesheet" href="/style.css" />
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div class="aurora" aria-hidden="true"></div>
|
| 11 |
+
|
| 12 |
+
<header class="topbar">
|
| 13 |
+
<div class="brand">
|
| 14 |
+
<div class="logo" aria-hidden="true">
|
| 15 |
+
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 16 |
+
<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5"/><path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/>
|
| 17 |
+
</svg>
|
| 18 |
+
</div>
|
| 19 |
+
<div class="brand-text">
|
| 20 |
+
<span class="brand-name">SQL<span class="accent">Forge</span></span>
|
| 21 |
+
<span class="brand-sub">fine-tuned text-to-SQL</span>
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
|
| 25 |
+
<div class="status" id="status" data-state="loading" title="Model status">
|
| 26 |
+
<span class="dot"></span>
|
| 27 |
+
<span class="status-label" id="statusLabel">connecting…</span>
|
| 28 |
+
</div>
|
| 29 |
+
</header>
|
| 30 |
+
|
| 31 |
+
<main class="layout">
|
| 32 |
+
<!-- INPUT -->
|
| 33 |
+
<section class="panel input-panel">
|
| 34 |
+
<div class="panel-head">
|
| 35 |
+
<h2>Ask in plain English</h2>
|
| 36 |
+
<p class="muted">Pick a database, type a question, and watch the model write & run the SQL.</p>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
<label class="field-label" for="dbSelect">Database</label>
|
| 40 |
+
<div class="select-wrap">
|
| 41 |
+
<select id="dbSelect"></select>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<label class="field-label" for="question">Question</label>
|
| 45 |
+
<textarea id="question" rows="3" placeholder="e.g. How many singers do we have?"></textarea>
|
| 46 |
+
|
| 47 |
+
<div class="examples" id="examples"></div>
|
| 48 |
+
|
| 49 |
+
<details class="schema-box">
|
| 50 |
+
<summary>Schema being used <span class="chev">›</span></summary>
|
| 51 |
+
<pre id="schemaView" class="code mono">—</pre>
|
| 52 |
+
</details>
|
| 53 |
+
|
| 54 |
+
<button id="runBtn" class="run-btn">
|
| 55 |
+
<span class="btn-label">Generate SQL</span>
|
| 56 |
+
<span class="spinner" aria-hidden="true"></span>
|
| 57 |
+
</button>
|
| 58 |
+
</section>
|
| 59 |
+
|
| 60 |
+
<!-- OUTPUT -->
|
| 61 |
+
<section class="panel output-panel">
|
| 62 |
+
<div class="panel-head row">
|
| 63 |
+
<h2>Result</h2>
|
| 64 |
+
<div class="badges" id="badges"></div>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<div id="placeholder" class="placeholder">
|
| 68 |
+
<svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
| 69 |
+
<path d="m18 16 4-4-4-4"/><path d="m6 8-4 4 4 4"/><path d="m14.5 4-5 16"/>
|
| 70 |
+
</svg>
|
| 71 |
+
<p>Your generated query and live results will appear here.</p>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div id="result" class="result hidden">
|
| 75 |
+
<div class="block">
|
| 76 |
+
<div class="block-head"><span>Generated SQL</span><button class="copy" id="copyBtn">copy</button></div>
|
| 77 |
+
<pre id="sqlOut" class="code sql mono"></pre>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
<div id="traceBlock" class="block trace hidden">
|
| 81 |
+
<div class="block-head"><span>🛠️ Self-correction trace</span></div>
|
| 82 |
+
<div id="traceList"></div>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
<div id="resultsBlock" class="block hidden">
|
| 86 |
+
<div class="block-head"><span id="resultsHead">Query results</span></div>
|
| 87 |
+
<div class="table-scroll"><table id="resultsTable"></table></div>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<div id="errorBlock" class="block error hidden">
|
| 91 |
+
<div class="block-head"><span>Execution error</span></div>
|
| 92 |
+
<pre id="errorOut" class="code mono"></pre>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
</section>
|
| 96 |
+
</main>
|
| 97 |
+
|
| 98 |
+
<footer class="footer">
|
| 99 |
+
<span>Qwen2.5-Coder-1.5B · QLoRA · Spider</span>
|
| 100 |
+
<span class="sep">•</span>
|
| 101 |
+
<span><b class="accent">65.6%</b> execution accuracy <span class="muted">(vs 57.4% base)</span></span>
|
| 102 |
+
<span class="sep">•</span>
|
| 103 |
+
<span class="muted">runs SQL against real SQLite DBs</span>
|
| 104 |
+
</footer>
|
| 105 |
+
|
| 106 |
+
<script src="/app.js"></script>
|
| 107 |
+
</body>
|
| 108 |
+
</html>
|
app/static/style.css
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* SQLForge — dark "forge" theme: deep slate + molten amber, cyan for SQL */
|
| 2 |
+
:root {
|
| 3 |
+
--bg: #0a0e16;
|
| 4 |
+
--bg-2: #0e131e;
|
| 5 |
+
--panel: #131a28;
|
| 6 |
+
--panel-2: #161e2e;
|
| 7 |
+
--border: #243149;
|
| 8 |
+
--border-soft: #1c2638;
|
| 9 |
+
--text: #e7ecf4;
|
| 10 |
+
--text-dim: #9aa7bd;
|
| 11 |
+
--muted: #6b7890;
|
| 12 |
+
--amber: #f59e0b;
|
| 13 |
+
--amber-2: #f97316;
|
| 14 |
+
--cyan: #38bdf8;
|
| 15 |
+
--green: #34d399;
|
| 16 |
+
--red: #f87171;
|
| 17 |
+
--radius: 14px;
|
| 18 |
+
--shadow: 0 18px 50px -18px rgba(0,0,0,.7);
|
| 19 |
+
--mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
| 20 |
+
--sans: "Inter", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
* { box-sizing: border-box; }
|
| 24 |
+
html, body { margin: 0; padding: 0; }
|
| 25 |
+
body {
|
| 26 |
+
font-family: var(--sans);
|
| 27 |
+
background: var(--bg);
|
| 28 |
+
color: var(--text);
|
| 29 |
+
min-height: 100vh;
|
| 30 |
+
line-height: 1.55;
|
| 31 |
+
-webkit-font-smoothing: antialiased;
|
| 32 |
+
position: relative;
|
| 33 |
+
overflow-x: hidden;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/* ambient glow */
|
| 37 |
+
.aurora {
|
| 38 |
+
position: fixed; inset: 0; z-index: 0; pointer-events: none;
|
| 39 |
+
background:
|
| 40 |
+
radial-gradient(60rem 40rem at 78% -8%, rgba(249,115,22,.16), transparent 60%),
|
| 41 |
+
radial-gradient(48rem 34rem at 8% 4%, rgba(56,189,248,.10), transparent 55%),
|
| 42 |
+
radial-gradient(40rem 40rem at 50% 120%, rgba(245,158,11,.08), transparent 60%);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/* ---------- top bar ---------- */
|
| 46 |
+
.topbar {
|
| 47 |
+
position: relative; z-index: 2;
|
| 48 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 49 |
+
padding: 18px clamp(18px, 4vw, 44px);
|
| 50 |
+
border-bottom: 1px solid var(--border-soft);
|
| 51 |
+
background: rgba(10,14,22,.6);
|
| 52 |
+
backdrop-filter: blur(10px);
|
| 53 |
+
}
|
| 54 |
+
.brand { display: flex; align-items: center; gap: 13px; }
|
| 55 |
+
.logo {
|
| 56 |
+
display: grid; place-items: center;
|
| 57 |
+
width: 42px; height: 42px; border-radius: 12px;
|
| 58 |
+
color: #1a1205;
|
| 59 |
+
background: linear-gradient(140deg, var(--amber), var(--amber-2));
|
| 60 |
+
box-shadow: 0 6px 20px -6px rgba(245,158,11,.6);
|
| 61 |
+
}
|
| 62 |
+
.brand-text { display: flex; flex-direction: column; line-height: 1.1; }
|
| 63 |
+
.brand-name { font-weight: 800; font-size: 19px; letter-spacing: -.02em; }
|
| 64 |
+
.brand-sub { font-size: 12px; color: var(--muted); letter-spacing: .02em; }
|
| 65 |
+
.accent {
|
| 66 |
+
background: linear-gradient(120deg, var(--amber), var(--amber-2));
|
| 67 |
+
-webkit-background-clip: text; background-clip: text; color: transparent;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/* status pill */
|
| 71 |
+
.status {
|
| 72 |
+
display: inline-flex; align-items: center; gap: 9px;
|
| 73 |
+
padding: 8px 14px; border-radius: 999px;
|
| 74 |
+
font-size: 13px; font-weight: 600;
|
| 75 |
+
border: 1px solid var(--border);
|
| 76 |
+
background: var(--panel);
|
| 77 |
+
}
|
| 78 |
+
.status .dot {
|
| 79 |
+
width: 9px; height: 9px; border-radius: 50%; background: var(--muted);
|
| 80 |
+
box-shadow: 0 0 0 0 rgba(0,0,0,0);
|
| 81 |
+
}
|
| 82 |
+
.status[data-state="online"] { color: var(--green); border-color: rgba(52,211,153,.35); }
|
| 83 |
+
.status[data-state="online"] .dot { background: var(--green); box-shadow: 0 0 10px 1px rgba(52,211,153,.7); }
|
| 84 |
+
.status[data-state="loading"] { color: var(--amber); border-color: rgba(245,158,11,.35); }
|
| 85 |
+
.status[data-state="loading"] .dot { background: var(--amber); animation: pulse 1.2s infinite; }
|
| 86 |
+
.status[data-state="error"],
|
| 87 |
+
.status[data-state="offline"] { color: var(--red); border-color: rgba(248,113,113,.35); }
|
| 88 |
+
.status[data-state="error"] .dot,
|
| 89 |
+
.status[data-state="offline"] .dot { background: var(--red); }
|
| 90 |
+
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
|
| 91 |
+
|
| 92 |
+
/* ---------- layout ---------- */
|
| 93 |
+
.layout {
|
| 94 |
+
position: relative; z-index: 1;
|
| 95 |
+
display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1.15fr);
|
| 96 |
+
gap: 22px;
|
| 97 |
+
padding: clamp(18px, 3vw, 32px) clamp(18px, 4vw, 44px);
|
| 98 |
+
max-width: 1280px; margin: 0 auto;
|
| 99 |
+
}
|
| 100 |
+
@media (max-width: 880px) { .layout { grid-template-columns: 1fr; } }
|
| 101 |
+
|
| 102 |
+
.panel {
|
| 103 |
+
background: linear-gradient(180deg, var(--panel), var(--bg-2));
|
| 104 |
+
border: 1px solid var(--border-soft);
|
| 105 |
+
border-radius: var(--radius);
|
| 106 |
+
padding: 22px;
|
| 107 |
+
box-shadow: var(--shadow);
|
| 108 |
+
}
|
| 109 |
+
.panel-head h2 { margin: 0 0 4px; font-size: 16px; font-weight: 700; letter-spacing: -.01em; }
|
| 110 |
+
.panel-head.row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
| 111 |
+
.muted { color: var(--muted); }
|
| 112 |
+
.panel-head p { margin: 0 0 6px; font-size: 13px; }
|
| 113 |
+
|
| 114 |
+
/* ---------- form ---------- */
|
| 115 |
+
.field-label {
|
| 116 |
+
display: block; margin: 16px 0 7px;
|
| 117 |
+
font-size: 12px; font-weight: 600; text-transform: uppercase;
|
| 118 |
+
letter-spacing: .06em; color: var(--text-dim);
|
| 119 |
+
}
|
| 120 |
+
.select-wrap { position: relative; }
|
| 121 |
+
.select-wrap::after {
|
| 122 |
+
content: "▾"; position: absolute; right: 14px; top: 50%; transform: translateY(-50%);
|
| 123 |
+
color: var(--muted); pointer-events: none; font-size: 12px;
|
| 124 |
+
}
|
| 125 |
+
select, textarea {
|
| 126 |
+
width: 100%; font-family: var(--sans); font-size: 14px; color: var(--text);
|
| 127 |
+
background: var(--bg); border: 1px solid var(--border);
|
| 128 |
+
border-radius: 10px; padding: 12px 14px; outline: none;
|
| 129 |
+
transition: border-color .15s, box-shadow .15s;
|
| 130 |
+
}
|
| 131 |
+
select { appearance: none; cursor: pointer; padding-right: 34px; }
|
| 132 |
+
textarea { resize: vertical; min-height: 64px; }
|
| 133 |
+
select:focus, textarea:focus {
|
| 134 |
+
border-color: var(--amber);
|
| 135 |
+
box-shadow: 0 0 0 3px rgba(245,158,11,.15);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.examples { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
| 139 |
+
.chip {
|
| 140 |
+
font-size: 12.5px; color: var(--text-dim); cursor: pointer;
|
| 141 |
+
background: var(--panel-2); border: 1px solid var(--border);
|
| 142 |
+
padding: 7px 12px; border-radius: 999px;
|
| 143 |
+
transition: all .15s; line-height: 1.3; max-width: 100%;
|
| 144 |
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
| 145 |
+
}
|
| 146 |
+
.chip:hover { color: var(--text); border-color: var(--amber); background: rgba(245,158,11,.08); }
|
| 147 |
+
|
| 148 |
+
.schema-box { margin-top: 16px; border-top: 1px solid var(--border-soft); padding-top: 12px; }
|
| 149 |
+
.schema-box summary {
|
| 150 |
+
cursor: pointer; font-size: 13px; color: var(--text-dim); font-weight: 600;
|
| 151 |
+
list-style: none; display: flex; align-items: center; gap: 6px;
|
| 152 |
+
}
|
| 153 |
+
.schema-box summary::-webkit-details-marker { display: none; }
|
| 154 |
+
.schema-box .chev { transition: transform .2s; color: var(--muted); }
|
| 155 |
+
.schema-box[open] .chev { transform: rotate(90deg); }
|
| 156 |
+
.schema-box pre { margin: 10px 0 0; max-height: 220px; overflow: auto; }
|
| 157 |
+
|
| 158 |
+
/* run button */
|
| 159 |
+
.run-btn {
|
| 160 |
+
position: relative; width: 100%; margin-top: 18px;
|
| 161 |
+
font-family: var(--sans); font-size: 15px; font-weight: 700; color: #1a1205;
|
| 162 |
+
background: linear-gradient(120deg, var(--amber), var(--amber-2));
|
| 163 |
+
border: none; border-radius: 11px; padding: 13px 18px; cursor: pointer;
|
| 164 |
+
display: flex; align-items: center; justify-content: center; gap: 10px;
|
| 165 |
+
box-shadow: 0 10px 26px -10px rgba(245,158,11,.65);
|
| 166 |
+
transition: transform .12s, box-shadow .12s, filter .12s;
|
| 167 |
+
}
|
| 168 |
+
.run-btn:hover { transform: translateY(-1px); filter: brightness(1.05); }
|
| 169 |
+
.run-btn:active { transform: translateY(0); }
|
| 170 |
+
.run-btn:disabled { cursor: not-allowed; filter: grayscale(.4) brightness(.8); box-shadow: none; }
|
| 171 |
+
.run-btn .spinner {
|
| 172 |
+
width: 16px; height: 16px; border-radius: 50%; display: none;
|
| 173 |
+
border: 2px solid rgba(26,18,5,.35); border-top-color: #1a1205;
|
| 174 |
+
animation: spin .7s linear infinite;
|
| 175 |
+
}
|
| 176 |
+
.run-btn.loading .spinner { display: inline-block; }
|
| 177 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 178 |
+
|
| 179 |
+
/* ---------- output ---------- */
|
| 180 |
+
.badges { display: flex; flex-wrap: wrap; gap: 7px; }
|
| 181 |
+
.badge {
|
| 182 |
+
font-size: 11.5px; font-weight: 600; padding: 4px 10px; border-radius: 999px;
|
| 183 |
+
border: 1px solid var(--border); color: var(--text-dim); background: var(--panel-2);
|
| 184 |
+
white-space: nowrap;
|
| 185 |
+
}
|
| 186 |
+
.badge.good { color: var(--green); border-color: rgba(52,211,153,.35); background: rgba(52,211,153,.08); }
|
| 187 |
+
.badge.fix { color: var(--amber); border-color: rgba(245,158,11,.35); background: rgba(245,158,11,.08); }
|
| 188 |
+
.badge.warn { color: var(--red); border-color: rgba(248,113,113,.35); background: rgba(248,113,113,.08); }
|
| 189 |
+
|
| 190 |
+
.placeholder {
|
| 191 |
+
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
| 192 |
+
gap: 14px; text-align: center; color: var(--muted);
|
| 193 |
+
min-height: 320px; border: 1px dashed var(--border); border-radius: 12px; margin-top: 16px;
|
| 194 |
+
}
|
| 195 |
+
.placeholder svg { color: var(--border); }
|
| 196 |
+
|
| 197 |
+
.result { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
|
| 198 |
+
.hidden { display: none !important; }
|
| 199 |
+
|
| 200 |
+
.block { border: 1px solid var(--border-soft); border-radius: 11px; overflow: hidden; background: var(--bg); }
|
| 201 |
+
.block-head {
|
| 202 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 203 |
+
padding: 9px 14px; font-size: 12.5px; font-weight: 600; color: var(--text-dim);
|
| 204 |
+
background: var(--panel-2); border-bottom: 1px solid var(--border-soft);
|
| 205 |
+
}
|
| 206 |
+
.block.trace .block-head { color: var(--amber); }
|
| 207 |
+
.block.error { border-color: rgba(248,113,113,.4); }
|
| 208 |
+
.block.error .block-head { color: var(--red); background: rgba(248,113,113,.07); }
|
| 209 |
+
|
| 210 |
+
.code { font-family: var(--mono); font-size: 13px; padding: 14px; margin: 0; overflow: auto; line-height: 1.6; }
|
| 211 |
+
pre.code { white-space: pre-wrap; word-break: break-word; }
|
| 212 |
+
.sql .kw { color: var(--cyan); font-weight: 600; }
|
| 213 |
+
.sql .fn { color: var(--amber); }
|
| 214 |
+
.sql .str { color: var(--green); }
|
| 215 |
+
.sql .num { color: #c4b5fd; }
|
| 216 |
+
|
| 217 |
+
.copy {
|
| 218 |
+
font-family: var(--sans); font-size: 11.5px; color: var(--text-dim); cursor: pointer;
|
| 219 |
+
background: transparent; border: 1px solid var(--border); border-radius: 6px; padding: 3px 9px;
|
| 220 |
+
transition: all .15s;
|
| 221 |
+
}
|
| 222 |
+
.copy:hover { color: var(--text); border-color: var(--amber); }
|
| 223 |
+
|
| 224 |
+
/* self-correction trace */
|
| 225 |
+
.trace-step { padding: 11px 14px; border-bottom: 1px solid var(--border-soft); }
|
| 226 |
+
.trace-step:last-child { border-bottom: none; }
|
| 227 |
+
.trace-step .tlabel { font-size: 12px; font-weight: 600; margin-bottom: 6px; display: flex; align-items: center; gap: 7px; }
|
| 228 |
+
.trace-step .tlabel .tag { font-size: 10.5px; padding: 2px 7px; border-radius: 999px; }
|
| 229 |
+
.trace-step .tag.fail { color: var(--red); background: rgba(248,113,113,.12); }
|
| 230 |
+
.trace-step .tag.ok { color: var(--green); background: rgba(52,211,153,.12); }
|
| 231 |
+
.trace-step pre { margin: 0; font-size: 12.5px; }
|
| 232 |
+
.trace-step .terr { color: var(--red); font-size: 12px; margin-top: 6px; font-family: var(--mono); }
|
| 233 |
+
|
| 234 |
+
/* results table */
|
| 235 |
+
.table-scroll { overflow: auto; max-height: 360px; }
|
| 236 |
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
| 237 |
+
th, td {
|
| 238 |
+
text-align: left; padding: 8px 14px; border-bottom: 1px solid var(--border-soft);
|
| 239 |
+
white-space: nowrap; font-family: var(--mono); font-size: 12.5px;
|
| 240 |
+
}
|
| 241 |
+
th { position: sticky; top: 0; background: var(--panel-2); color: var(--text-dim); font-weight: 600; z-index: 1; }
|
| 242 |
+
td { color: var(--text); }
|
| 243 |
+
tbody tr:hover td { background: rgba(255,255,255,.02); }
|
| 244 |
+
|
| 245 |
+
/* footer */
|
| 246 |
+
.footer {
|
| 247 |
+
position: relative; z-index: 1;
|
| 248 |
+
display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 10px;
|
| 249 |
+
padding: 22px 18px 32px; font-size: 12.5px; color: var(--text-dim);
|
| 250 |
+
border-top: 1px solid var(--border-soft); margin-top: 8px;
|
| 251 |
+
}
|
| 252 |
+
.footer .sep { color: var(--border); }
|
| 253 |
+
.footer b { font-weight: 700; }
|
demo_dbs/car_1/car_1.sqlite
ADDED
|
Binary file (65.5 kB). View file
|
|
|
demo_dbs/concert_singer/concert_singer.sqlite
ADDED
|
Binary file (36.9 kB). View file
|
|
|
demo_dbs/pets_1/pets_1.sqlite
ADDED
|
Binary file (16.4 kB). View file
|
|
|
demo_dbs/student_transcripts_tracking/student_transcripts_tracking.sqlite
ADDED
|
Binary file (49.2 kB). View file
|
|
|
demo_dbs/world_1/world_1.sqlite
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:17b986695f16786d58d66f85e49dba87bdfe72953207ab9b1b49da9d2301ef65
|
| 3 |
+
size 319488
|
requirements-serve.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Lean serving deps for the HF Space (CPU). torch is installed separately in the
|
| 2 |
+
# Dockerfile (CPU wheel). No bitsandbytes/trl/datasets/wandb — those are train-only.
|
| 3 |
+
transformers>=4.45.0
|
| 4 |
+
peft>=0.13.0
|
| 5 |
+
accelerate>=1.0.0
|
| 6 |
+
fastapi>=0.111.0
|
| 7 |
+
uvicorn[standard]>=0.30.0
|
| 8 |
+
huggingface_hub>=0.25.0
|
sqlforge/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLForge — fine-tuned text-to-SQL: shared library for prompts, inference, and eval."""
|
| 2 |
+
|
| 3 |
+
__version__ = "0.1.0"
|
sqlforge/cli.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""sqlforge CLI — generate (and optionally run) SQL from a natural-language question.
|
| 2 |
+
|
| 3 |
+
Examples:
|
| 4 |
+
sqlforge -q "How many users signed up after 2020?" --db app.sqlite --run
|
| 5 |
+
sqlforge -q "list all singers" --schema 'CREATE TABLE singer ("Name" text);'
|
| 6 |
+
"""
|
| 7 |
+
import argparse
|
| 8 |
+
import os
|
| 9 |
+
import sys
|
| 10 |
+
|
| 11 |
+
DEFAULT_BASE = "Qwen/Qwen2.5-Coder-1.5B-Instruct"
|
| 12 |
+
DEFAULT_ADAPTER = "Abdullahkousa2/sqlforge-qwen2.5-coder-1.5b"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def main() -> None:
|
| 16 |
+
ap = argparse.ArgumentParser(
|
| 17 |
+
prog="sqlforge",
|
| 18 |
+
description="Turn a natural-language question into SQL with the fine-tuned SQLForge model.")
|
| 19 |
+
ap.add_argument("-q", "--question", required=True, help="the question to answer in SQL")
|
| 20 |
+
src = ap.add_mutually_exclusive_group(required=True)
|
| 21 |
+
src.add_argument("--db", help="path to a SQLite database (schema auto-extracted)")
|
| 22 |
+
src.add_argument("--schema", help="raw CREATE TABLE text, or a path to a .sql file")
|
| 23 |
+
ap.add_argument("--base", default=DEFAULT_BASE, help="base model (HF id or local path)")
|
| 24 |
+
ap.add_argument("--adapter", default=DEFAULT_ADAPTER,
|
| 25 |
+
help="LoRA adapter (HF id or local path); pass 'none' for the plain base model")
|
| 26 |
+
ap.add_argument("--no-4bit", action="store_true",
|
| 27 |
+
help="load in bf16/fp32 instead of 4-bit (use this on CPU)")
|
| 28 |
+
ap.add_argument("--run", action="store_true", help="also execute the query (requires --db)")
|
| 29 |
+
args = ap.parse_args()
|
| 30 |
+
|
| 31 |
+
from .inference import generate_sql, generate_sql_with_retry, load_model
|
| 32 |
+
|
| 33 |
+
if args.db:
|
| 34 |
+
from .exec_eval import schema_from_sqlite
|
| 35 |
+
schema = schema_from_sqlite(args.db)
|
| 36 |
+
else:
|
| 37 |
+
schema = open(args.schema, encoding="utf-8").read() if os.path.isfile(args.schema) else args.schema
|
| 38 |
+
|
| 39 |
+
adapter = None if (args.adapter or "").lower() == "none" else args.adapter
|
| 40 |
+
model, tok = load_model(args.base, adapter_path=adapter, four_bit=not args.no_4bit)
|
| 41 |
+
|
| 42 |
+
if args.db and args.run:
|
| 43 |
+
from .exec_eval import run_sql
|
| 44 |
+
sql, _ = generate_sql_with_retry(
|
| 45 |
+
model, tok, schema, args.question, validate=lambda s: run_sql(args.db, s)[1])
|
| 46 |
+
print(sql)
|
| 47 |
+
rows, err = run_sql(args.db, sql)
|
| 48 |
+
if err:
|
| 49 |
+
print(f"-- execution error: {err}", file=sys.stderr)
|
| 50 |
+
sys.exit(1)
|
| 51 |
+
for row in rows[:50]:
|
| 52 |
+
print(row)
|
| 53 |
+
else:
|
| 54 |
+
print(generate_sql(model, tok, schema, args.question))
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
if __name__ == "__main__":
|
| 58 |
+
main()
|
sqlforge/exec_eval.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Execution accuracy — the real metric.
|
| 3 |
+
|
| 4 |
+
A predicted query is correct if, run against the actual SQLite database, it
|
| 5 |
+
returns the SAME result set as the gold query. This is far more meaningful than
|
| 6 |
+
string matching: two very different-looking queries can be equally correct, and
|
| 7 |
+
two near-identical strings can differ by one wrong column.
|
| 8 |
+
|
| 9 |
+
Comparison is order-insensitive (a multiset of rows) unless the gold query
|
| 10 |
+
contains ORDER BY, in which case row order must match too.
|
| 11 |
+
"""
|
| 12 |
+
import sqlite3
|
| 13 |
+
import threading
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def db_file(db_root: str | Path, db_id: str) -> Path:
|
| 18 |
+
"""Path to a Spider database: <db_root>/<db_id>/<db_id>.sqlite"""
|
| 19 |
+
return Path(db_root) / db_id / f"{db_id}.sqlite"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def schema_from_sqlite(path: str | Path) -> str:
|
| 23 |
+
"""Reconstruct the CREATE TABLE schema string from a SQLite file.
|
| 24 |
+
|
| 25 |
+
Matches the format the model was trained on (the raw DDL from sqlite_master),
|
| 26 |
+
so the demo server can feed the model a real DB's schema with no extra work.
|
| 27 |
+
"""
|
| 28 |
+
conn = sqlite3.connect(str(path))
|
| 29 |
+
try:
|
| 30 |
+
rows = conn.execute(
|
| 31 |
+
"SELECT sql FROM sqlite_master "
|
| 32 |
+
"WHERE type='table' AND sql IS NOT NULL "
|
| 33 |
+
"AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
| 34 |
+
).fetchall()
|
| 35 |
+
finally:
|
| 36 |
+
conn.close()
|
| 37 |
+
return "\n\n".join(r[0].strip() + ";" for r in rows)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def run_sql(path: str | Path, sql: str, timeout: float = 15.0):
|
| 41 |
+
"""Execute SQL; return (rows, error). A watchdog interrupts runaway queries."""
|
| 42 |
+
conn = sqlite3.connect(str(path))
|
| 43 |
+
conn.text_factory = lambda b: b.decode("utf-8", errors="ignore") if isinstance(b, bytes) else b
|
| 44 |
+
watchdog = threading.Timer(timeout, conn.interrupt)
|
| 45 |
+
watchdog.start()
|
| 46 |
+
try:
|
| 47 |
+
cur = conn.execute(sql)
|
| 48 |
+
return cur.fetchall(), None
|
| 49 |
+
except Exception as exc: # noqa: BLE001 - any SQL/exec error => prediction is wrong
|
| 50 |
+
return None, str(exc)
|
| 51 |
+
finally:
|
| 52 |
+
watchdog.cancel()
|
| 53 |
+
conn.close()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _norm(rows, order_sensitive: bool):
|
| 57 |
+
"""Stringify cells so types compare cleanly; sort unless order matters."""
|
| 58 |
+
table = [tuple(str(c) for c in row) for row in rows]
|
| 59 |
+
return table if order_sensitive else sorted(table)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def execution_match(path: str | Path, pred_sql: str, gold_sql: str):
|
| 63 |
+
"""
|
| 64 |
+
Return (is_correct, error).
|
| 65 |
+
is_correct = True/False, or None if the GOLD query itself fails (skip example).
|
| 66 |
+
"""
|
| 67 |
+
gold_rows, gerr = run_sql(path, gold_sql)
|
| 68 |
+
if gerr is not None:
|
| 69 |
+
return None, f"gold failed: {gerr}"
|
| 70 |
+
|
| 71 |
+
pred_rows, perr = run_sql(path, pred_sql)
|
| 72 |
+
if perr is not None:
|
| 73 |
+
return False, perr
|
| 74 |
+
|
| 75 |
+
order_sensitive = "order by" in gold_sql.lower()
|
| 76 |
+
return _norm(pred_rows, order_sensitive) == _norm(gold_rows, order_sensitive), None
|
sqlforge/inference.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Model loading + SQL generation. Shared by the eval harness and the demo server.
|
| 3 |
+
"""
|
| 4 |
+
import re
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
|
| 8 |
+
|
| 9 |
+
from .prompts import build_correction_messages, build_inference_messages
|
| 10 |
+
|
| 11 |
+
DEFAULT_BASE = "Qwen/Qwen2.5-Coder-1.5B-Instruct"
|
| 12 |
+
|
| 13 |
+
_SQL_FENCE = re.compile(r"```(?:sql)?\s*(.*?)\s*```", re.DOTALL | re.IGNORECASE)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def load_model(base_model: str = DEFAULT_BASE, adapter_path: str | None = None,
|
| 17 |
+
four_bit: bool = True):
|
| 18 |
+
"""Load the base model (4-bit by default) and optionally attach a LoRA adapter."""
|
| 19 |
+
tok = AutoTokenizer.from_pretrained(base_model)
|
| 20 |
+
kwargs = {"torch_dtype": torch.bfloat16, "device_map": "auto"}
|
| 21 |
+
if four_bit:
|
| 22 |
+
kwargs["quantization_config"] = BitsAndBytesConfig(
|
| 23 |
+
load_in_4bit=True,
|
| 24 |
+
bnb_4bit_quant_type="nf4",
|
| 25 |
+
bnb_4bit_compute_dtype=torch.bfloat16,
|
| 26 |
+
bnb_4bit_use_double_quant=True,
|
| 27 |
+
)
|
| 28 |
+
model = AutoModelForCausalLM.from_pretrained(base_model, **kwargs)
|
| 29 |
+
if adapter_path:
|
| 30 |
+
from peft import PeftModel
|
| 31 |
+
model = PeftModel.from_pretrained(model, adapter_path)
|
| 32 |
+
model.eval()
|
| 33 |
+
return model, tok
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def clean_sql(text: str) -> str:
|
| 37 |
+
"""Strip markdown fences / prose; keep a single SQL statement."""
|
| 38 |
+
text = text.strip()
|
| 39 |
+
m = _SQL_FENCE.search(text)
|
| 40 |
+
if m:
|
| 41 |
+
text = m.group(1).strip()
|
| 42 |
+
# cut at first statement terminator, drop trailing chatter
|
| 43 |
+
if ";" in text:
|
| 44 |
+
text = text.split(";")[0]
|
| 45 |
+
return " ".join(text.split()).strip()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@torch.no_grad()
|
| 49 |
+
def _generate_from_messages(model, tok, msgs: list[dict], max_new_tokens: int = 256) -> str:
|
| 50 |
+
prompt = tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
|
| 51 |
+
inputs = tok(prompt, return_tensors="pt").to(model.device)
|
| 52 |
+
out = model.generate(
|
| 53 |
+
**inputs,
|
| 54 |
+
max_new_tokens=max_new_tokens,
|
| 55 |
+
do_sample=False, # greedy — deterministic, reproducible eval
|
| 56 |
+
pad_token_id=tok.eos_token_id,
|
| 57 |
+
)
|
| 58 |
+
gen = out[0][inputs["input_ids"].shape[1]:]
|
| 59 |
+
return clean_sql(tok.decode(gen, skip_special_tokens=True))
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def generate_sql(model, tok, schema: str, question: str, max_new_tokens: int = 256) -> str:
|
| 63 |
+
return _generate_from_messages(
|
| 64 |
+
model, tok, build_inference_messages(schema, question), max_new_tokens)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def generate_sql_with_retry(model, tok, schema: str, question: str, validate,
|
| 68 |
+
max_retries: int = 2, max_new_tokens: int = 256,
|
| 69 |
+
trace: list | None = None):
|
| 70 |
+
"""Generate SQL, then self-correct if it crashes against the database.
|
| 71 |
+
|
| 72 |
+
`validate(sql) -> error_str_or_None` runs the query and returns the DB error
|
| 73 |
+
(or None if it executes). On error we feed the failed query + error back to
|
| 74 |
+
the model and regenerate, up to `max_retries` times. Returns
|
| 75 |
+
(final_sql, attempts) where attempts == 1 means no correction was needed.
|
| 76 |
+
|
| 77 |
+
If `trace` is provided, each attempt is appended as
|
| 78 |
+
{"attempt": n, "sql": ..., "error": ...} — used by the demo UI to show the
|
| 79 |
+
agent fixing itself.
|
| 80 |
+
|
| 81 |
+
Note: this only fixes queries that *crash*. A query that runs but returns the
|
| 82 |
+
wrong rows is left alone — there's no error signal to correct against.
|
| 83 |
+
"""
|
| 84 |
+
sql = generate_sql(model, tok, schema, question, max_new_tokens)
|
| 85 |
+
err = validate(sql)
|
| 86 |
+
attempts = 1
|
| 87 |
+
if trace is not None:
|
| 88 |
+
trace.append({"attempt": attempts, "sql": sql, "error": err})
|
| 89 |
+
while err is not None and attempts <= max_retries:
|
| 90 |
+
msgs = build_correction_messages(schema, question, sql, err)
|
| 91 |
+
fixed = _generate_from_messages(model, tok, msgs, max_new_tokens)
|
| 92 |
+
attempts += 1
|
| 93 |
+
new_err = validate(fixed)
|
| 94 |
+
# accept the retry if it now runs, or as the new working draft to refine
|
| 95 |
+
sql, err = fixed, new_err
|
| 96 |
+
if trace is not None:
|
| 97 |
+
trace.append({"attempt": attempts, "sql": sql, "error": err})
|
| 98 |
+
if err is None:
|
| 99 |
+
break
|
| 100 |
+
return sql, attempts
|
sqlforge/prompts.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Single source of truth for the prompt format.
|
| 3 |
+
|
| 4 |
+
Both data prep (training) and inference (eval + serving) import from here, so the
|
| 5 |
+
model is always evaluated with the exact format it was trained on — no skew.
|
| 6 |
+
"""
|
| 7 |
+
import re
|
| 8 |
+
|
| 9 |
+
SYSTEM_PROMPT = (
|
| 10 |
+
"You are an expert data analyst. Given a SQLite database schema and a "
|
| 11 |
+
"question, write a single valid SQLite SQL query that answers it. "
|
| 12 |
+
"Respond with only the SQL query and nothing else."
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def build_user(schema: str, question: str) -> str:
|
| 17 |
+
return f"Database schema:\n{schema}\n\nQuestion: {question}"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def build_messages(schema: str, question: str, query: str) -> list[dict]:
|
| 21 |
+
"""Full training example (system + user + assistant)."""
|
| 22 |
+
return [
|
| 23 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 24 |
+
{"role": "user", "content": build_user(schema, question)},
|
| 25 |
+
{"role": "assistant", "content": query},
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def build_inference_messages(schema: str, question: str) -> list[dict]:
|
| 30 |
+
"""Prompt for generation (no assistant turn — model completes it)."""
|
| 31 |
+
return [
|
| 32 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 33 |
+
{"role": "user", "content": build_user(schema, question)},
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
_CREATE_RE = re.compile(r'CREATE\s+TABLE\s+["`\[]?(\w+)', re.IGNORECASE)
|
| 38 |
+
_COL_RE = re.compile(r'^["`\[]?(\w+)["`\]]?\s+\w')
|
| 39 |
+
_SKIP = {"PRIMARY", "FOREIGN", "UNIQUE", "CONSTRAINT", "CHECK", "KEY"}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def schema_identifiers(schema: str) -> dict[str, list[str]]:
|
| 43 |
+
"""Parse a CREATE TABLE schema string into {table: [columns]}.
|
| 44 |
+
|
| 45 |
+
The model's crashes are almost always invented/pluralised identifiers, so we
|
| 46 |
+
hand it the *exact* names back in the correction prompt. Line-based parse
|
| 47 |
+
matches the one-column-per-line DDL both the dataset and SQLite produce.
|
| 48 |
+
"""
|
| 49 |
+
tables: dict[str, list[str]] = {}
|
| 50 |
+
current = None
|
| 51 |
+
for raw in schema.splitlines():
|
| 52 |
+
line = raw.strip()
|
| 53 |
+
m = _CREATE_RE.match(line)
|
| 54 |
+
if m:
|
| 55 |
+
current = m.group(1)
|
| 56 |
+
tables[current] = []
|
| 57 |
+
continue
|
| 58 |
+
if current is None:
|
| 59 |
+
continue
|
| 60 |
+
if line.startswith(")"):
|
| 61 |
+
current = None
|
| 62 |
+
continue
|
| 63 |
+
toks = line.split()
|
| 64 |
+
if toks and toks[0].strip('("`[]').upper() in _SKIP:
|
| 65 |
+
continue
|
| 66 |
+
cm = _COL_RE.match(line)
|
| 67 |
+
if cm:
|
| 68 |
+
tables[current].append(cm.group(1))
|
| 69 |
+
return tables
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _schema_facts(schema: str) -> str:
|
| 73 |
+
ids = schema_identifiers(schema)
|
| 74 |
+
if not ids:
|
| 75 |
+
return ""
|
| 76 |
+
lines = [f"- {t}({', '.join(cols)})" for t, cols in ids.items() if cols]
|
| 77 |
+
return "The database has ONLY these tables and columns:\n" + "\n".join(lines)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def build_correction_messages(schema: str, question: str, bad_sql: str,
|
| 81 |
+
error: str) -> list[dict]:
|
| 82 |
+
"""Self-correction prompt: the model's previous query failed to execute.
|
| 83 |
+
|
| 84 |
+
Keeps the original system/task framing (so it stays in-distribution with
|
| 85 |
+
training) and appends the failed attempt + the SQLite error, PLUS the exact
|
| 86 |
+
valid table/column names parsed from the schema — so the model can't keep
|
| 87 |
+
inventing or pluralising identifiers. Also nudges it away from needless JOINs
|
| 88 |
+
(the 1.5B's main failure mode).
|
| 89 |
+
"""
|
| 90 |
+
facts = _schema_facts(schema)
|
| 91 |
+
user = (
|
| 92 |
+
f"{build_user(schema, question)}\n\n"
|
| 93 |
+
f"Your previous query failed with a database error.\n"
|
| 94 |
+
f"Previous query: {bad_sql}\n"
|
| 95 |
+
f"Error: {error}\n\n"
|
| 96 |
+
f"{facts}\n\n"
|
| 97 |
+
f"Use ONLY the exact table and column names listed above — do not invent, "
|
| 98 |
+
f"pluralise, or rename them. Prefer the simplest query and avoid "
|
| 99 |
+
f"unnecessary JOINs. Then write a corrected SQLite query. "
|
| 100 |
+
f"Respond with only the SQL query."
|
| 101 |
+
)
|
| 102 |
+
return [
|
| 103 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 104 |
+
{"role": "user", "content": user},
|
| 105 |
+
]
|