ilacChatBot / app /retrieval.py
emrecn's picture
fix: drug detection Türkçe ek toleransı (prefix match) — 'lasirini'→'lasirin'
2665f1f
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)