File size: 17,891 Bytes
a72d1cf | 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 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 | """Hata kataloğu: tip tanımları, çalışma zamanı sınıflandırıcısı ve değerlendirme hatası analizi."""
from __future__ import annotations
import json
import re
import sqlite3
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path
from .trnum import AYLAR, fold
# --------------------------------------------------------------------------- katalog
CATALOG = {
"SEMA_ESLEME": {
"ad": "Şema eşleme hatası",
"tanim": "Soru, yanlış veya var olmayan tablo/sütuna bağlandı (ör. 'gerçekleşme' yerine 'bütçe' sütunu).",
"ornek": "no such column: gerceklesme2024",
"cozum": "Benzer sütun adlarını öner; orijinal başlıkları ve birimleri şemada göster.",
},
"DEGER_ESLEME": {
"ad": "Değer eşleme hatası",
"tanim": "Metin değeri tablodakiyle birebir eşleşmedi: İ/ı-I/i büyük-küçük harf, ek almış adlar, kısaltmalar.",
"ornek": "WHERE il = 'Istanbul' (tabloda 'İstanbul')",
"cozum": "TR_NORM() ile katlanmış karşılaştırma; tablodaki en yakın değerleri geri besle.",
},
"BIRIM": {
"ad": "Birim hatası",
"tanim": "Ölçek/birim dönüşümü unutuldu veya yanlış yapıldı (bin TL ↔ TL, bin MWh ↔ MWh, m³ ↔ litre, % ↔ oran).",
"ornek": "Soru TL isterken 'bin TL' sütunu doğrudan döndürüldü (1000 kat fark).",
"cozum": "Sütun birimlerini meta veride sakla, şemada göster; sonuçta birim etiketi kullan.",
},
"TARIH_BICIMI": {
"ad": "Tarih biçimi hatası",
"tanim": "Türkçe tarih yazımı ('15.03.2025', 'Ocak 2024') ile veritabanındaki ISO biçim ('2025-03-15', '2024-01') uyuşmadı.",
"ornek": "WHERE yururluk_tarihi = '15.03.2025'",
"cozum": "Tarihleri aktarımda ISO'ya çevir; SQL'deki Türkçe tarih literallerini yakala ve dönüştürmeyi öner.",
},
"SAYI_BICIMI": {
"ad": "Türkçe sayı biçimi hatası",
"tanim": "'1.234,56' biçimi yanlış yorumlandı: sayı metin kaldı, nokta ondalık sanıldı (1000 kat hata) "
"veya virgül binlik sanıldı (10/100 kat hata); SQL'e Türkçe sayı literali yazıldı.",
"ornek": "SUM('1.234,56') = 1.234 | '4,5' -> 45",
"cozum": "Aktarımda TR yerel ayarıyla ayrıştır; SQL literallerini denetle.",
},
"SAYISAL_AKIL": {
"ad": "Sayısal akıl yürütme hatası",
"tanim": "Toplama/ortalama/yüzde formülü yanlış; belgedeki 'Toplam' satırı tekrar sayıldı; "
"işaret (negatif gider) veya tamsayı bölmesi hatası.",
"ornek": "SUM(...) toplam satırını da içerdi (2 kat sonuç)",
"cozum": "toplam_satiri bayrağı; formül kanıtları; sonuç makullük kontrolleri.",
},
"SOZDIZIMI": {
"ad": "SQL sözdizimi hatası",
"tanim": "Üretilen sorgu ayrıştırılamadı veya SQLite lehçesine uymuyor.",
"ornek": "near \"TOP\": syntax error",
"cozum": "Hata mesajını geri besle; yalnızca SQLite sözdizimi iste.",
},
"BOS_SONUC": {
"ad": "Boş sonuç",
"tanim": "Sorgu çalıştı ama satır dönmedi; çoğunlukla değer/tarih eşleme sorunudur.",
"ornek": "WHERE donem = 'Mart 2025' -> 0 satır",
"cozum": "WHERE literallerini tablodaki gerçek değerlerle karşılaştır, en yakınlarını öner.",
},
"CEVAPSIZLIK": {
"ad": "Cevaplanabilirlik hatası",
"tanim": "Cevabı tablolarda olmayan soruya uydurma cevap verildi ya da cevaplanabilir soru reddedildi.",
"ornek": "'Mersin'in tüketimi' sorusuna başka bir ilin değeri döndü.",
"cozum": "Kapsam dışı yıl/varlık denetimi; emin değilse açıkça 'cevap bulunamadı' de.",
},
"YURUTME": {
"ad": "Diğer yürütme hatası",
"tanim": "Bilinmeyen fonksiyon, tip uyuşmazlığı, zaman aşımı vb.",
"ornek": "no such function: YEAR",
"cozum": "SQLite fonksiyonlarını kullan (strftime, substr).",
},
"YANLIS_SONUC": {
"ad": "Yanlış sonuç (sınıflandırılamadı)",
"tanim": "Sorgu çalıştı fakat altın sonuçla uyuşmadı ve belirgin bir alt tipe atanamadı.",
"ornek": "-",
"cozum": "Elle inceleme.",
},
}
@dataclass
class ErrorEvent:
tip: str
mesaj: str
sql: str = ""
oneriler: list[str] = field(default_factory=list)
asama: str = "yurutme" # statik | yurutme | sonuc | degerlendirme
soru: str = ""
zaman: str = field(default_factory=lambda: datetime.now().isoformat(timespec="seconds"))
def feedback(self) -> str:
info = CATALOG.get(self.tip, {})
s = f"[{self.tip} – {info.get('ad', '')}] {self.mesaj}"
if self.oneriler:
s += "\nÖneriler: " + " | ".join(self.oneriler)
return s
# --------------------------------------------------------------------------- yardımcılar
_STR_LIT = re.compile(r"'((?:[^']|'')*)'")
_TR_NUM_LIT = re.compile(r"^\s*-?\d{1,3}(\.\d{3})+(,\d+)?\s*$|^\s*-?\d+,\d+\s*$")
_BARE_TR_NUM = re.compile(r"(?<![\w.'])\d{1,3}(?:\.\d{3}){2,}(?:,\d+)?(?![\w.])")
_DMY = re.compile(r"^\d{1,2}[./]\d{1,2}[./]\d{4}$")
def string_literals(sql: str) -> list[str]:
return [m.group(1).replace("''", "'") for m in _STR_LIT.finditer(sql or "")]
def _all_columns(con) -> list[tuple[str, str]]:
try:
return con.execute("SELECT tablo, sutun FROM _sutunlar").fetchall()
except sqlite3.Error:
return []
def _close(name: str, options: list[str], n: int = 3) -> list[str]:
try:
from rapidfuzz import process
return [m[0] for m in process.extract(name, options, limit=n, score_cutoff=45)]
except Exception:
import difflib
return difflib.get_close_matches(name, options, n=n, cutoff=0.4)
def static_checks(sql: str, con) -> list[ErrorEvent]:
"""Yürütme öncesi denetimler: sözdizimi, salt-okuma, TR sayı/tarih literalleri, toplam satırı."""
events = []
s = (sql or "").strip().rstrip(";")
if not s:
return [ErrorEvent("SOZDIZIMI", "Boş sorgu üretildi.", sql, asama="statik")]
# yazma komutları: anahtar sözcük denetimi; REPLACE(...) metin işlevi serbesttir (REPLACE INTO değil)
if not re.match(r"^\s*(select|with)\b", s, re.I) or re.search(
r"\b(insert|update|delete|drop|alter|create|attach|detach|pragma|vacuum|reindex)\b|\breplace\b(?!\s*\()",
s, re.I):
return [ErrorEvent("SOZDIZIMI", "Yalnızca SELECT/WITH sorgularına izin verilir.", sql, asama="statik")]
if s.count(";") > 0:
return [ErrorEvent("SOZDIZIMI", "Tek bir SQL ifadesi üretin (';' ile birden çok ifade yok).", sql,
asama="statik")]
tree = None
try:
import sqlglot
from sqlglot import exp
tree = sqlglot.parse_one(s, read="sqlite")
# ifade tipi denetimi: kök sorgu SELECT/birleşim olmalı, içinde hiçbir yazma düğümü bulunmamalı
write_nodes = tuple(getattr(exp, n) for n in ("Insert", "Update", "Delete", "Drop", "Create", "Alter",
"Merge", "Command") if hasattr(exp, n))
if not isinstance(tree, (exp.Select, exp.Union, exp.Intersect, exp.Except, exp.Subquery)) or \
any(isinstance(n, write_nodes) for n in tree.walk()):
return [ErrorEvent("SOZDIZIMI", "Yalnızca SELECT/WITH sorgularına izin verilir.", sql, asama="statik")]
except Exception as e: # sqlglot yoksa veya hata
if e.__class__.__name__ in ("ParseError", "TokenError"):
events.append(ErrorEvent("SOZDIZIMI", f"SQL ayrıştırılamadı: {str(e).splitlines()[0][:200]}",
sql, asama="statik"))
return events
for lit in string_literals(s):
if _TR_NUM_LIT.match(lit):
v = lit.replace(".", "").replace(",", ".")
events.append(ErrorEvent("SAYI_BICIMI", f"SQL'de Türkçe biçimli sayı metni var: '{lit}'.",
sql, [f"Sayıları tırnaksız ve nokta ondalıkla yazın: {v}"], "statik"))
elif _DMY.match(lit.strip()):
d, m, y = re.split(r"[./]", lit.strip())
events.append(ErrorEvent("TARIH_BICIMI", f"SQL'de Türkçe tarih yazımı var: '{lit}'.", sql,
[f"Tarihler ISO biçimde saklanır: '{y}-{int(m):02d}-{int(d):02d}'"], "statik"))
else:
parts = fold(lit).split()
if len(parts) == 2 and parts[0] in AYLAR and parts[1].isdigit():
events.append(ErrorEvent("TARIH_BICIMI", f"SQL'de ay adı ile tarih var: '{lit}'.", sql,
[f"Dönemler 'YYYY-AA' biçimindedir: '{parts[1]}-{AYLAR[parts[0]]:02d}'"],
"statik"))
for m in _BARE_TR_NUM.finditer(s):
events.append(ErrorEvent("SAYI_BICIMI", f"SQL'de binlik ayraçlı sayı var: {m.group(0)}", sql,
[f"{m.group(0).replace('.', '').replace(',', '.')} yazın"], "statik"))
# toplam satırı tuzağı
try:
flagged = {t for (t,) in con.execute("SELECT tablo FROM _sutunlar WHERE sutun='toplam_satiri'")}
except sqlite3.Error:
flagged = set()
low = s.lower()
uses_agg = re.search(r"\b(sum|avg|count|max|min)\s*\(|\border\s+by\b|\bgroup\s+by\b", low)
for t in flagged:
if re.search(rf'["`]?\b{re.escape(t)}\b["`]?', low) and uses_agg and "toplam_satiri" not in low \
and _total_row_reachable(tree, t, con):
events.append(ErrorEvent(
"SAYISAL_AKIL",
f"'{t}' tablosunda belgenin kendi Toplam satırı var (toplam_satiri=1); toplama/sıralama "
"yaparken bu satır sonucu bozabilir.", sql,
["WHERE koşuluna 'toplam_satiri = 0' ekleyin (yalnızca toplam değeri isteniyorsa "
"'toplam_satiri = 1' satırını seçin)."], "statik"))
return events
def _total_row_reachable(tree, table: str, con) -> bool:
"""Sorgunun WHERE koşulu belgenin Toplam satırını zaten dışlıyor mu? Dışlıyorsa uyarı gereksizdir.
Tabloyu okuyan her SELECT için 'SELECT COUNT(*) FROM tablo WHERE (koşul) AND toplam_satiri = 1' çalıştırılır.
Koşul çıkarılamazsa ya da sorgu çalışmazsa (takma ad, birleştirme vb.) temkinli davranılır: uyarı korunur."""
if tree is None:
return True
try:
from sqlglot import exp
selects = [sel for sel in tree.find_all(exp.Select)
if any(tb.name.lower() == table.lower() for tb in sel.find_all(exp.Table))]
if not selects:
return True
for sel in selects:
if len({tb.name.lower() for tb in sel.find_all(exp.Table)}) != 1 or any(
tb.alias for tb in sel.find_all(exp.Table)):
return True
where = sel.args.get("where")
cond = f"({where.this.sql(dialect='sqlite')}) AND " if where is not None else ""
n = con.execute(f'SELECT COUNT(*) FROM "{table}" WHERE {cond}toplam_satiri = 1').fetchone()[0]
if n:
return True
return False
except Exception: # noqa: BLE001
return True
def classify_exception(exc: Exception, sql: str, con) -> ErrorEvent:
msg = str(exc)
low = msg.lower()
cols = _all_columns(con)
if "no such column" in low:
bad = msg.split(":", 1)[-1].strip().split(".")[-1]
sugg = _close(bad, sorted({c for _, c in cols}))
return ErrorEvent("SEMA_ESLEME", msg, sql, [f"Benzer sütunlar: {', '.join(sugg)}"] if sugg else [])
if "no such table" in low:
bad = msg.split(":", 1)[-1].strip()
sugg = _close(bad, sorted({t for t, _ in cols}))
return ErrorEvent("SEMA_ESLEME", msg, sql, [f"Benzer tablolar: {', '.join(sugg)}"] if sugg else [])
if "ambiguous column" in low:
return ErrorEvent("SEMA_ESLEME", msg, sql, ["Sütunu tablo adıyla niteleyin (tablo.sutun)."])
if "syntax error" in low or "incomplete input" in low or "unrecognized token" in low:
return ErrorEvent("SOZDIZIMI", msg, sql, ["Yalnızca SQLite sözdizimi kullanın."])
if "no such function" in low:
return ErrorEvent("YURUTME", msg, sql,
["SQLite fonksiyonları: strftime, substr, ROUND, ABS, TR_NORM, TR_LOWER"])
return ErrorEvent("YURUTME", msg, sql)
def check_result(sql: str, rows: list, con) -> list[ErrorEvent]:
"""Sonuç denetimi: boş sonuçta WHERE literallerini tablodaki değerlerle karşılaştırır."""
if rows and not all(all(v is None for v in r) for r in rows):
return []
events = []
lits = [l for l in string_literals(sql) if not l.endswith("%")]
suggestions = []
for lit in lits:
hits = []
for t, c in _all_columns(con):
try:
vals = [v for (v,) in con.execute(f'SELECT DISTINCT "{c}" FROM "{t}" WHERE typeof("{c}")="text"')]
except sqlite3.Error:
continue
exact_fold = [v for v in vals if fold(v) == fold(lit) and v != lit]
if exact_fold:
hits.append(f"{t}.{c} = '{exact_fold[0]}' (büyük/küçük harf veya Türkçe karakter farkı)")
continue
close = [v for v in _close(lit, vals, 2)]
if close and len(lit) > 2:
hits.append(f"{t}.{c} içinde yakın değerler: " + ", ".join(repr(v) for v in close))
if hits:
suggestions.extend(hits[:2])
if rows and all(all(v is None for v in r) for r in rows):
return [ErrorEvent("BOS_SONUC", "Sorgu yalnızca NULL döndürdü (eşleşen satır yok veya sütun metin "
"olarak saklanmış olabilir).", sql, suggestions or ["WHERE koşullarını gözden geçirin."],
"sonuc")]
tip = "DEGER_ESLEME" if any("harf" in s for s in suggestions) else "BOS_SONUC"
events.append(ErrorEvent(tip, "Sorgu 0 satır döndürdü.", sql,
suggestions or ["WHERE koşullarını gevşetin veya değerleri kontrol edin; "
"metin karşılaştırmalarında TR_NORM(sutun)=TR_NORM('değer') kullanın."],
"sonuc"))
return events
# --------------------------------------------------------------------------- günlük
class ErrorLog:
"""Hataları JSONL dosyasına yazar (arayüzdeki Hata Kataloğu sayfası okur)."""
def __init__(self, path: str | Path):
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
def add(self, ev: ErrorEvent, kaynak: str = "arayuz", **extra):
rec = asdict(ev) | {"kaynak": kaynak} | extra
with self.path.open("a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
def read(self) -> list[dict]:
if not self.path.exists():
return []
return [json.loads(l) for l in self.path.read_text(encoding="utf-8").splitlines() if l.strip()]
# --------------------------------------------------------------------------- değerlendirme analizi
def _num(x):
return x if isinstance(x, (int, float)) and not isinstance(x, bool) else None
def diagnose_failure(item: dict, pred_sql: str | None, pred_rows, gold_rows, abstained: bool,
exec_error: str | None, db_mode: str) -> str:
"""Değerlendirmede başarısız bir örneğin hata tipini kestirir."""
cats = set(item.get("kategoriler", []))
if not item["cevaplanabilir"]:
return "CEVAPSIZLIK"
if abstained and not pred_sql:
return "CEVAPSIZLIK"
if exec_error:
e = exec_error.lower()
if "no such column" in e or "no such table" in e or "ambiguous" in e:
return "SEMA_ESLEME"
if "syntax" in e or "incomplete" in e:
return "SOZDIZIMI"
return "YURUTME"
lits = string_literals(pred_sql or "")
if any(_DMY.match(l) for l in lits) or any(
len(fold(l).split()) == 2 and fold(l).split()[0] in AYLAR for l in lits):
return "TARIH_BICIMI"
if any(_TR_NUM_LIT.match(l) for l in lits):
return "SAYI_BICIMI"
if not pred_rows:
return "DEGER_ESLEME" if "deger_esleme" in cats else ("TARIH_BICIMI" if "tarih" in cats else "BOS_SONUC")
# sayısal oranlar
p = [_num(v) for r in pred_rows for v in r]
g = [_num(v) for r in gold_rows for v in r]
p = [v for v in p if v is not None]
g = [v for v in g if v is not None]
if db_mode != "tr" and pred_rows and any(isinstance(v, str) and re.search(r"\d[.,]\d", v)
for r in pred_rows for v in r):
return "SAYI_BICIMI"
if len(p) == 1 and len(g) == 1 and g[0] and p[0]:
ratio = abs(p[0] / g[0])
for k in (1e3, 1e-3, 1e6, 1e-6):
if abs(ratio - k) / k < 0.01:
return "SAYI_BICIMI" if db_mode != "tr" else "BIRIM"
if any(abs(ratio - k) / k < 0.02 for k in (10, 100, 0.1, 0.01)):
return "SAYI_BICIMI" if db_mode != "tr" else "SAYISAL_AKIL"
if abs(ratio - 2) < 0.02:
return "SAYISAL_AKIL" # toplam satırı çift sayım
return "SAYISAL_AKIL"
if db_mode != "tr" and "tr_sayi" in cats:
return "SAYI_BICIMI"
if "toplam_satiri" in cats and pred_sql and "toplam_satiri" not in pred_sql.lower():
return "SAYISAL_AKIL"
if "birim" in cats:
return "BIRIM"
if "deger_esleme" in cats:
return "DEGER_ESLEME"
if "tarih" in cats:
return "TARIH_BICIMI"
if p and g:
return "SAYISAL_AKIL"
return "SEMA_ESLEME"
|