HideIron's picture
readme + dash cleanup in source
898a901 verified
Raw History Blame Contribute Delete
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()