"""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"(? 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"