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()