Text-to-SQL / t2s /qa_eval.py
SevdanurGenc's picture
Upload 466 files
a72d1cf verified
Raw History Blame Contribute Delete
11.8 kB
"""Cevap düzeyinde soru-cevap değerlendirmesi (altın SQL gerektirmez).
Pilot, stres ve ağır LLM kümeleri bu modülle çalışır; arayüzdeki "Model karşılaştırma" bölümü ve
pilot/pilot_calistir.py aynı kodu kullanır.
Değerlendirme kuralları
sayi : sonucun ilk satırındaki sayılardan biri |a−b| ≤ 0,01 + 10⁻³|b| ile eşleşirse doğru
(isaret_serbest: mutlak değerler de denenir)
metin : ilk satırdaki bir değer Türkçe katlama sonrası beklenene eşitse doğru; tarih/dönem ISO'ya çevrilir.
'kabul' listesi varsa (ör. kurum kısa adı) değerin içinde geçmesi yeterlidir
liste : ilk sütundaki değer kümesi beklenen kümeye eşitse doğru
cevapsiz : sistem cevap üretmezse doğru
"""
from __future__ import annotations
import hashlib
import json
import re
import time
from dataclasses import dataclass
from pathlib import Path
from .agent import Text2SQLAgent, format_answer
from .ai import AIHelper
from .llm import USAGE
from .pipeline import build_database
from .schema import connect
from .trnum import fold, parse_date
ROOT = Path(__file__).resolve().parent.parent
TOL_A, TOL_R = 0.01, 1e-3
# ====================================================================== test kümeleri
@dataclass
class QASet:
key: str
belgeler: Path
sorular: Path
ham: Path | None
belge_basina: bool # True: her belge ayrı veritabanı
filtre: str | None = None # ör. "hizli": yalnız q["hizli"] olan sorular
def questions(self) -> list[dict]:
spec = json.loads(self.sorular.read_text(encoding="utf-8"))
qs = spec["sorular"] if isinstance(spec, dict) else spec
if self.filtre:
qs = [q for q in qs if q.get(self.filtre)]
return qs
def available(self) -> bool:
return self.belgeler.exists() and self.sorular.exists()
def docs(self) -> list[Path]:
return sorted(p for p in self.belgeler.iterdir() if p.suffix.lower() in (".pdf", ".html", ".htm"))
SETS = {
"agir": QASet("agir", ROOT / "agir_kume" / "belgeler", ROOT / "agir_kume" / "sorular.json",
ROOT / "agir_kume" / "ham_degerler.json", False),
"agir_hizli": QASet("agir_hizli", ROOT / "agir_kume" / "belgeler", ROOT / "agir_kume" / "sorular.json",
ROOT / "agir_kume" / "ham_degerler.json", False, "hizli"),
"stres": QASet("stres", ROOT / "stres_kumesi" / "belgeler", ROOT / "stres_kumesi" / "sorular.json",
ROOT / "stres_kumesi" / "ham_degerler.json", True),
"pilot": QASet("pilot", ROOT / "pilot" / "belgeler", ROOT / "pilot" / "sorular.json",
ROOT / "pilot" / "ham_degerler.json", False),
}
def code_hash() -> str:
return hashlib.sha256(b"".join(p.read_bytes() for p in sorted((ROOT / "t2s").glob("*.py")))).hexdigest()[:16]
def extraction_hash() -> str:
"""Çıkarımı etkileyen modüllerin özeti: değişmedikçe kurulu veritabanı yeniden kullanılır."""
names = ("extract.py", "pipeline.py", "schema.py", "trnum.py")
return hashlib.sha256(b"".join((ROOT / "t2s" / n).read_bytes() for n in names)).hexdigest()[:16]
# ====================================================================== hakem
def _num(v):
if isinstance(v, bool):
return None
if isinstance(v, (int, float)):
return float(v)
return None
def _txt(v):
if isinstance(v, float) and v.is_integer():
return str(int(v))
return str(v)
def _key(v) -> str:
"""Metin karşılaştırma anahtarı: Türkçe katlama; tarih/dönem ise ISO ('Ekim 2025' == '2025-10' == '2025/10')."""
s = str(v).strip()
m = re.fullmatch(r"(\d{4})[/.](\d{1,2})", s)
if m:
return f"{m.group(1)}-{int(m.group(2)):02d}"
return parse_date(s) or fold(s)
def judge(q: dict, res) -> tuple[bool, str]:
tip = q["tip"]
if tip == "cevapsiz":
return res.abstained, "red" if res.abstained else "cevap üretti"
if res.abstained or not res.rows:
return False, f"red/boş ({res.status})"
first = res.rows[0]
if tip == "sayi":
g = float(q["cevap"])
vals = [x for x in (_num(v) for v in first) if x is not None]
if q.get("isaret_serbest"):
vals += [abs(x) for x in vals]
ok = any(abs(x - g) <= TOL_A + TOL_R * abs(g) for x in vals)
return ok, f"bulunan {vals[:3]}"
if tip == "metin" and q.get("kabul"):
keys = [fold(str(k)) for k in q["kabul"]] + [fold(str(q["cevap"]))]
ok = any(v is not None and any(k in fold(_txt(v)) for k in keys) for v in first)
return ok, f"bulunan {[v for v in first if v is not None][:3]}"
if tip == "metin":
g = _key(q["cevap"])
ok = any(v is not None and _key(_txt(v)) == g for v in first)
return ok, f"bulunan {[v for v in first if v is not None][:3]}"
if tip == "liste":
got = {_key(_txt(r[0])) for r in res.rows if r and r[0] is not None}
exp = {_key(x) for x in q["cevap"]}
return got == exp, f"{len(got & exp)}/{len(exp)} ortak, {len(got - exp)} fazla"
return False, "bilinmeyen tip"
def value_recall(con, doc: str, raw_values: list[float]) -> dict:
"""Belgenin ham sayısal değerlerinden kaçı, o belgeden çıkarılan tablolarda bir sayı olarak bulunuyor?"""
tabs = [t for (t,) in con.execute("SELECT tablo FROM _tablolar WHERE kaynak=?", (doc,))]
found = set()
for t in tabs:
for (col, typ) in con.execute(f'SELECT name, type FROM pragma_table_info("{t}")'):
if typ.upper() not in ("REAL", "INTEGER"):
continue
for (v,) in con.execute(f'SELECT "{col}" FROM "{t}" WHERE "{col}" IS NOT NULL'):
found.add(round(float(v), 4))
need = [round(float(x), 4) for x in raw_values]
hit = sum(1 for x in need if x in found or -x in found)
return {"tablo": len(tabs), "ham_deger": len(need), "bulunan": hit,
"oran": round(100 * hit / len(need), 1) if need else None, "tablolar": tabs}
# ====================================================================== veritabanları
def prepare_dbs(qs: QASet, out_dir: Path, docs: list[Path] | None = None, reuse: bool = True) -> list[tuple[Path, list[Path]]]:
"""[(veritabanı, belgeler)] döndürür. Çıkarım kodu ve belgeler değişmedikçe kurulu veritabanı yeniden kullanılır."""
docs = docs or qs.docs()
out_dir.mkdir(parents=True, exist_ok=True)
groups = [(out_dir / f"{d.stem}.db", [d]) for d in docs] if qs.belge_basina else [(out_dir / f"{out_dir.name}.db", docs)]
sig = extraction_hash() + "|" + hashlib.sha256(b"".join(d.read_bytes()[:4096] + str(d.stat().st_size).encode()
for d in docs)).hexdigest()[:12]
stamp = out_dir / ".imza"
fresh = reuse and stamp.exists() and stamp.read_text() == sig and all(p.exists() for p, _ in groups)
if not fresh:
for db, gdocs in groups:
db.unlink(missing_ok=True)
build_database(db, docs=gdocs, mode="tr")
stamp.write_text(sig)
return groups
# ====================================================================== çalıştırma
def run_set(qs: QASet, backend, out_dir: Path, *, questions: list[dict] | None = None, max_attempts: int = 3,
self_correct: bool = True, use_ai_tables: bool = True, progress=None, limit: int | None = None,
reuse_db: bool = True) -> dict:
"""Bir test kümesini bir arka uçla çalıştırır; {'ozet':…, 'kayitlar':…} döndürür ve out_dir'e yazar."""
t0 = time.time()
u0 = (USAGE.calls, USAGE.input_tokens, USAGE.output_tokens)
questions = questions if questions is not None else qs.questions()
if limit:
questions = questions[:limit]
dbkey = qs.key.replace("_hizli", "")
if qs.key == "ozel":
dbkey = "ozel_" + hashlib.sha256(str(qs.belgeler.resolve()).encode()).hexdigest()[:8]
groups = prepare_dbs(qs, ROOT / "outputs" / "qa" / "db" / dbkey, reuse=reuse_db)
ham = json.loads(qs.ham.read_text(encoding="utf-8")) if qs.ham and qs.ham.exists() else {}
llm = backend != "kural"
ai = AIHelper(backend, cache=False) if (llm and use_ai_tables) else None
recs, cikarim, done, n = [], {}, 0, len(questions)
for db, gdocs in groups:
names = {d.name for d in gdocs}
mine = [q for q in questions if (not qs.belge_basina) or q["belge"] in names]
con = connect(db, readonly=True)
for d in gdocs:
info = {"yazilan_tablo": [t for (t,) in con.execute("SELECT tablo FROM _tablolar WHERE kaynak=?", (d.name,))]}
if d.name in ham:
info["deger_geri_cagirma"] = value_recall(con, d.name, ham[d.name])
cikarim[d.name] = info
if not mine:
con.close()
continue
agent = Text2SQLAgent(con, backend, max_attempts=max_attempts, self_correct=self_correct,
fallback_rule=False, log_source="qa_eval", ai=ai)
for q in mine:
try:
res = agent.run(q["soru"])
ok, why = judge(q, res)
rec = {**q, "dogru": ok, "aciklama": why, "durum": res.status, "sql": res.sql,
"sonuc": [list(r) for r in res.rows[:5]], "cevap_metni": format_answer(res, con),
"deneme": res.attempts, "sure": round(res.duration, 2)}
except Exception as e: # noqa: BLE001 - tek soru hatası tüm çalıştırmayı durdurmasın
rec = {**q, "dogru": False, "aciklama": f"istisna: {str(e)[:200]}", "durum": "istisna", "sql": None,
"sonuc": [], "cevap_metni": "", "deneme": 0, "sure": 0}
recs.append(rec)
done += 1
if progress:
progress(done, n, rec)
con.close()
summary = summarize(recs)
summary.update({"arka_uc": "kural" if not llm else backend.name, "kume": qs.key, "cikarim": cikarim,
"sure_sn": round(time.time() - t0, 1), "kod_ozeti": code_hash(),
"llm_cagri": USAGE.calls - u0[0], "girdi_belirteci": USAGE.input_tokens - u0[1],
"cikti_belirteci": USAGE.output_tokens - u0[2],
"zaman": time.strftime("%Y-%m-%dT%H:%M:%S")})
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "sonuclar.json").write_text(json.dumps({"ozet": summary, "kayitlar": recs}, ensure_ascii=False,
indent=1, default=str), encoding="utf-8")
return {"ozet": summary, "kayitlar": recs}
def _rate(rs):
return round(100 * sum(r["dogru"] for r in rs) / len(rs), 1) if rs else None
def summarize(recs: list[dict]) -> dict:
def by(field):
ks = sorted({str(r.get(field)) for r in recs if r.get(field) is not None})
return {k: {"n": sum(1 for r in recs if str(r.get(field)) == k),
"basari": _rate([r for r in recs if str(r.get(field)) == k])} for k in ks}
ans = [r for r in recs if r["tip"] != "cevapsiz"]
una = [r for r in recs if r["tip"] == "cevapsiz"]
return {
"soru": len(recs), "dogru": sum(r["dogru"] for r in recs), "basari": _rate(recs),
"cevaplanabilir_basari": _rate(ans), "cevapsiz_dogru_red": _rate(una),
"yanlis_red": round(100 * sum(r["durum"] != "cevap" for r in ans) / len(ans), 1) if ans else None,
"sessiz_yanlis": round(100 * sum((not r["dogru"]) and r["durum"] == "cevap" for r in recs) / len(recs), 1)
if recs else None,
"seviyeye_gore": by("seviye"), "dile_gore": by("dil"), "aileye_gore": by("aile"), "tipe_gore": by("tip"),
"cizime_gore": by("cizim"), "belge_turune_gore": by("belge_turu"), "zorluga_gore": by("zorluk"),
}