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"