import math import os import sys import pickle import re import logging import atexit import warnings import copyreg from collections import defaultdict from typing import Any, Dict, List, Optional, Tuple # Bu dosyanın bulunduğu dizin (src/) — CWD'den bağımsız _SRC_DIR = os.path.dirname(os.path.abspath(__file__)) # services/ klasörü BACKEND/ kökünde — hangi dizinden çalıştırılırsa çalıştırılsın bulsun _BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if _BACKEND_DIR not in sys.path: sys.path.insert(0, _BACKEND_DIR) from qdrant_client.http import models as qm from embedder import MursitEmbedder, COLLECTION_NAME from services.qdrant_client import get_qdrant_client warnings.filterwarnings("ignore", category=UserWarning) logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s — %(message)s", datefmt="%H:%M:%S", ) log = logging.getLogger("LawAgent.Retriever") # ═══════════════════════════════════════════════════════════════════════════════ # AYARLAR # ═══════════════════════════════════════════════════════════════════════════════ class CFG: TOP_K_DENSE: int = 200 TOP_K_BM25: int = 200 FINAL_K: int = 15 MAX_SAME_ARTICLE: int = 2 ALPHA_DEFAULT: float = 0.72 ALPHA_EXACT: float = 0.45 ALPHA_SEMANTIC: float = 0.80 BOOST_MADDE: float = 35.0 BOOST_ICTIHAT: float = 6.0 BOOST_KANUN: float = 2.5 CACHE_PATH: str = os.path.join(_SRC_DIR, "data", "retriever_cache.pkl") BOLUM_ANAHTARLARI = ("GEREKÇE", "HÜKÜM", "KARAR", "UYUŞMAZLIK", "SONUÇ") # ═══════════════════════════════════════════════════════════════════════════════ # BM25PLUS SINIFI # ═══════════════════════════════════════════════════════════════════════════════ class BM25Plus: def __init__(self, k1: float = 1.6, b: float = 0.68, delta: float = 1.0): self.k1 = k1 self.b = b self.delta = delta self.n = 0 self.avgdl = 0.0 self.idf: Dict[str, float] = {} self.tf: List[Dict[str, int]] = [] self.dl: List[int] = [] def _tokenize(self, text: str) -> List[str]: text = re.sub(r"[^\w\s]", " ", text.lower()) return [t for t in text.split() if len(t) > 1] def index(self, docs: List[str]) -> None: self.n = len(docs) toks = [self._tokenize(d) for d in docs] self.dl = [len(t) for t in toks] self.avgdl = sum(self.dl) / max(self.n, 1) df = defaultdict(int) for t in toks: for term in set(t): df[term] += 1 self.idf = { term: math.log((self.n - f + 0.5) / (f + 0.5) + 1) for term, f in df.items() } self.tf = [ dict(defaultdict(int, {term: t.count(term) for term in t})) for t in toks ] log.info(f"[BM25+] {self.n} doküman indexlendi.") def score(self, query: str, n: int = 300) -> List[Tuple[int, float]]: q_toks = self._tokenize(query) if not q_toks: return [] scores = [] for i in range(self.n): dl, tf, skor = self.dl[i], self.tf[i], 0.0 for t in q_toks: if t not in self.idf or tf.get(t, 0) == 0: continue f = tf[t] num = f * (self.k1 + 1) den = f + self.k1 * (1 - self.b + self.b * dl / self.avgdl) skor += self.idf[t] * ((num / den) + self.delta) if skor > 0: scores.append((i, skor)) scores.sort(key=lambda x: -x[1]) return scores[:n] # ═══════════════════════════════════════════════════════════════════════════════ # FUSION VE YARDIMCILAR # ═══════════════════════════════════════════════════════════════════════════════ def _minmax_normalize(vals: List[float]) -> List[float]: if not vals: return vals mn, mx = min(vals), max(vals) rng = mx - mn return [(v - mn) / rng if rng > 0 else 1.0 for v in vals] def hybrid_fuse( dense_hits: List[Dict], bm25_hits: List[Dict], alpha: float ) -> List[Dict]: d_norms = _minmax_normalize([h["dense_score"] for h in dense_hits]) dense_map = { h["chunk_id"]: {**h, "dense_norm": dn} for h, dn in zip(dense_hits, d_norms) } b_norms = _minmax_normalize([h["bm25_score"] for h in bm25_hits]) bm25_map = { h["chunk_id"]: {**h, "bm25_norm": bn} for h, bn in zip(bm25_hits, b_norms) } all_cids = set(dense_map) | set(bm25_map) fused = [] for cid in all_cids: d, b = dense_map.get(cid), bm25_map.get(cid) base = d if d else b d_n, b_n = (d["dense_norm"] if d else 0.0), (b["bm25_norm"] if b else 0.0) fused.append( { **base, "skor": round(alpha * d_n + (1 - alpha) * b_n, 6), "dense_score": round(d["dense_score"] if d else 0.0, 4), "bm25_score": round(b["bm25_score"] if b else 0.0, 4), } ) fused.sort(key=lambda x: -x["skor"]) return fused def detect_source_intent(query: str) -> str: q = query.lower() ictihat_keywords = ["karar", "içtihat", "yargıtay", "emsal", "görüş", "daire"] return "yargitay" if any(kw in q for kw in ictihat_keywords) else "hybrid" def normalize_article(x: Any) -> str: if x is None: return "" found = re.search(r"(\d+)", str(x)) return found.group(1) if found else "" _ESANLAMLILAR = { # TBK Eklemeleri "gabin": "aşırı oransızlık sömürme sarsılma orantısız 28", "ikrah": "korkutma tehdit zorlama 37 38 39", "müteselsil": "zincirleme ortaklaşa dayanışma 161 162", # TKHK Eklemeleri "aidat": "üyelik ücreti yıllık ücret kart çıkarma bedeli", "promosyon": "hediye kültürel ürün süreli yayın 21", # TTK (En çok hata burada) "unvan": "ticaret unvanı tecavüz koruma marka 52", "müdür": "limited şirket müdür sorumluluk temsil 623 630 644", "bono": "emre yazılı senet kambiyo senedi zamanaşımı temel ilişki 732", "çek": "karşılıksızdır ibraz şerh arkasına vurulan 796 814", "rüçhan": "yeni pay alma öncelik sermaye artırımı 461", "defter": "ticari defter tasdik delil niteliği 64 222", } def expand_query(query: str) -> str: q = query.lower() ekler = [] for anahtar, deger in _ESANLAMLILAR.items(): if anahtar in q: ekler.append(deger) return query + " " + " ".join(ekler) if ekler else query def detect_kanun(query: str) -> Optional[str]: q = query.lower() mapping = { "tbk": "TBK", "ttk": "TTK", "tkhk": "TKHK", "borçlar": "TBK", "ticaret": "TTK", "tüketici": "TKHK", } for k, v in mapping.items(): if k in q: return v return None def extract_madde(query: str) -> Optional[str]: # "TBK 117", "m.117", "madde 117" gibi tüm varyasyonları yakalar found = re.search(r"(?:tbk|ttk|tkhk|madde|m\.|md\.)?\s*(\d+)", query.lower()) if found: return found.group(1) return None # ═══════════════════════════════════════════════════════════════════════════════ # LEGAL RETRIEVER SINIFI # ═══════════════════════════════════════════════════════════════════════════════ class LegalRetriever: def __init__(self, quantize: bool = False, reindex: bool = False): self.cfg = CFG() self.embedder = MursitEmbedder(quantize=quantize) self.qdrant = get_qdrant_client() self.bm25 = BM25Plus() self.corpus: List[Dict] = [] atexit.register(self._kapat) self._load(reindex) def _kapat(self): try: self.qdrant.close() except: pass def _load(self, reindex: bool) -> None: if not reindex and os.path.exists(self.cfg.CACHE_PATH): # Pickle __main__.BM25Plus → retriever.BM25Plus yönlendirmesi # (retriever.py doğrudan çalıştırılıp cache kaydedilmişse gerekli) import sys as _sys _this_module = _sys.modules.get(__name__) or _sys.modules.get("retriever") if _this_module and not hasattr(_sys.modules.get("__main__"), "BM25Plus"): import types _fake = types.ModuleType("__main__") _fake.BM25Plus = BM25Plus _sys.modules.setdefault("__main__", _fake) _sys.modules["__main__"].BM25Plus = BM25Plus try: with open(self.cfg.CACHE_PATH, "rb") as f: data = pickle.load(f) self.corpus, self.bm25 = data["corpus"], data["bm25"] log.info(f"Cache yuklendi: {len(self.corpus)} chunk.") return except (AttributeError, ModuleNotFoundError) as e: log.warning(f"Cache yuklenemedi ({e}), Qdrant'tan yeniden olusturuluyor...") # Bozuk cache'i sil, aşağıda yeniden oluşturulacak try: os.remove(self.cfg.CACHE_PATH) except OSError: pass log.info("Corpus Qdrant'tan çekiliyor (Scroll)...") offset = None while True: res, offset = self.qdrant.scroll( COLLECTION_NAME, limit=500, offset=offset, with_payload=True ) for p in res: pay = p.payload self.corpus.append( { "chunk_id": str(p.id), "text": pay.get("text", ""), "law": pay.get("law", ""), "article_no": normalize_article(pay.get("article_no")), "source": pay.get("source", ""), "decision_id": pay.get("decision_id", ""), } ) if offset is None: break self.bm25.index([c["text"] for c in self.corpus]) os.makedirs(os.path.dirname(self.cfg.CACHE_PATH), exist_ok=True) with open(self.cfg.CACHE_PATH, "wb") as f: pickle.dump({"corpus": self.corpus, "bm25": self.bm25}, f) def retrieve(self, query: str, k: int = None) -> List[Dict]: k = k or self.cfg.FINAL_K # Sorgu Genişletme (Sadece BM25 için değil, tüm süreci besler) expanded_q = expand_query(query) kanun, madde, source_intent = ( detect_kanun(query), extract_madde(query), detect_source_intent(query), ) # --- DİNAMİK ALPHA (GÜNCELLENDİ) --- query_len = len(query.split()) if madde: alpha = self.cfg.ALPHA_EXACT elif query_len > 10: # Uzun cümlelerde Mürşit'e daha çok güven alpha = self.cfg.ALPHA_SEMANTIC else: alpha = self.cfg.ALPHA_DEFAULT # 1. Dense Search (Orijinal query ile anlamsal arama) vec = self.embedder.encode_single(query) dense_results = self.qdrant.query_points( COLLECTION_NAME, query=vec, limit=self.cfg.TOP_K_DENSE, with_payload=True ).points dense_hits = [{**p.payload, "dense_score": p.score} for p in dense_results] # 2. BM25 Search (Genişletilmiş query ile anahtar kelime araması) bm25_hits_raw = self.bm25.score(expanded_q, n=self.cfg.TOP_K_BM25) bm25_hits = [{**self.corpus[idx], "bm25_score": s} for idx, s in bm25_hits_raw] # 3. Hybrid Fusion fused = hybrid_fuse(dense_hits, bm25_hits, alpha) # 4. Boosting for c in fused: # article_no'yu her ihtimale karşı tekrar temizleyip string yapıyoruz res_art = normalize_article(c.get("article_no", "")) # Madde numarası tam eşleşiyorsa if madde and str(res_art) == str(madde): # DEBUG: Gerçekten buraya giriyor mu görmek için terminale yazdırabilirsin # print(f"DEBUG: {madde} maddesi yakalandı, boost uygulanıyor.") c["skor"] *= self.cfg.BOOST_MADDE # Diğer boostlar... if source_intent == "yargitay" and c.get("source") == "yargitay": c["skor"] *= self.cfg.BOOST_ICTIHAT if kanun and str(c.get("law", "")).upper() == str(kanun).upper(): c["skor"] *= self.cfg.BOOST_KANUN # Skorlara göre tekrar sırala fused.sort(key=lambda x: -x["skor"]) return self._filter_results(fused, k) def _filter_results(self, results: List[Dict], k: int) -> List[Dict]: seen_art = defaultdict(int) # mevzuat maddeleri için seen_dec = defaultdict(int) # içtihat kararları için filtered = [] for r in results: if r["source"] == "mevzuat": key = f"{r['law']}_{r['article_no']}" # Aynı maddeden en fazla 1 kez al (config'te MAX_SAME_ARTICLE=1 olmalı) if seen_art[key] >= self.cfg.MAX_SAME_ARTICLE: continue seen_art[key] += 1 elif r["source"] == "yargitay": key = r["decision_id"] # Aynı karardan sadece 1 kez al (diversity artırıldı) if seen_dec[key] >= 1: continue seen_dec[key] += 1 filtered.append(r) if len(filtered) >= k: break return filtered if __name__ == "__main__": r = LegalRetriever() # İçtihat beklediğimiz bir sorgu test_sorgu = "Kira bedeli ödenmezse tahliye süreci ve Yargıtay kararları" print(f"\n🔍 TEST SORGUSU: {test_sorgu}") print("-" * 80) sonuclar = r.retrieve(test_sorgu, k=5) for i, s in enumerate(sonuclar, 1): kaynak = s.get("source", "Bilinmiyor").upper() madde = s.get("article_no", "-") karar_id = s.get("decision_id", "-") skor = s.get("skor", 0) print(f"{i}. [{kaynak}] | Skor: {skor:.4f}") if kaynak == "YARGITAY": print(f" ⚖️ KARAR ID: {karar_id}") else: print(f" 📜 MADDE: {s.get('law')} m.{madde}") print(f" 📝 METİN: {s.get('text')[:150]}...") print("-" * 80)