Abdullahkousa2 commited on
Commit
d6bfc8b
·
verified ·
1 Parent(s): 5206e1e

Upload folder using huggingface_hub

Browse files
.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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
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 &amp; 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
+ ]