Text-to-SQL / t2s /llm.py
SevdanurGenc's picture
Upload 466 files
a72d1cf verified
Raw History Blame Contribute Delete
21.6 kB
"""LLM arka uçları ve model senkronizasyonu.
Sağlayıcılar
claude : Anthropic Messages API (ANTHROPIC_API_KEY, isteğe bağlı ANTHROPIC_BASE_URL)
openai : OpenAI veya OpenAI uyumlu uç nokta (OPENAI_API_KEY, OPENAI_BASE_URL; vLLM, LM Studio, OpenRouter…)
ollama : Yerel Ollama sunucusu (OLLAMA_HOST veya OLLAMA_BASE_URL, vars. http://localhost:11434)
Model listeleri doğrudan sağlayıcının API'sinden çekilir (list_models); kullanıcı model adını elle yazmak
zorunda kalmaz. Her çağrının sayısı ve belirteç kullanımı USAGE içinde tutulur.
"""
from __future__ import annotations
import json
import os
import re
import time
from dataclasses import dataclass, field
from pathlib import Path
import requests
ANTHROPIC_VERSION = "2023-06-01"
DEFAULT_TIMEOUT = 120
SYSTEM_PROMPT = """[GÖREV:SQL]
Sen, Türkçe resmi belgelerden (rapor, mevzuat, finansal tablo) otomatik çıkarılmış tablolar üzerinde çalışan bir SQLite uzmanısın. Kullanıcının sorusunu (Türkçe veya İngilizce olabilir) TEK bir SQLite SELECT sorgusuna çevir.
Veritabanı kuralları:
- Sayılar veritabanında sayısal tipte ve nokta ondalık ayraçla saklanır. Belgedeki '1.234,56' değeri 1234.56'dır. SQL'e asla Türkçe biçimli sayı ('1.234,56', '4,5') yazma.
- Yüzde sütunları (birim: %) yüzde puanı olarak saklanır: '%94,3' -> 94.3.
- Tarihler ISO biçimindedir: tam tarih 'YYYY-AA-GG', ay-yıl 'YYYY-AA'. '15.03.2025' -> '2025-03-15', 'Mart 2025' -> '2025-03'.
- Tablolardaki metin değerleri Türkçedir; İngilizce soruda geçen adları Türkçe karşılıklarıyla eşle (ör. 'personnel expenses' -> 'Personel Giderleri').
- Metin karşılaştırmalarında Türkçe İ/ı sorunlarına karşı TR_NORM(sutun) = TR_NORM('değer') kullanabilirsin.
- Birimler sütun açıklamalarında verilir (ör. 'bin TL', 'bin MWh'). Soru başka bir birim isterse dönüştür (bin TL -> TL için *1000).
- 'toplam_satiri' sütunu olan tablolarda toplam_satiri = 1 satırı belgedeki hazır Toplam satırıdır. Toplama, ortalama, sayım ve sıralamalarda WHERE toplam_satiri = 0 kullan.
- Negatif değerler giderleri gösterebilir (finansal tablolarda parantezli sayılar).
- Yüzde değişim = (yeni - eski) * 100.0 / eski. Tamsayı bölmesinden kaçınmak için 100.0 veya 1.0 ile çarp.
- Yalnızca verilen tablo ve sütunları kullan. Sütun adlarını çift tırnakla yazabilirsin.
Cevaplanabilirlik:
- Sorunun cevabı tablolarda yoksa (tabloda olmayan yıl, dönem, il, kalem, kurum vb.) cevap UYDURMA; "cevaplanabilir": false döndür ve gerekçesini sorunun dilinde yaz.
Çıktı: Yalnızca şu JSON nesnesi, başka metin yok:
{"dusunce": "<kısa gerekçe: hangi tablo/sütun/koşul>", "cevaplanabilir": true|false, "sql": "<SQLite sorgusu veya boş>", "gerekce": "<cevaplanamıyorsa nedeni>"}"""
def build_user_prompt(question: str, schema: str, evidence: str | None = None) -> str:
s = f"### Şema\n{schema}\n\n### Soru\n{question}\n"
if evidence:
s += f"\n### Ek bilgi (kanıt)\n{evidence}\n"
return s
def parse_json_reply(text: str) -> dict:
text = (text or "").strip()
m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S)
if m:
text = m.group(1)
else:
m = re.search(r"\{.*\}", text, re.S)
if m:
text = m.group(0)
try:
d = json.loads(text)
except json.JSONDecodeError:
m = re.search(r"(select|with)\b.*", text, re.I | re.S)
return {"cevaplanabilir": bool(m), "sql": m.group(0).strip().strip("`") if m else "",
"gerekce": "" if m else "Model geçerli JSON üretmedi.", "dusunce": ""}
if not isinstance(d, dict):
return {"cevaplanabilir": False, "sql": "", "gerekce": "Model geçerli JSON üretmedi.", "dusunce": ""}
d.setdefault("cevaplanabilir", bool(d.get("sql")))
d.setdefault("sql", "")
d.setdefault("gerekce", "")
return d
# ======================================================================= kullanım sayacı
@dataclass
class Usage:
calls: int = 0
errors: int = 0
input_tokens: int = 0
output_tokens: int = 0
seconds: float = 0.0
by_task: dict = field(default_factory=dict)
def add(self, task: str, inp: int, out: int, secs: float, ok: bool = True):
self.calls += 1
self.errors += 0 if ok else 1
self.input_tokens += inp or 0
self.output_tokens += out or 0
self.seconds += secs
self.by_task[task] = self.by_task.get(task, 0) + 1
USAGE = Usage()
def _task_of(system: str) -> str:
m = re.search(r"\[GÖREV:([A-Z_]+)\]", system or "")
return m.group(1) if m else "DIGER"
# ======================================================================= arka uçlar
class LLMBackend:
provider = "llm"
name = "llm"
model = ""
def chat(self, system: str, messages: list[dict], max_tokens: int = 800) -> str:
raise NotImplementedError
def ping(self) -> tuple[bool, str]:
try:
t0 = time.time()
out = self.chat("[GÖREV:PING] Yalnızca 'ok' yaz.", [{"role": "user", "content": "ping"}], max_tokens=5)
return True, f"{out.strip()[:40]} · {time.time() - t0:.1f} sn"
except Exception as e: # noqa: BLE001
return False, str(e)[:300]
# Bazı yeni modeller belirli parametreleri reddeder (ör. Claude'un yeni modellerinde `temperature`,
# OpenAI'nin yeni modellerinde `max_tokens`). Reddedilen parametre model başına öğrenilir ve istek
# o parametre olmadan bir kez daha gönderilir.
_DROPPED: dict[str, set[str]] = {}
_PARAM_RE = re.compile(r"(temperature|max_tokens)")
def _learn_rejected_param(name: str, r) -> bool:
"""400 yanıtı bir parametrenin desteklenmediğini söylüyorsa onu kaydeder ve True döndürür."""
if r.status_code != 400:
return False
txt = r.text[:600].lower()
if not any(w in txt for w in ("unsupported", "deprecated", "not supported", "does not support")):
return False
m = _PARAM_RE.search(txt)
if not m or m.group(1) in _DROPPED.get(name, set()):
return False
_DROPPED.setdefault(name, set()).add(m.group(1))
return True
class AnthropicBackend(LLMBackend):
provider = "claude"
def __init__(self, model: str | None = None, api_key: str | None = None, temperature: float = 0.0,
base_url: str | None = None):
self.model = model or os.getenv("ANTHROPIC_MODEL", "")
self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY")
self.base_url = (base_url or os.getenv("ANTHROPIC_BASE_URL") or "https://api.anthropic.com").rstrip("/")
self.temperature = temperature
self.name = f"claude:{self.model}"
if not self.api_key:
raise RuntimeError("ANTHROPIC_API_KEY tanımlı değil.")
if not self.model:
raise RuntimeError("Claude modeli seçilmedi.")
def chat(self, system, messages, max_tokens=800):
t0 = time.time()
task = _task_of(system)
try:
for _ in range(3):
body = {"model": self.model, "max_tokens": max_tokens, "system": system, "messages": messages}
if "temperature" not in _DROPPED.get(self.name, set()):
body["temperature"] = self.temperature
r = requests.post(f"{self.base_url}/v1/messages", timeout=DEFAULT_TIMEOUT,
headers={"x-api-key": self.api_key, "anthropic-version": ANTHROPIC_VERSION,
"content-type": "application/json"}, json=body)
if not _learn_rejected_param(self.name, r):
break
if r.status_code >= 400:
raise RuntimeError(f"Anthropic API {r.status_code}: {r.text[:300]}")
d = r.json()
u = d.get("usage", {})
USAGE.add(task, u.get("input_tokens", 0), u.get("output_tokens", 0), time.time() - t0)
return "".join(b.get("text", "") for b in d.get("content", []) if b.get("type") == "text")
except Exception:
USAGE.add(task, 0, 0, time.time() - t0, ok=False)
raise
class OpenAICompatBackend(LLMBackend):
provider = "openai"
def __init__(self, model: str, base_url: str | None = None, api_key: str | None = None,
temperature: float = 0.0, timeout: int = DEFAULT_TIMEOUT):
if not model:
raise RuntimeError("Model seçilmedi.")
self.model = model
self.base_url = (base_url or os.getenv("OPENAI_BASE_URL") or "https://api.openai.com/v1").rstrip("/")
self.api_key = api_key or os.getenv("OPENAI_API_KEY") or "yok"
self.temperature = temperature
self.timeout = timeout
self.name = f"openai:{model}"
def _payload(self, system, messages, max_tokens):
p = {"model": self.model, "messages": [{"role": "system", "content": system}] + messages}
dropped = _DROPPED.get(self.name, set())
# Yeni OpenAI akıl yürütme modelleri max_tokens / temperature kabul etmez
new_style = "api.openai.com" in self.base_url and re.match(r"^(o\d|gpt-([5-9]|\d\d))", self.model)
if new_style or "max_tokens" in dropped:
p["max_completion_tokens"] = max(max_tokens, 2000)
else:
p["max_tokens"] = max_tokens
if not new_style and "temperature" not in dropped:
p["temperature"] = self.temperature
return p
def chat(self, system, messages, max_tokens=800):
t0 = time.time()
task = _task_of(system)
try:
for _ in range(3):
r = requests.post(f"{self.base_url}/chat/completions", timeout=self.timeout,
headers={"Authorization": f"Bearer {self.api_key}"},
json=self._payload(system, messages, max_tokens))
if not _learn_rejected_param(self.name, r):
break
if r.status_code >= 400:
raise RuntimeError(f"API {r.status_code}: {r.text[:300]}")
d = r.json()
u = d.get("usage") or {}
USAGE.add(task, u.get("prompt_tokens", 0), u.get("completion_tokens", 0), time.time() - t0)
return d["choices"][0]["message"]["content"] or ""
except Exception:
USAGE.add(task, 0, 0, time.time() - t0, ok=False)
raise
def ollama_host() -> str:
h = os.getenv("OLLAMA_HOST") or os.getenv("OLLAMA_BASE_URL") or "http://localhost:11434"
if not h.startswith("http"):
h = "http://" + h
return re.sub(r"/v1/?$", "", h.rstrip("/"))
OLLAMA_DEVICES = ("auto", "gpu", "cpu")
def ollama_device_options(device: str | None) -> dict:
"""Hesaplama aygıtı -> Ollama 'options'. auto: Ollama karar verir (sığmayan katmanlar CPU'da);
gpu: tüm katmanları ekran kartına yüklemeyi dener; cpu: ekran kartını hiç kullanmaz."""
d = (device or "auto").lower()
if d == "cpu":
return {"num_gpu": 0}
if d == "gpu":
return {"num_gpu": 999}
return {}
class OllamaBackend(LLMBackend):
"""Ollama'nın yerel /api/chat uç noktası: hesaplama aygıtı (num_gpu) ancak burada seçilebilir."""
provider = "ollama"
def __init__(self, model: str, host: str | None = None, temperature: float = 0.0, device: str | None = None,
timeout: int = 600):
if not model:
raise RuntimeError("Ollama modeli seçilmedi.")
self.model = model
self.host = (host or ollama_host()).rstrip("/")
self.host = re.sub(r"/v1$", "", self.host)
self.temperature = temperature
self.device = (device or "auto").lower()
self.timeout = timeout
self.name = f"ollama:{model}"
def chat(self, system, messages, max_tokens=800):
t0 = time.time()
task = _task_of(system)
opts = {"temperature": self.temperature, "num_predict": max_tokens, **ollama_device_options(self.device)}
try:
r = requests.post(f"{self.host}/api/chat", timeout=self.timeout,
json={"model": self.model, "stream": False, "options": opts,
"messages": [{"role": "system", "content": system}] + messages})
if r.status_code >= 400:
raise RuntimeError(f"Ollama {r.status_code}: {r.text[:300]}")
d = r.json()
USAGE.add(task, d.get("prompt_eval_count", 0), d.get("eval_count", 0), time.time() - t0)
return (d.get("message") or {}).get("content", "")
except Exception:
USAGE.add(task, 0, 0, time.time() - t0, ok=False)
raise
def ollama_backend(model: str = "qwen2.5-coder:7b") -> OllamaBackend:
return OllamaBackend(model)
class ScriptedBackend(LLMBackend):
"""Testler için: önceden yazılmış yanıtları sırayla döndürür (kendini düzeltme döngüsünü sınamak için)."""
provider = "test"
def __init__(self, replies: list[str]):
self.replies = list(replies)
self.calls: list[list[dict]] = []
self.name = "betikli-test"
def chat(self, system, messages, max_tokens=800):
self.calls.append(messages)
return self.replies.pop(0) if self.replies else '{"cevaplanabilir": false, "sql": "", "gerekce": "bitti"}'
# ======================================================================= model senkronizasyonu
@dataclass
class ModelInfo:
id: str
label: str
created: str | int | None = None
size: int | None = None
note: str = ""
_OPENAI_SKIP = re.compile(r"(embed|tts|whisper|dall-e|davinci|babbage|moderation|image|audio|realtime|transcribe|"
r"search|instruct|computer-use|codex-mini|sora|-vision-preview)", re.I)
_OPENAI_CHAT = re.compile(r"^(gpt-|o\d|chatgpt)", re.I)
def list_claude_models(api_key: str | None = None, base_url: str | None = None, timeout: int = 15) -> list[ModelInfo]:
key = api_key or os.getenv("ANTHROPIC_API_KEY")
if not key:
raise RuntimeError("ANTHROPIC_API_KEY gerekli.")
base = (base_url or os.getenv("ANTHROPIC_BASE_URL") or "https://api.anthropic.com").rstrip("/")
out, after = [], None
for _ in range(10): # sayfalama
params = {"limit": 100}
if after:
params["after_id"] = after
r = requests.get(f"{base}/v1/models", params=params, timeout=timeout,
headers={"x-api-key": key, "anthropic-version": ANTHROPIC_VERSION})
if r.status_code >= 400:
raise RuntimeError(f"Anthropic API {r.status_code}: {r.text[:200]}")
d = r.json()
for m in d.get("data", []):
out.append(ModelInfo(m["id"], m.get("display_name") or m["id"], m.get("created_at")))
if not d.get("has_more"):
break
after = d.get("last_id")
out.sort(key=lambda m: str(m.created or ""), reverse=True) # en yeni üstte
return out
def list_openai_models(api_key: str | None = None, base_url: str | None = None, timeout: int = 15) -> list[ModelInfo]:
base = (base_url or os.getenv("OPENAI_BASE_URL") or "https://api.openai.com/v1").rstrip("/")
key = api_key or os.getenv("OPENAI_API_KEY") or ""
if "api.openai.com" in base and not key:
raise RuntimeError("OPENAI_API_KEY gerekli.")
r = requests.get(f"{base}/models", timeout=timeout, headers={"Authorization": f"Bearer {key or 'yok'}"})
if r.status_code >= 400:
raise RuntimeError(f"API {r.status_code}: {r.text[:200]}")
return filter_openai_models(r.json().get("data", []), official="api.openai.com" in base)
def filter_openai_models(data: list[dict], official: bool) -> list[ModelInfo]:
"""Resmî OpenAI uç noktasında yalnızca sohbet modellerini bırakır (gömme, ses, görüntü vb. elenir).
OpenAI uyumlu diğer sunucularda (vLLM, LM Studio, OpenRouter) tüm modeller listelenir."""
out = []
for m in data:
mid = m.get("id", "")
if official and (not _OPENAI_CHAT.match(mid) or _OPENAI_SKIP.search(mid)):
continue
out.append(ModelInfo(mid, mid, m.get("created"), note=m.get("owned_by", "")))
out.sort(key=lambda m: (m.created or 0) if isinstance(m.created, (int, float)) else 0, reverse=True)
return out
def list_ollama_models(host: str | None = None, timeout: int = 5) -> list[ModelInfo]:
h = (host or ollama_host()).rstrip("/")
r = requests.get(f"{h}/api/tags", timeout=timeout)
if r.status_code >= 400:
raise RuntimeError(f"Ollama {r.status_code}: {r.text[:200]}")
out = []
for m in r.json().get("models", []):
det = m.get("details") or {}
note = " · ".join(x for x in (det.get("parameter_size"), det.get("quantization_level")) if x)
out.append(ModelInfo(m.get("name") or m.get("model"), m.get("name") or m.get("model"),
m.get("modified_at"), m.get("size"), note))
return out
def list_models(provider: str, api_key: str | None = None, base_url: str | None = None) -> list[ModelInfo]:
if provider == "claude":
return list_claude_models(api_key, base_url)
if provider == "openai":
return list_openai_models(api_key, base_url)
if provider == "ollama":
return list_ollama_models(base_url)
return []
@dataclass
class OllamaRec:
id: str
size_gb: float # indirme boyutu (yaklaşık, 4-bit)
min_ram_gb: int # rahat çalışma için önerilen bellek (VRAM ya da RAM)
note_tr: str
note_en: str
# Text-to-SQL için önerilen yerel modeller (boyutlar yaklaşık; Ollama kütüphanesindeki varsayılan 4-bit sürümler)
OLLAMA_RECS = [
OllamaRec("qwen2.5-coder:7b", 4.7, 8, "Önerilen başlangıç: SQL ve kodda güçlü, 8 GB'lık kartta çalışır.",
"Recommended start: strong at SQL and code, runs on an 8 GB card."),
OllamaRec("qwen2.5-coder:14b", 9.0, 16, "Daha isabetli SQL; 12–16 GB VRAM ya da 16 GB+ RAM.",
"More accurate SQL; 12–16 GB VRAM or 16 GB+ RAM."),
OllamaRec("qwen2.5-coder:32b", 20.0, 32, "Yerelde en güçlü seçeneklerden; 24 GB+ VRAM.",
"Among the strongest local options; 24 GB+ VRAM."),
OllamaRec("qwen3:8b", 5.2, 10, "Genel amaçlı, Türkçesi iyi; düşünme kipi yanıtı uzatabilir.",
"General purpose with good Turkish; thinking mode can lengthen replies."),
OllamaRec("gpt-oss:20b", 14.0, 16, "OpenAI'nin açık ağırlıklı modeli; 16 GB bellekte çalışır.",
"OpenAI's open-weight model; runs in 16 GB of memory."),
OllamaRec("gemma3:12b", 8.1, 12, "Çok dilli; Türkçe soruları anlamada iyi.", "Multilingual; good at Turkish questions."),
OllamaRec("llama3.1:8b", 4.9, 8, "Yaygın temel model; karşılaştırma için.", "Widely used baseline; for comparison."),
]
RECOMMENDED_OLLAMA = [r.id for r in OLLAMA_RECS]
def ollama_version(host: str | None = None, timeout: int = 4) -> str:
h = (host or ollama_host()).rstrip("/")
r = requests.get(f"{h}/api/version", timeout=timeout)
r.raise_for_status()
return r.json().get("version", "?")
def ollama_pull(model: str, host: str | None = None):
"""Ollama'ya model indirtir; (durum, tamamlanan, toplam) üçlülerini akış hâlinde verir."""
h = (host or ollama_host()).rstrip("/")
with requests.post(f"{h}/api/pull", json={"model": model, "stream": True}, stream=True, timeout=3600) as r:
if r.status_code >= 400:
raise RuntimeError(f"Ollama {r.status_code}: {r.text[:200]}")
for line in r.iter_lines():
if not line:
continue
d = json.loads(line)
if d.get("error"):
raise RuntimeError(d["error"])
yield d.get("status", ""), d.get("completed"), d.get("total")
def make_backend(provider: str, model: str, api_key: str | None = None, base_url: str | None = None,
device: str | None = None) -> LLMBackend:
if provider == "claude":
return AnthropicBackend(model, api_key, base_url=base_url)
if provider == "openai":
return OpenAICompatBackend(model, base_url, api_key)
if provider == "ollama":
return OllamaBackend(model, base_url, device=device)
raise ValueError(provider)
def available_backends() -> dict[str, str]:
"""Ortamda hazır olan sağlayıcılar (anahtar veya çalışan sunucu)."""
out = {"kural": "Kural tabanlı (çevrimdışı, anahtar gerekmez)"}
if os.getenv("ANTHROPIC_API_KEY"):
out["claude"] = "Claude (ANTHROPIC_API_KEY bulundu)"
if os.getenv("OPENAI_API_KEY"):
out["openai"] = "OpenAI uyumlu (OPENAI_API_KEY bulundu)"
try:
if requests.get(ollama_host() + "/api/tags", timeout=0.8).ok:
out["ollama"] = "Ollama (yerel)"
except Exception: # noqa: BLE001
pass
return out
def load_dotenv(path: str | Path) -> list[str]:
"""Basit .env okuyucu: mevcut ortam değişkenlerini ezmez. Yüklenen anahtar adlarını döndürür."""
p = Path(path)
loaded = []
if not p.exists():
return loaded
for line in p.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
k, v = k.strip().removeprefix("export ").strip(), v.strip().strip('"').strip("'")
if k and k not in os.environ:
os.environ[k] = v
loaded.append(k)
return loaded