File size: 26,332 Bytes
efc4680
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2665f1f
 
 
 
 
 
 
 
 
 
 
 
 
 
efc4680
2665f1f
 
efc4680
2665f1f
 
efc4680
 
2665f1f
efc4680
2665f1f
efc4680
 
2665f1f
efc4680
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c88e4b7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
efc4680
c88e4b7
 
efc4680
c88e4b7
efc4680
 
c88e4b7
efc4680
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6919acc
 
 
 
 
efc4680
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
import json
import os
import re
import time
from collections import Counter
from datetime import datetime, timezone
from typing import Optional
import requests
from langchain_chroma import Chroma
from langchain_community.embeddings import JinaEmbeddings
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from dotenv import load_dotenv
from app.logger import get_logger, get_jsonl_logger

load_dotenv()

logger = get_logger("retrieval")
query_logger = get_jsonl_logger("queries")

CHROMA_DB_DIR = "./chroma_db"

# ── Sabitler ────────────────────────────────────────────────────────────
MAX_HISTORY_TURNS = 3
LOW_CONFIDENCE_THRESHOLD = 0.2
REFUSAL_MESSAGE = "Bu konu hakkında elimdeki kaynaklarda yeterli bilgi bulunmuyor."
DISCLAIMER = "\n\n---\n*Bu bilgi genel bilgilendirme amaçlıdır. İlacı kullanmadan önce mutlaka doktorunuza veya eczacınıza danışın.*"
DISCLAIMER_MARKER = "doktorunuza veya eczacınıza danışın"

# Global objeler: RAG sistemi ve LLM her çağrıda yeniden oluşturulmaz (Performans Artışı)
db = Chroma(persist_directory=CHROMA_DB_DIR, embedding_function=JinaEmbeddings(jina_api_key=os.environ.get("JINA_API_KEY"), model_name="jina-embeddings-v3"))


def _load_drug_ids() -> list[str]:
    """Chroma'daki benzersiz drug_id'leri döndürür (uzun ad önce sıralı,
    böylece 'abizol 10 mg' eşleşmesi 'abizol'den önce denenir)."""
    try:
        metas = db._collection.get(include=["metadatas"])["metadatas"]
        ids = {m.get("drug_id", "") for m in metas if m.get("drug_id")}
        ids.discard("SKIP")
        return sorted(ids, key=lambda s: (-len(s), s.lower()))
    except Exception as e:
        logger.warning(f"drug_id listesi yüklenemedi: {e}")
        return []


DRUG_IDS = _load_drug_ids()
logger.info(f"Metadata filtering için {len(DRUG_IDS)} drug_id yüklendi")


def _normalize(s: str) -> str:
    """Türkçe karakter-duyarsız, noktalama-sız eşleştirme için normalize.
    ÖNEMLİ: Python'da 'İ'.lower() = 'i\\u0307' (iki karakter) olduğundan,
    Türkçe karakter mapping'i .lower()'dan ÖNCE yapılmalı."""
    tr = str.maketrans("ıİşŞğĞüÜöÖçÇ", "iissgguuoocc")
    s = s.translate(tr).lower()
    return re.sub(r"[^a-z0-9\s]", " ", s)


# ── Reranking ────────────────────────────────────────────────────────────
# Vektör araması (embedding + cosine similarity) hızlıdır ama kaba bir
# sıralama verir: anlamsal olarak yakın ama soruya tam cevap vermeyen
# chunk'lar üst sıralara çıkabilir. Reranker, (query, chunk) çiftlerini
# tek tek değerlendiren bir cross-encoder modelidir ve çok daha isabetli
# sıralama üretir. Akış: similarity_search ile top-N aday al (örn. 20) →
# Jina Reranker API'ye gönder → modelin skorlamasına göre en alakalı
# top_n chunk'ı LLM'e ver. Böylece "doğru ilaç + doğru bölüm" isabeti
# belirgin şekilde artar; karşılığında ~200-500 ms ek latency ve API
# çağrısı maliyeti gelir.
JINA_RERANK_URL = "https://api.jina.ai/v1/rerank"
JINA_RERANK_MODEL = "jina-reranker-v2-base-multilingual"


def rerank_jina_with_scores(query: str, docs: list, top_n: int = 5) -> tuple[list, list[float]]:
    """Aday chunk'ları Jina Reranker v2 (multilingual) ile yeniden sıralar
    ve relevance_score'larıyla birlikte döndürür. API hatasında orijinal
    sıralamanın ilk top_n'ini boş skor listesiyle döndürür."""
    if not docs:
        return docs, []
    api_key = os.environ.get("JINA_API_KEY")
    if not api_key:
        logger.warning("JINA_API_KEY yok, rerank atlandı")
        return docs[:top_n], []
    resp = requests.post(
        JINA_RERANK_URL,
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        },
        json={
            "model": JINA_RERANK_MODEL,
            "query": query,
            "documents": [d.page_content for d in docs],
            "top_n": top_n,
        },
        timeout=15,
    )
    resp.raise_for_status()
    results = resp.json().get("results", [])
    reranked = [docs[r["index"]] for r in results]
    scores = [float(r.get("relevance_score", 0.0)) for r in results]
    logger.info(f"Rerank: {len(docs)} aday → {len(reranked)} chunk")
    return reranked, scores


def rerank_jina(query: str, docs: list, top_n: int = 5) -> list:
    """Geriye dönük imza. Hata durumunda orijinal sıralama döner."""
    try:
        reranked, _ = rerank_jina_with_scores(query, docs, top_n)
        return reranked
    except Exception as e:
        logger.warning(f"Rerank hatası, orijinal sıralama kullanılıyor: {e}")
        return docs[:top_n]


def _token_matches(d_tok: str, q_tokens: list) -> bool:
    """drug-token sorgu token'larında tam eşleşiyor VEYA bir token'ın prefix'i ise True.
    Prefix toleransı yalnızca alfabetik ve >=4 karakterli token'lar için: Türkçe ekleri
    yutmak amaçlı ('lasirini' → 'lasirin', 'parolün' → 'parol', 'majezikten' → 'majezik').
    Sayılar/kısa birimler ('40', 'mg') tam eşleşmeli."""
    if d_tok in q_tokens:
        return True
    if d_tok.isalpha() and len(d_tok) >= 4:
        for q in q_tokens:
            if q.startswith(d_tok):
                return True
    return False


def detect_drug_id(query: str) -> Optional[str]:
    """Sorguda geçen ilk (en uzun) drug_id'yi bulur. Türkçe ek toleranslı:
    'lasirini', 'parolün', 'majezikten' gibi ekli formlar marka adına eşlenir."""
    q_norm = _normalize(query)
    q_tokens = q_norm.split()
    # 1) Multi-token tam eşleşme (ek toleranslı)
    for did in DRUG_IDS:
        d_tokens = _normalize(did).split()
        if d_tokens and all(_token_matches(t, q_tokens) for t in d_tokens):
            return did
    # 2) Marka adı (ilk token) eşleşmesi yeterli
    for did in DRUG_IDS:
        brand = _normalize(did).split()[0] if did else ""
        if brand and _token_matches(brand, q_tokens):
            return did
    return None


# ── Multi-turn yardımcıları ─────────────────────────────────────────────

def build_history_block(history: list, max_turns: int = MAX_HISTORY_TURNS) -> str:
    """Gradio history'sini (user, assistant) tuple listesinden son N turluk düz metin
    bloğuna çevirir. Boşsa "" döner. Gradio bazı sürümlerde dict list de verebilir,
    bu durum da desteklenir."""
    if not history:
        return ""
    recent = history[-max_turns:]
    lines = []
    for turn in recent:
        user_msg, assistant_msg = _extract_turn(turn)
        if user_msg:
            lines.append(f"Kullanıcı: {user_msg.strip()}")
        if assistant_msg:
            # Uzun geçmiş cevaplarını kırp — rewriter'ın bağlam penceresini şişirmesin
            trimmed = assistant_msg.strip()
            if len(trimmed) > 500:
                trimmed = trimmed[:500] + "…"
            lines.append(f"Asistan: {trimmed}")
    return "\n".join(lines)


def _coerce_text(value) -> str:
    """history içinden gelen mesaj içeriğini string'e çevirir.
    Gradio multimodal/messages formatında content; string, None, liste
    (text bloğu / dosya tuple'ı) veya dict olabilir."""
    if value is None:
        return ""
    if isinstance(value, str):
        return value
    if isinstance(value, (list, tuple)):
        parts = []
        for item in value:
            if isinstance(item, str):
                parts.append(item)
            elif isinstance(item, dict):
                t = item.get("text") or item.get("content") or ""
                if isinstance(t, str):
                    parts.append(t)
        return " ".join(p for p in parts if p)
    if isinstance(value, dict):
        t = value.get("text") or value.get("content") or ""
        return t if isinstance(t, str) else ""
    return str(value)


def _extract_turn(turn) -> tuple[str, str]:
    """Hem (user, assistant) tuple hem de {'role','content'} dict pair formatını destekler.
    Her iki tarafı _coerce_text'ten geçirerek liste/None/multimodal içeriği güvenle string'e indirger."""
    if isinstance(turn, (list, tuple)) and len(turn) == 2:
        return _coerce_text(turn[0]), _coerce_text(turn[1])
    if isinstance(turn, dict):
        role = turn.get("role", "")
        content = _coerce_text(turn.get("content", ""))
        if role == "user":
            return content, ""
        if role == "assistant":
            return "", content
    return "", ""


# ── Prompts ─────────────────────────────────────────────────────────────

REWRITER_PROMPT = PromptTemplate.from_template("""Aşağıda bir sohbet geçmişi ve kullanıcının son mesajı var. Görevin: son mesajı, tek başına anlaşılır ve arama motoruna verilebilecek bağımsız bir Türkçe soruya dönüştürmek.

KURALLAR:
1. Son mesajda bir ilaç adı geçiyorsa onu koru; rastgele başka bir ilaç ekleme.
2. Son mesajda ilaç adı geçmiyor ama geçmişte bir ilaç konuşulduysa VE son mesaj o ilacın bir özelliğini (yan etki, doz, etkileşim, hamilelik, yaş, saklama vb.) soran bir takip sorusuysa, o ilacın adını soruya ekle. "bu", "bu ilaç", "o", "o ilaç", "bunun", "onun", "ondan" gibi işaret zamirlerini geçmişteki ilacın adıyla DEĞİŞTİR (sadece ilaç adını eklemekle kalma, zamiri çıkar).
3. "Bunlar", "bunlardan biri", "ikisi", "diğeri" gibi önceki cevaba atıf yapan ifadeleri, geçmişteki asistan cevabından ilgili konuya (yan etki, uyarı, kullanım vb.) çözerek yaz.
4. Son mesaj farklı bir hastalık / durum / şikayet için ilaç ÖNERİSİ sorduğu bağımsız bir soruysa ("X için hangi ilaç", "X tedavisinde hangi ilaçlar kullanılır", "X durumunda ne alınmalı", "X olduğunda hangi ilaç"), geçmişteki ilacı SORUYA EKLEME — bu sorgu önceki ilacın bir özelliği değildir, yeni bir konudur. Soruyu olduğu gibi bırak.
5. Son mesajda YENİ bir ilaç adı geçiyorsa normalde geçmişteki ilacı yok say ve yeni ilaçla devam et.
6. ANCAK son mesaj karşılaştırma ifadesi içeriyorsa ("fark", "farkı", "farkı nedir", "arasındaki", "kıyasla", "göre", "hangisi", "hangisi daha"), hem geçmişteki ilacı hem yeni ilacı KORU ve karşılaştırma sorusunu bozmadan yaz.
7. Hiçbir yerde ilaç adı yoksa ya da mesaj selamlaşma / teşekkür / onay ifadesi ise ("merhaba", "selam", "teşekkürler", "tamam", "anladım", "sağol"), soruyu/ifadeyi aynen aktar; zorla ilaç adı ekleme.
8. Sadece yeniden yazılmış soruyu tek satır olarak döndür. Açıklama, başlık, tırnak işareti, ön-ek ekleme.

ÖRNEKLER:

Örnek 1 (takip sorusu — Kural 2):
SOHBET GEÇMİŞİ:
Kullanıcı: Parol ne için kullanılır?
Asistan: Ağrı ve ateş düşürücü olarak kullanılır.
SON MESAJ: Yan etkileri neler?
YENİDEN YAZILMIŞ SORU: Parol'ün yan etkileri nelerdir?

Örnek 2 (zamir çözümü — Kural 2):
SOHBET GEÇMİŞİ:
Kullanıcı: Majezik hakkında bilgi ver.
Asistan: Majezik bir ağrı kesicidir...
SON MESAJ: Bu ilaç hamilelikte kullanılabilir mi?
YENİDEN YAZILMIŞ SORU: Majezik hamilelikte kullanılabilir mi?

Örnek 3 (konu değişikliği — Kural 5):
SOHBET GEÇMİŞİ:
Kullanıcı: Parol hamilelikte kullanılır mı?
Asistan: Doktor kontrolünde kullanılabilir.
SON MESAJ: Peki Majezik?
YENİDEN YAZILMIŞ SORU: Majezik hamilelikte kullanılır mı?

Örnek 4 (yeni medikal konu — Kural 4, KRİTİK):
SOHBET GEÇMİŞİ:
Kullanıcı: COVADRİN hangi ilaçlarla birlikte kullanılmaz?
Asistan: COVADRİN MAO inhibitörleri ve antidepresanlarla birlikte kullanılmamalıdır.
SON MESAJ: El ve ayak tırnaklarındaki mantar enfeksiyonlarının tedavisinde hangi ilaçlar kullanılabilir?
YENİDEN YAZILMIŞ SORU: El ve ayak tırnaklarındaki mantar enfeksiyonlarının tedavisinde hangi ilaçlar kullanılabilir?

Örnek 5 (karşılaştırma — Kural 6, KRİTİK):
SOHBET GEÇMİŞİ:
Kullanıcı: Parol yan etkileri nelerdir?
Asistan: Mide bulantısı, cilt döküntüsü gibi yan etkiler olabilir.
SON MESAJ: Majezik'ten farkı nedir?
YENİDEN YAZILMIŞ SORU: Parol ile Majezik arasındaki fark nedir?

Örnek 6 (sohbet ifadesi — Kural 7):
SOHBET GEÇMİŞİ:
Kullanıcı: Parol ne için kullanılır?
Asistan: Ağrı ve ateş düşürücü olarak kullanılır.
SON MESAJ: Teşekkürler, çok faydalı oldu
YENİDEN YAZILMIŞ SORU: Teşekkürler, çok faydalı oldu

Örnek 7 (önceki cevaba atıf — Kural 3):
SOHBET GEÇMİŞİ:
Kullanıcı: Parol'ün yan etkileri nelerdir?
Asistan: Mide bulantısı, cilt döküntüsü, baş ağrısı olabilir.
SON MESAJ: Bunlardan biri çocuklarda görülürse ne yapmalı?
YENİDEN YAZILMIŞ SORU: Parol'ün yan etkilerinden biri (mide bulantısı, cilt döküntüsü veya baş ağrısı) çocuklarda görülürse ne yapmalı?

Şimdi aşağıdaki son mesajı yeniden yaz:

SOHBET GEÇMİŞİ:
{history}

SON MESAJ: {query}

YENİDEN YAZILMIŞ SORU:""")


ANSWER_PROMPT = PromptTemplate.from_template("""Sen, Türkiye'de satılan ilaçların resmî "Kullanma Talimatı" (KT) belgelerine dayanarak bilgi veren bir sağlık bilgilendirme asistanısın.

KURALLAR:
1. Her zaman Türkçe yanıt ver.
2. Yalnızca aşağıdaki BAĞLAM bölümünde verilen bilgileri kullan. Bağlamda geçmeyen hiçbir bilgiyi ASLA uydurma, tahmin yürütme veya genel tıp bilgisi ile tamamlama.
3. Bağlamda yanıt için yeterli bilgi yoksa sadece "Bilmiyorum." yaz.
4. Spesifik doz önerisi verme; kişiye özel teşhis koyma; tedavi başlatma/değiştirme önerme. Kullanıcı doz sorarsa KT'de yazan genel bilgiyi aktar ve "Dozaj kararı için doktor/eczacıya danışılmalıdır" de.
5. Kısa, net ve doğrudan cevap ver. Bağlamda olan bilgiyi tekrar etme.
6. GEÇMİŞ KONUŞMA'yı yalnızca kullanıcının sorusunu doğru anlamak için kullan; yanıtın içinde geçmişe atıf yapma.
7. Yanıtın sonuna doktor/eczacıya danışma hatırlatmasını mutlaka ekle.

GEÇMİŞ KONUŞMA:
{history}

BAĞLAM:
{context}

KULLANICININ SORUSU: {question}

YANIT:""")


llm = ChatGoogleGenerativeAI(model="gemini-flash-latest", temperature=0)

rewriter_chain = REWRITER_PROMPT | llm | StrOutputParser()
answer_chain = ANSWER_PROMPT | llm | StrOutputParser()


def rewrite_query(raw_query: str, history: list) -> str:
    """Geçmişi kullanarak sorguyu bağımsız bir soruya dönüştürür.
    Geçmiş boş veya hata durumunda orijinal sorguyu döndürür."""
    history_block = build_history_block(history)
    if not history_block:
        return raw_query
    rewritten = rewriter_chain.invoke({"history": history_block, "query": raw_query})
    rewritten = (rewritten or "").strip().strip('"').strip("'")
    # İlk satırı al — model bazen açıklama ekleyebilir
    rewritten = rewritten.split("\n", 1)[0].strip()
    if not rewritten:
        return raw_query
    return rewritten


# ── Kaynak + uyarı yardımcıları ─────────────────────────────────────────

def format_sources(docs: list) -> str:
    """Kullanılan chunk'ların bölüm + ilaç bilgisini sade liste halinde döndürür.
    Aynı (bölüm, ilaç) tekrarları teke indirir."""
    seen = set()
    lines = []
    for doc in docs:
        section = doc.metadata.get("section", "Bilinmiyor")
        drug = doc.metadata.get("drug_id", "Bilinmiyor")
        key = (section, drug)
        if key in seen:
            continue
        seen.add(key)
        # Bölüm adı "2. Kullanmadan önce..." gibi sayıyla başlarsa Markdown bunu
        # bullet içinde nested ordered list olarak yorumlayıp ●'yu ayrı satıra
        # itiyor; noktayı escape ederek liste içeriği olarak kalmasını sağla.
        section_md = re.sub(r"^(\d+)\.", r"\1\\.", section)
        lines.append(f"- {section_md}{drug}")
    return "\n".join(lines)


def append_disclaimer(answer: str) -> str:
    """Doktor/eczacı uyarısını garanti altına alır."""
    if DISCLAIMER_MARKER in answer:
        return answer
    return answer.rstrip() + DISCLAIMER


def _is_bilmiyorum(text: str) -> bool:
    return bool(re.fullmatch(
        r'(?i)^[^\w]*(üzgünüm|maalesef|hayır)?[^\w]*bilmiyorum[^\w]*$',
        text.strip()
    ))


def _is_quota_error(exc: Exception) -> bool:
    """Google Gemini (veya benzeri) kota / rate-limit hatalarını tespit eder."""
    msg = str(exc).lower()
    return any(tok in msg for tok in (
        "429",
        "quota",
        "resourceexhausted",
        "resource_exhausted",
        "rate limit",
        "rate_limit",
        "exceeded",
    ))


QUOTA_MESSAGE = (
    "⚠️ **Servis geçici olarak yanıt veremiyor.**\n\n"
    "Yapay zekâ modeli için kullanım kotası şu anda dolmuş görünüyor. "
    "Lütfen birkaç dakika bekledikten sonra tekrar deneyin. "
    "Sorun devam ederse günlük limit dolmuş olabilir; bu durumda 24 saat içinde otomatik olarak yenilenecektir."
)


def _build_chunks_debug_string(docs: list) -> str:
    out = ""
    for i, doc in enumerate(docs):
        section_name = doc.metadata.get("section", "Bilinmiyor")
        out += f"**Parça {i+1} ({section_name}):**\n```text\n{doc.page_content}\n```\n\n"
    return out


def _log_candidates_detail(candidates: list, distances: list[float]) -> None:
    """Retrieval'dan gelen aday chunk'ları (rerank öncesi) retrieval.log'a yazar.
    Distance: ChromaDB cosine distance (düşük değer = daha yakın eşleşme)."""
    lines = ["", "═" * 70, f"RETRIEVAL ADAYLARI (RERANK ÖNCESİ) — {len(candidates)} chunk", "═" * 70]
    for i, doc in enumerate(candidates):
        dist = distances[i] if i < len(distances) else None
        dist_str = f"{dist:.4f}" if dist is not None else "N/A"
        lines.append(f"\n┌─ #{i+1}  distance={dist_str}")
        lines.append(f"│  metadata: {doc.metadata}")
        lines.append(f"├─ content ({len(doc.page_content)} karakter)")
        lines.append(doc.page_content)
        lines.append("└" + "─" * 69)
    lines.append("═" * 70)
    logger.info("\n".join(lines))


def _log_chunks_detail(docs: list, scores: list[float]) -> None:
    """Rerank sonrası seçilen chunk'ların tam detayını retrieval.log'a yazar.
    Her chunk için: sıra, skor, tüm metadata, tam metin."""
    lines = ["", "═" * 70, "RERANK SONRASI SEÇİLEN CHUNK'LAR", "═" * 70]
    for i, doc in enumerate(docs):
        score = scores[i] if i < len(scores) else None
        score_str = f"{score:.4f}" if score is not None else "N/A"
        lines.append(f"\n┌─ #{i+1}  score={score_str}")
        lines.append(f"│  metadata: {doc.metadata}")
        lines.append(f"├─ content ({len(doc.page_content)} karakter)")
        lines.append(doc.page_content)
        lines.append("└" + "─" * 69)
    lines.append("═" * 70)
    logger.info("\n".join(lines))


def _log_query_json(payload: dict) -> None:
    try:
        query_logger.info(json.dumps(payload, ensure_ascii=False))
    except Exception as e:
        logger.warning(f"JSONL log hatası: {e}")


# ── Ana akış ────────────────────────────────────────────────────────────

def get_answer(query: str, history: list = None) -> tuple[str, str, str]:
    t_total = time.perf_counter()
    history = history or []
    flags: list[str] = []
    logger.info(f"Sorgu: {query}")

    # 1) Query rewriting
    t = time.perf_counter()
    try:
        rewritten = rewrite_query(query, history) if history else query
    except Exception as e:
        rewritten = query
        flags.append("rewrite_failed")
        logger.warning(f"Rewrite hatası: {e}")
        if _is_quota_error(e):
            flags.append("quota_exhausted")
            t_rewrite_ms = (time.perf_counter() - t) * 1000
            final = append_disclaimer(QUOTA_MESSAGE)
            _emit_log(query, rewritten, None, [], [], final, flags,
                      t_rewrite_ms, 0.0, 0.0, 0.0, t_total)
            return final, "Tespit edilemedi", ""
    t_rewrite_ms = (time.perf_counter() - t) * 1000
    if rewritten != query:
        logger.info(f"Yeniden yazılmış sorgu: {rewritten}")

    # 2) Drug detect (yeniden yazılmış sorgu üzerinde)
    detected = detect_drug_id(rewritten)

    # 3) Retrieval
    t = time.perf_counter()
    search_kwargs: dict = {"k": 20}
    if detected:
        search_kwargs["filter"] = {"drug_id": detected}
        logger.info(f"Metadata filtresi uygulandı: drug_id={detected!r}")
    else:
        logger.info("Sorguda ilaç tespit edilemedi, filtre uygulanmadı")
    try:
        candidates_with_scores = db.similarity_search_with_score(rewritten, **search_kwargs)
        candidates = [d for d, _ in candidates_with_scores]
        candidate_distances = [float(s) for _, s in candidates_with_scores]
    except Exception as e:
        t_retrieval_ms = (time.perf_counter() - t) * 1000
        flags.append("retrieval_failed")
        logger.error(f"Retrieval hatası (embedding/DB erişimi başarısız): {e}")
        final = append_disclaimer(
            "Şu anda arama servisine erişilemiyor. Lütfen internet bağlantınızı kontrol edip birkaç saniye sonra tekrar deneyin."
        )
        _emit_log(query, rewritten, detected, [], [], final, flags,
                  t_rewrite_ms, t_retrieval_ms, 0.0, 0.0, t_total)
        return final, detected or "Tespit edilemedi", ""
    t_retrieval_ms = (time.perf_counter() - t) * 1000

    logger.info(f"Retrieval: {len(candidates)} aday chunk")
    _log_candidates_detail(candidates, candidate_distances)

    # 4) Rerank (+ skorlar)
    t = time.perf_counter()
    try:
        docs, scores = rerank_jina_with_scores(rewritten, candidates, top_n=5)
    except Exception as e:
        docs, scores = candidates[:5], []
        flags.append("rerank_failed")
        logger.warning(f"Rerank hatası, orijinal sıralama kullanılıyor: {e}")
    t_rerank_ms = (time.perf_counter() - t) * 1000

    top_score = max(scores) if scores else 0.0

    # 5) Güven / boş kontrol
    if not docs or (scores and top_score < LOW_CONFIDENCE_THRESHOLD):
        flags.append("no_docs" if not docs else "low_confidence")
        final = append_disclaimer(REFUSAL_MESSAGE)
        _emit_log(query, rewritten, detected, docs, scores, final, flags,
                  t_rewrite_ms, t_retrieval_ms, t_rerank_ms, 0.0, t_total)
        return final, detected or "Tespit edilemedi", ""

    drug_id = docs[0].metadata.get("drug_id", "Bilinmiyor")
    unique_drugs = Counter(d.metadata.get("drug_id", "Bilinmiyor") for d in docs)
    if len(unique_drugs) > 1:
        logger.warning(
            f"Chunk'lar farklı ilaçlardan geliyor ({len(unique_drugs)} farklı drug_id): "
            f"{dict(unique_drugs)}. docs[0]={drug_id} (rerank'te en alakalı) seçildi."
        )
    logger.info(f"Tespit edilen ilaç: {drug_id} | Döküman sayısı: {len(docs)} | top_score={top_score:.3f}")

    # Detaylı chunk log'u (retrieval.log'a)
    _log_chunks_detail(docs, scores)

    # 6) LLM çağrısı
    context = "\n\n".join(d.page_content for d in docs)
    history_block = build_history_block(history) or "(Geçmiş yok)"

    t = time.perf_counter()
    try:
        raw_answer = answer_chain.invoke({
            "context": context,
            "history": history_block,
            "question": rewritten,
        })
    except Exception as e:
        flags.append("llm_failed")
        logger.error(f"LLM hatası: {e}")
        if _is_quota_error(e):
            flags.append("quota_exhausted")
            t_llm_ms = (time.perf_counter() - t) * 1000
            final = append_disclaimer(QUOTA_MESSAGE)
            _emit_log(query, rewritten, detected, docs, scores, final, flags,
                      t_rewrite_ms, t_retrieval_ms, t_rerank_ms, t_llm_ms, t_total)
            return final, detected or "Tespit edilemedi", ""
        raw_answer = "Bilmiyorum."
    t_llm_ms = (time.perf_counter() - t) * 1000

    # 7) "Bilmiyorum" fail-safe + kaynak bloğu
    if _is_bilmiyorum(raw_answer):
        answer = "Bilmiyorum."
        logger.info("Cevap: Bilmiyorum (fail-safe)")
    else:
        answer = raw_answer.strip() + "\n\n---\n**Kaynaklar:**\n" + format_sources(docs)

    # 8) Doktor uyarısı — garanti
    final = append_disclaimer(answer)

    # 9) Log
    _emit_log(query, rewritten, detected, docs, scores, final, flags,
              t_rewrite_ms, t_retrieval_ms, t_rerank_ms, t_llm_ms, t_total)

    logger.info(
        f"Toplam süre: {(time.perf_counter() - t_total) * 1000:.0f}ms | "
        f"Bağlam: {len(context)} karakter"
    )

    used_chunks_str = _build_chunks_debug_string(docs)
    return final, drug_id, used_chunks_str


def _emit_log(raw_query, rewritten, detected, docs, scores, final, flags,
              t_rewrite_ms, t_retrieval_ms, t_rerank_ms, t_llm_ms, t_total_start):
    payload = {
        "ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
        "raw_query": raw_query,
        "rewritten_query": rewritten,
        "detected_drug": detected,
        "retrieved": [
            {
                "idx": i,
                "rerank_score": round(scores[i], 4) if i < len(scores) else None,
                "metadata": dict(d.metadata),
                "content": d.page_content,
            }
            for i, d in enumerate(docs)
        ],
        "answer_preview": (final or "")[:200],
        "latency_ms": {
            "rewrite": round(t_rewrite_ms, 1),
            "retrieval": round(t_retrieval_ms, 1),
            "rerank": round(t_rerank_ms, 1),
            "llm": round(t_llm_ms, 1),
            "total": round((time.perf_counter() - t_total_start) * 1000, 1),
        },
        "flags": flags,
    }
    _log_query_json(payload)