File size: 21,611 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
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
"""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