Spaces:
Running
Running
Download src/selfheal_sql/heal.py from HideIron/selfheal-sql: direct link, hf CLI and curl.
- Browser
- Download file 12.5 kB
-
https://huggingface.co/spaces/HideIron/selfheal-sql/resolve/main/src/selfheal_sql/heal.py
- Command line
-
hf download hf://spaces/HideIron/selfheal-sql/src/selfheal_sql/heal.py
-
curl -L -o heal.py https://huggingface.co/spaces/HideIron/selfheal-sql/resolve/main/src/selfheal_sql/heal.py
12.5 kB
| """The heal step: classify each failure, generate a corrected SQL query, keep it only | |
| if it ACTUALLY produces the gold result. Output: DPO preference pairs. | |
| Replaces AgentTune's TrainingExampleGenerator, which is tool-call-specific (it drops | |
| every failure whose root cause isn't wrong_tool/loop_collapse, asks the LLM for "the | |
| EXACT JSON tool call", and trains on a placeholder prompt). The classification stage | |
| keeps AgentTune's FailureClassifier contract ({root_cause, confidence, analysis}) but | |
| with a SQL-specific taxonomy. The output rows carry chosen/rejected exactly as | |
| agenttune.decide.closed_loop.contracts.TrainingExample expects, so the retrain step | |
| consumes them through Lexsi's own examples_to_dpo_dataset / run_retrain. | |
| The healer NEVER sees the gold SQL. Gold is used only to verify a proposed fix by | |
| executing both and comparing results (execcheck). Failed fixes are retried with the | |
| new error message, up to --max-attempts. | |
| Runs locally (needs the Spider DBs on disk + the HF router for LLM calls). | |
| Usage: | |
| python heal.py --run baseline-pool600 --split-file ../../data/splits/pool.jsonl \ | |
| --out ../../data/heal [--limit 6] [--workers 6] | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| import re | |
| import sys | |
| import threading | |
| import time | |
| import urllib.error | |
| import urllib.request | |
| from collections import Counter | |
| from concurrent.futures import ThreadPoolExecutor | |
| HERE = os.path.dirname(os.path.abspath(__file__)) | |
| sys.path.insert(0, HERE) | |
| from execcheck import check, extract_sql # noqa: E402 | |
| from student import PROMPT # noqa: E402 (the student's prompt - reused verbatim) | |
| ROUTER = "https://router.huggingface.co/v1/chat/completions" | |
| HEALER_MODEL = "CohereLabs/aya-expanse-32b" # on the hackathon's allowed model list | |
| RUNS_REPO = "HideIron/selfheal-sql-runs" | |
| SCHEMA_CAP = 7000 # chars of schema shown to the healer | |
| # SQL-specific root causes (replaces the tool-agent taxonomy in | |
| # agenttune FailureClassifier.ROOT_CAUSES; same output contract). | |
| ROOT_CAUSES = [ | |
| "schema_misread", # non-existent column/table, column on wrong table | |
| "wrong_join", # wrong join path, missing join, wrong join keys | |
| "wrong_aggregation", # count/sum/avg/group-by/having misuse | |
| "missing_or_wrong_filter", # WHERE/HAVING condition missing or wrong | |
| "value_mismatch", # wrong literal (e.g. 'US' vs 'United States') | |
| "wrong_output_columns", # right rows, wrong selected columns/order/distinct | |
| "overcomplicated_query", # needless subquery/join that changes the result | |
| "other", | |
| ] | |
| _counts_lock = threading.Lock() | |
| TOKENS = [0, 0] # prompt, completion | |
| CALLS = [0] | |
| def llm(messages: list[dict], temperature: float, max_tokens: int = 500) -> str: | |
| """One chat call to the HF router, with backoff on 429/5xx. Returns content.""" | |
| body = json.dumps( | |
| {"model": HEALER_MODEL, "messages": messages, "temperature": temperature, | |
| "max_tokens": max_tokens} | |
| ).encode() | |
| last = None | |
| for i in range(5): | |
| req = urllib.request.Request( | |
| ROUTER, data=body, | |
| headers={"Authorization": "Bearer " + os.environ["HF_TOKEN"], | |
| "Content-Type": "application/json"}, | |
| ) | |
| try: | |
| with urllib.request.urlopen(req, timeout=180) as r: | |
| d = json.load(r) | |
| u = d.get("usage", {}) | |
| with _counts_lock: | |
| TOKENS[0] += u.get("prompt_tokens", 0) | |
| TOKENS[1] += u.get("completion_tokens", 0) | |
| CALLS[0] += 1 | |
| return d["choices"][0]["message"]["content"] | |
| except urllib.error.HTTPError as e: | |
| last = f"HTTP {e.code}: {e.read()[:200]!r}" | |
| if e.code in (429, 500, 502, 503, 504): | |
| time.sleep(2 ** i) | |
| continue | |
| raise RuntimeError(last) from e | |
| except Exception as e: # noqa: BLE001 (timeouts, resets) | |
| last = repr(e) | |
| time.sleep(2 ** i) | |
| raise RuntimeError(f"router call failed after retries: {last}") | |
| def _json_block(text: str) -> dict: | |
| m = re.search(r"\{.*\}", text, re.DOTALL) | |
| return json.loads(m.group(0)) if m else {} | |
| def classify(fail: dict, srow: dict) -> dict: | |
| """SQL-specific root-cause classification (AgentTune ClassifiedFailure contract).""" | |
| sys_p = ( | |
| "You are an expert SQL failure analyst. Classify the failure into EXACTLY ONE " | |
| "root cause from this list:\n" + ", ".join(ROOT_CAUSES) + "\n\n" | |
| 'Return ONLY a JSON object: {"root_cause": "<one of the list>", ' | |
| '"confidence": 0.0-1.0, "analysis": "one short sentence"}' | |
| ) | |
| user = ( | |
| f"Schema:\n{srow['schema'][:SCHEMA_CAP]}\n\nQuestion: {srow['question']}\n\n" | |
| f"Model's SQL:\n{fail['raw_output'][:1200]}\n\n" | |
| f"Outcome: {fail['status']}" | |
| + (f" - {fail['error_message']}" if fail.get("error_message") else "") | |
| + ( | |
| f" (query returned {fail['pred_rows']} rows, expected result has {fail['gold_rows']} rows)" | |
| if fail.get("pred_rows") is not None and fail.get("gold_rows") is not None | |
| else "" | |
| ) | |
| ) | |
| try: | |
| p = _json_block(llm([{"role": "system", "content": sys_p}, | |
| {"role": "user", "content": user}], 0.0, 250)) | |
| rc = p.get("root_cause", "other") | |
| return { | |
| "root_cause": rc if rc in ROOT_CAUSES else "other", | |
| "confidence": float(p.get("confidence", 0.5)), | |
| "analysis": str(p.get("analysis", ""))[:400], | |
| "classify_error": None, | |
| } | |
| except Exception as e: # noqa: BLE001 | |
| return {"root_cause": "other", "confidence": 0.0, "analysis": "", | |
| "classify_error": str(e)[:300]} | |
| def generate_fix(fail: dict, srow: dict, cls: dict, max_attempts: int) -> tuple[str | None, list]: | |
| """Ask the healer for corrected SQL; keep only a fix that executes to the gold | |
| result. Retries feed the candidate's own error back. Returns (sql|None, attempts).""" | |
| base = ( | |
| f"A SQLite query is wrong and you must fix it.\n\n" | |
| f"Schema:\n{srow['schema'][:SCHEMA_CAP]}\n\n" | |
| f"Question: {srow['question']}\n\n" | |
| f"Wrong SQL:\n{fail['raw_output'][:1200]}\n\n" | |
| f"Observed problem: {fail['status']}" | |
| + (f" - {fail['error_message']}" if fail.get("error_message") else "") | |
| + (f"\nLikely root cause: {cls['root_cause']} ({cls['analysis']})" if cls.get("analysis") else "") | |
| + "\n\nRules: make the MINIMAL edit that fixes the query. Keep the original's " | |
| "formatting, aliasing style, capitalisation and column order; do not add joins, " | |
| "aliases, extra columns or reformat. Change only what is wrong.\n" | |
| "Return ONLY the corrected SQLite query, nothing else." | |
| ) | |
| msgs = [{"role": "user", "content": base}] | |
| attempts = [] | |
| for k in range(max_attempts): | |
| try: | |
| out = llm(msgs, 0.0 if k == 0 else 0.4, 400) | |
| except Exception as e: # noqa: BLE001 | |
| attempts.append({"attempt": k + 1, "error": f"llm: {str(e)[:200]}"}) | |
| break | |
| cand = extract_sql(out) | |
| if not cand: | |
| attempts.append({"attempt": k + 1, "error": "no SQL in healer output"}) | |
| feedback = "Your reply contained no SQL. Return ONLY the corrected SQLite query." | |
| else: | |
| res = check(srow["db_path"], cand, srow["gold_sql"]) | |
| attempts.append({"attempt": k + 1, "sql": cand, "status": res.status, | |
| "error": res.error_message[:200]}) | |
| if res.correct: | |
| return cand, attempts | |
| feedback = ( | |
| f"Your query:\n{cand}\n\nIt is still wrong: {res.status}" | |
| + (f" - {res.error_message[:300]}" if res.error_message else "") | |
| + ( | |
| f" (it returned {res.pred_rows} rows; the correct result has {res.gold_rows} rows)" | |
| if res.pred_rows is not None and res.gold_rows is not None | |
| else "" | |
| ) | |
| + "\nTry again. Return ONLY the corrected SQLite query." | |
| ) | |
| msgs = msgs + [{"role": "assistant", "content": out}, | |
| {"role": "user", "content": feedback}] | |
| return None, attempts | |
| def heal_one(fail: dict, srow: dict, max_attempts: int) -> dict: | |
| cls = classify(fail, srow) | |
| fixed_sql, attempts = generate_fix(fail, srow, cls, max_attempts) | |
| rejected = extract_sql(fail["raw_output"]) or fail["raw_output"].strip() | |
| return { | |
| "qid": fail["qid"], "db_id": fail["db_id"], "question": srow["question"], | |
| "status_before": fail["status"], "error_before": fail.get("error_message", ""), | |
| **{k: cls[k] for k in ("root_cause", "confidence", "analysis", "classify_error")}, | |
| "healed": fixed_sql is not None, | |
| "attempts_used": len(attempts), "attempts": attempts, | |
| # --- TrainingExample-ready fields (consumed by train_dpo.py) --- | |
| "prompt_user_content": PROMPT.format(schema=srow["schema"], question=srow["question"]), | |
| "chosen_sql": fixed_sql, | |
| "rejected_sql": rejected, | |
| "healer_model": HEALER_MODEL, | |
| } | |
| def main() -> None: | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--run", default="baseline-pool600", help="runs-repo folder with results.jsonl") | |
| ap.add_argument("--split-file", required=True) | |
| ap.add_argument("--out", required=True) | |
| ap.add_argument("--limit", type=int, default=None) | |
| ap.add_argument("--workers", type=int, default=6) | |
| ap.add_argument("--max-attempts", type=int, default=3) | |
| ap.add_argument("--no-push", action="store_true") | |
| ap.add_argument("--tag", default="", help="suffix for the runs-repo push path") | |
| args = ap.parse_args() | |
| from huggingface_hub import HfApi, hf_hub_download | |
| rp = hf_hub_download(RUNS_REPO, f"runs/{args.run}/results.jsonl", repo_type="dataset") | |
| results = [json.loads(l) for l in open(rp, encoding="utf-8")] | |
| splits = {r["qid"]: r for r in map(json.loads, open(args.split_file, encoding="utf-8"))} | |
| fails = [r for r in results if r["status"] != "correct"] | |
| if args.limit: | |
| fails = fails[: args.limit] | |
| print(f"[heal] {len(fails)} failures to heal (of {len(results)} runs)", flush=True) | |
| t0 = time.time() | |
| done = [0] | |
| def work(f): | |
| row = heal_one(f, splits[f["qid"]], args.max_attempts) | |
| with _counts_lock: | |
| done[0] += 1 | |
| if done[0] % 20 == 0: | |
| print(f"[heal] {done[0]}/{len(fails)} ({time.time()-t0:.0f}s, " | |
| f"{CALLS[0]} calls)", flush=True) | |
| return row | |
| with ThreadPoolExecutor(max_workers=args.workers) as ex: | |
| rows = list(ex.map(work, fails)) | |
| os.makedirs(args.out, exist_ok=True) | |
| with open(os.path.join(args.out, "pairs.jsonl"), "w", encoding="utf-8") as f: | |
| for r in rows: | |
| f.write(json.dumps(r, ensure_ascii=False) + "\n") | |
| healed = [r for r in rows if r["healed"]] | |
| summary = { | |
| "run": args.run, "healer_model": HEALER_MODEL, "n_failures": len(fails), | |
| "n_healed": len(healed), "heal_yield": round(len(healed) / max(len(fails), 1), 4), | |
| "by_root_cause": dict(Counter(r["root_cause"] for r in rows)), | |
| "healed_by_root_cause": dict(Counter(r["root_cause"] for r in healed)), | |
| "attempts_histogram": dict(Counter(r["attempts_used"] for r in healed)), | |
| "classify_errors": sum(1 for r in rows if r["classify_error"]), | |
| "llm_calls": CALLS[0], "prompt_tokens": TOKENS[0], "completion_tokens": TOKENS[1], | |
| "elapsed_s": round(time.time() - t0, 1), "max_attempts": args.max_attempts, | |
| } | |
| with open(os.path.join(args.out, "heal_summary.json"), "w", encoding="utf-8") as f: | |
| json.dump(summary, f, indent=2) | |
| print("[heal] SUMMARY", json.dumps(summary), flush=True) | |
| if not args.no_push: | |
| dest = f"heal/{args.run}{args.tag}" | |
| HfApi().upload_folder(repo_id=RUNS_REPO, repo_type="dataset", folder_path=args.out, | |
| path_in_repo=dest, commit_message=f"heal {dest}") | |
| print("[heal] pushed to", RUNS_REPO, dest, flush=True) | |
| if __name__ == "__main__": | |
| main() | |