Spaces:
Running
Running
File size: 12,508 Bytes
898a901 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 | """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()
|