Spaces:
Sleeping
Sleeping
| 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) | |