"""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": "", "cevaplanabilir": true|false, "sql": "", "gerekce": ""}""" 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