File size: 10,842 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
"""Uygulama genelindeki yapay zekâ yardımcıları.

Bir LLM arka ucu seçildiğinde (Claude, OpenAI uyumlu, Ollama) şu adımlar AI ile zenginleşir:
  enrich_table       : çıkarılan tabloya anlamlı ad, özet, sütun açıklaması/birim önerisi ve ayrıştırma uyarıları
  suggest_questions  : veritabanı şemasına göre örnek soru önerileri
  verbalize          : SQL sonucunu tek-iki cümlelik doğal dil cevaba dönüştürme
  explain_error      : hata kaydını kullanıcıya açıklama ve onarım önerme
  summarize_failures : değerlendirmedeki başarısız örnekleri temalara ayırma
  select_tables      : çok tablolu veritabanlarında soruya ilgili tabloları seçme (şema daraltma)
(SQL üretimi ve kendini düzeltme Text2SQLAgent içinde yapılır.)

Tasarım ilkeleri: AI yalnızca öneri üretir; deterministik çıkarım/ayrıştırma her zaman birincil kaynaktır.
Tüm çağrılar başarısız olursa None döner ve uygulama AI'sız çalışmaya devam eder. Yanıtlar diskte önbelleğe
alınır (aynı girdi için tekrar ücret ödenmez).
"""
from __future__ import annotations

import hashlib
import json
import re
from pathlib import Path

from .llm import LLMBackend, parse_json_reply
from .trnum import slugify

ROOT = Path(__file__).resolve().parent.parent
CACHE = ROOT / "outputs" / ".ai_onbellek.json"

LANG_NAME = {"tr": "Türkçe", "en": "English"}


class AIHelper:
    def __init__(self, backend: LLMBackend | None, lang: str = "tr", cache: bool = True):
        self.backend = backend if isinstance(backend, LLMBackend) else None
        self.lang = lang
        self.use_cache = cache
        self.last_error: str | None = None
        self._mem = self._load()

    # ------------------------------------------------------------ altyapı
    @property
    def active(self) -> bool:
        return self.backend is not None

    @property
    def label(self) -> str:
        return self.backend.name if self.backend else ""

    def _load(self) -> dict:
        try:
            return json.loads(CACHE.read_text(encoding="utf-8")) if self.use_cache and CACHE.exists() else {}
        except Exception:  # noqa: BLE001
            return {}

    def _save(self):
        if not self.use_cache:
            return
        try:
            CACHE.parent.mkdir(parents=True, exist_ok=True)
            items = list(self._mem.items())[-500:]
            CACHE.write_text(json.dumps(dict(items), ensure_ascii=False), encoding="utf-8")
        except Exception:  # noqa: BLE001
            pass

    def _call(self, system: str, user: str, max_tokens: int = 900, as_json: bool = False):
        if not self.active:
            return None
        key = hashlib.sha256(f"{self.backend.name}|{system}|{user}".encode()).hexdigest()[:24]
        if key in self._mem:
            raw = self._mem[key]
        else:
            try:
                raw = self.backend.chat(system, [{"role": "user", "content": user}], max_tokens=max_tokens)
                self.last_error = None
            except Exception as e:  # noqa: BLE001
                self.last_error = str(e)[:300]
                return None
            self._mem[key] = raw
            self._save()
        if not as_json:
            return (raw or "").strip()
        return _parse_any_json(raw)

    def _lang_rule(self) -> str:
        return f"Yanıt dili: {LANG_NAME.get(self.lang, 'Türkçe')}."

    # ------------------------------------------------------------ özellikler
    def enrich_table(self, caption: str, columns: list[dict], sample_rows: list[list], context: str = "") -> dict | None:
        """columns: [{"ad","orijinal","tip","anlam","birim","ayristirilamayan"}]"""
        system = ("[GÖREV:TABLO_ZENGINLESTIR]\nSen Türkçe kamu ve finans belgelerindeki tabloları veritabanına aktaran bir "
                  "veri mühendisisin. Sana belgeden otomatik çıkarılmış bir tablonun başlığı, sütunları ve örnek satırları "
                  "verilecek. Görevin: (1) tabloya kısa, anlamlı, snake_case ve yalnızca ASCII karakterli bir ad önermek "
                  "(en çok 4 sözcük, Türkçe kökler, ör. 'il_elektrik_tuketimi'); (2) tabloyu bir-iki cümleyle özetlemek; "
                  "(3) her sütun için kısa bir açıklama ve (biliniyorsa) birim yazmak; (4) ayrıştırmada şüpheli gördüğün "
                  "durumları (ör. sayısal olması gerekirken metin kalan sütun, tutarsız birim, toplam satırı) uyarı olarak "
                  "listelemek. Sütun adlarını DEĞİŞTİRME; yalnızca verilen adları anahtar olarak kullan. Uydurma bilgi "
                  f"ekleme. {self._lang_rule()}\n"
                  'Yalnızca şu JSON\'u döndür: {"tablo_adi": "...", "ozet": "...", "sutunlar": {"<sütun_adı>": '
                  '{"aciklama": "...", "birim": "..."}}, "uyarilar": ["..."]}')
        user = json.dumps({"belgedeki_baslik": caption, "baglam": context[-400:], "sutunlar": columns,
                           "ornek_satirlar": sample_rows[:5]}, ensure_ascii=False, default=str)
        d = self._call(system, user, max_tokens=1200, as_json=True)
        if not isinstance(d, dict):
            return None
        names = {c["ad"] for c in columns}
        cols = {k: {"aciklama": str((v or {}).get("aciklama", ""))[:240], "birim": str((v or {}).get("birim", "") or "")[:30]}
                for k, v in (d.get("sutunlar") or {}).items() if k in names and isinstance(v, dict)}
        name = slugify(str(d.get("tablo_adi", "")), 200)
        if len(name) > 40:  # sözcük ortasından kesme
            name = "_".join(name[:41].split("_")[:-1]) or name[:40]
        return {"tablo_adi": name if name and name != "sutun" else None,
                "ozet": str(d.get("ozet", ""))[:600], "sutunlar": cols,
                "uyarilar": [str(x)[:240] for x in (d.get("uyarilar") or [])][:6]}

    def suggest_questions(self, schema: str, n: int = 6, seed: int = 0) -> list[str]:
        system = ("[GÖREV:SORU_ONER]\nSana bir SQLite veritabanının şeması (tablo/sütun açıklamaları ve örnek değerler) "
                  f"verilecek. Bu tablolardan kesin cevabı bulunabilen, çeşitli türlerde (seçim, toplam/ortalama, en "
                  f"yüksek, yüzde değişim, filtre, tarih) {n} kısa soru yaz. Soruda tablolarda gerçekten geçen değerleri "
                  f"kullan. {self._lang_rule()} Yalnızca JSON dizi döndür: [\"soru 1\", ...]")
        out = self._call(system, f"(öneri turu {seed})\n{schema[:9000]}", max_tokens=700, as_json=True)
        if isinstance(out, dict):
            out = out.get("sorular") or out.get("questions") or []
        return [str(q).strip() for q in (out or []) if str(q).strip()][:n]

    def verbalize(self, question: str, sql: str, columns: list, rows: list, unit: str | None, fallback: str) -> str | None:
        num_rule = ("Sayıları Türkçe biçimde yaz (1.234,56)." if self.lang == "tr"
                    else "Write numbers in English format (1,234.56).")
        system = (f"[GÖREV:CEVAP]\nKullanıcının sorusunu yalnızca verilen SQL sonucuna dayanarak bir-iki cümleyle yanıtla. "
                  f"{num_rule} Birim verilmişse kullan. Sonuçta olmayan hiçbir bilgiyi ekleme. {self._lang_rule()}")
        user = (f"Soru: {question}\nSQL: {sql}\nSütunlar: {columns}\nSonuç (ilk 20 satır): {rows[:20]}\n"
                f"Birim: {unit or '-'}\nBiçimlenmiş kısa cevap: {fallback}")
        return self._call(system, user, max_tokens=250)

    def explain_error(self, record: dict) -> str | None:
        system = ("[GÖREV:HATA_ACIKLA]\nBir Text-to-SQL sisteminin hata kaydını teknik olmayan bir kullanıcıya açıkla. "
                  "Üç kısa başlık kullan: Ne oldu? Neden oldu? Nasıl düzeltilir? Hata Türkçe sayı/tarih biçimi, birim, "
                  "İ/ı büyük-küçük harf veya belgedeki 'Toplam' satırıyla ilgiliyse bunu özellikle belirt. "
                  f"Kısa tut (en çok 120 sözcük). Markdown kullan. {self._lang_rule()}")
        keep = {k: record.get(k) for k in ("tip", "asama", "soru", "mesaj", "sql", "oneriler")}
        return self._call(system, json.dumps(keep, ensure_ascii=False, default=str), max_tokens=500)

    def summarize_failures(self, records: list[dict]) -> str | None:
        bad = [{k: r.get(k) for k in ("id", "soru", "hata_tipi", "tahmin_sql", "altin_sql", "cevap")}
               for r in records if not r.get("dogru")][:40]
        if not bad:
            return None
        system = ("[GÖREV:DEGERLENDIRME_OZETI]\nBir Text-to-SQL değerlendirmesindeki başarısız örnekleri analiz et. "
                  "Hataları 3-5 temaya grupla; her tema için kaç örnek olduğunu, tipik nedenini ve somut bir iyileştirme "
                  f"önerisini yaz. Örnek ID'lerini parantez içinde ver. Markdown kullan. {self._lang_rule()}")
        return self._call(system, json.dumps(bad, ensure_ascii=False, default=str), max_tokens=900)

    def describe_table(self, caption: str, schema_snippet: str) -> str | None:
        system = ("[GÖREV:TABLO_OZETI]\nVeritabanındaki bir tabloyu iki cümleyle özetle: ne ölçüyor, hangi dönemleri ve "
                  f"birimleri kapsıyor, nasıl sorular sorulabilir. {self._lang_rule()}")
        return self._call(system, f"Başlık: {caption}\n{schema_snippet[:4000]}", max_tokens=250)

    def select_tables(self, question: str, tables: dict[str, str], k: int = 5) -> list[str] | None:
        """tables: {ad: başlık/özet}. Soruya en ilgili en fazla k tabloyu döndürür."""
        system = ("[GÖREV:TABLO_SEC]\nSoru ve veritabanındaki tabloların listesi verilecek. Soruyu cevaplamak için gereken "
                  f"tabloları seç (en çok {k}). Yalnızca listede bulunan adları kullan. Yalnızca JSON dizi döndür: [\"ad\", ...]")
        user = f"Soru: {question}\nTablolar:\n" + "\n".join(f"- {n}: {d}" for n, d in tables.items())
        out = self._call(system, user, max_tokens=200, as_json=True)
        if isinstance(out, dict):
            out = out.get("tablolar") or out.get("tables") or []
        picked = [x for x in (out or []) if isinstance(x, str) and x in tables][:k]
        return picked or None


def _parse_any_json(raw: str):
    if raw is None:
        return None
    txt = raw.strip()
    m = re.search(r"```(?:json)?\s*(.+?)```", txt, re.S)
    if m:
        txt = m.group(1).strip()
    for cand in (txt, _slice(txt, "[", "]"), _slice(txt, "{", "}")):
        if not cand:
            continue
        try:
            return json.loads(cand)
        except json.JSONDecodeError:
            continue
    d = parse_json_reply(raw)
    return d if d.get("sql") else None


def _slice(s: str, a: str, b: str) -> str | None:
    i, j = s.find(a), s.rfind(b)
    return s[i:j + 1] if 0 <= i < j else None