| import re |
| import unicodedata |
| from functools import lru_cache |
|
|
| GENERIC_TERMS = {
|
| "kanun", "kanunu", "madde", "yuksekogretim", "kurum", "kurumu",
|
| "kurumlari", "universite", "esaslari", "gorevleri", "yetkileri",
|
| "sartlari", "nedir", "nelerdir",
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| GENERIC_TERMS = { |
| "kanun", "kanunu", "madde", "yuksekogretim", "kurum", "kurumu", |
| "kurumlari", "universite", "esaslari", "gorevleri", "yetkileri", |
| "sartlari", "nedir", "nelerdir", |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| TOPIC_STOPWORDS = { |
| "hangi", "nedir", "nasil", "kim", "kimler", "kimleri", "ne", "kadar", |
| "gore", "sayili", "kanun", "kanuna", "kanunu", "madde", "maddesi", |
| "hakkinda", "bilgi", "ver", "verir", "genel", "mevcut", "ilgili", |
| "icin", "bir", "ve", "ile", "veya", "bu", "de", "da", "olan", "olarak", |
| "kapsar", "duzenler", "neler", "nelerdir", "var", "yok", "midir", "mudur", |
| } |
|
|
|
|
| _SEARCH_TRANSLATION = str.maketrans("çğıöşü", "cgiosu") |
| _NON_SEARCH_CHARACTER_RE = re.compile(r"[^a-z0-9/\s-]") |
| _SEARCH_WHITESPACE_RE = re.compile(r"\s+") |
|
|
| _STEMMER = None |
| try: |
| from TurkishStemmer import TurkishStemmer |
| _STEMMER = TurkishStemmer() |
| except ImportError: |
| try: |
| import snowballstemmer |
| _STEMMER = snowballstemmer.stemmer("turkish") |
| except ImportError: |
| try: |
| from trnlp import TrnlpWord |
| _STEMMER = "trnlp" |
| except ImportError: |
| _STEMMER = None |
|
|
|
|
| @lru_cache(maxsize=100_000) |
| def normalize_for_search(text: str) -> str: |
| """Normalize Turkish text once and reuse it across retrieval channels. |
| |
| Corpus strings are scored repeatedly by BM25, semantic-address, role and |
| evidence matchers during one query. The function is pure, so a bounded |
| process-local cache removes duplicate Unicode work without changing any |
| score or persisted representation. |
| """ |
| text = unicodedata.normalize("NFKD", text or "") |
| text = "".join(ch for ch in text if unicodedata.category(ch) != "Mn") |
| text = text.lower() |
| text = text.translate(_SEARCH_TRANSLATION) |
| text = _NON_SEARCH_CHARACTER_RE.sub(" ", text) |
| return _SEARCH_WHITESPACE_RE.sub(" ", text).strip() |
|
|
|
|
| @lru_cache(maxsize=50_000) |
| def get_turkish_stem(word: str) -> str: |
| """Extract the dictionary/morphological stem of a Turkish word. |
| |
| Supports TurkishStemmer, snowballstemmer and trnlp with LRU caching. |
| Fallback to normalized word if no stemmer package is installed. |
| """ |
| word_norm = normalize_for_search(word) |
| if not word_norm or _STEMMER is None: |
| return word_norm |
| if len(word_norm) <= 3 or word_norm.isdigit(): |
| return word_norm |
| try: |
| if _STEMMER == "trnlp": |
| from trnlp import TrnlpWord |
| obj = TrnlpWord(word_norm) |
| obj.get_stem |
| stem = str(getattr(obj, "stem", "") or "").strip() |
| elif hasattr(_STEMMER, "stem"): |
| stem = _STEMMER.stem(word_norm) |
| elif hasattr(_STEMMER, "stemWord"): |
| stem = _STEMMER.stemWord(word_norm) |
| else: |
| stem = word_norm |
|
|
| if stem and len(stem) >= 2: |
| return normalize_for_search(stem) |
| except Exception: |
| pass |
| return word_norm |
|
|
|
|
|
|
| def search_terms_match(left: str, right: str) -> bool: |
| """Match Turkish inflectional variants using TRNLP stems and suffix heuristics. |
| |
| Exact and whole-token prefix matches cover ordinary case/possessive suffixes. |
| TRNLP stem equivalence provides dictionary-level precision for agglutinative forms. |
| The bounded common-stem rule acts as a robust fallback. |
| """ |
| left = normalize_for_search(left) |
| right = normalize_for_search(right) |
| if not left or not right: |
| return False |
| if left == right: |
| return True |
|
|
| left_class = _legal_homonym_class(left) |
| right_class = _legal_homonym_class(right) |
| if left_class and right_class and left_class != right_class: |
| return False |
|
|
| |
| left_stem = get_turkish_stem(left) |
| right_stem = get_turkish_stem(right) |
| if left_stem and right_stem and left_stem == right_stem: |
| return True |
|
|
| |
| if len(left) >= 4 and right.startswith(left) and _has_turkish_inflection(left, right): |
| return True |
| if len(right) >= 4 and left.startswith(right) and _has_turkish_inflection(right, left): |
| return True |
|
|
| |
| common = 0 |
| for left_char, right_char in zip(left, right): |
| if left_char != right_char: |
| break |
| common += 1 |
| shorter = min(len(left), len(right)) |
| if left_class == right_class == "establishment_action": |
| return common >= 5 |
| return common >= 6 and common / max(shorter, 1) >= 0.60 |
|
|
|
|
| def _legal_homonym_class(term: str) -> str: |
| """Disambiguate high-impact Turkish legal homonyms before stemming. |
| |
| ``kurul`` as a governing body and ``kurulmak`` as establishment share a |
| long orthographic stem but denote different legal objects. A character |
| stemmer must not turn "enstitü nasıl kurulur" into a match for a heading |
| about "Kurulunun görevleri". |
| """ |
| if re.fullmatch( |
| r"kurul(?:u|un|unun|a|da|dan|ca|unca|lari|larin|larinin|lara|larda|lardan)?", |
| term, |
| ): |
| return "governing_body" |
| if re.match( |
| r"kurul(?:ur|uyor|du|acak|an|mus|ma|masi|masin|mak|abil)", |
| term, |
| ): |
| return "establishment_action" |
| return "" |
|
|
|
|
| def _has_turkish_inflection(stem: str, extended: str) -> bool: |
| """Accept a token prefix only when the remainder is a plausible suffix. |
| |
| Plain ``startswith`` treats proper names such as Gazi/Gaziantep as the |
| same entity. This bounded suffix inventory covers the case, possessive, |
| plural and common copular forms needed by legal search while rejecting a |
| lexical continuation such as ``antep``. |
| """ |
| if not extended.startswith(stem) or len(extended) <= len(stem): |
| return False |
| suffix = extended[len(stem) :] |
| return bool( |
| re.fullmatch( |
| r"(?:" |
| r"[aeiu]|y[aeiu]|n[aeiu]|s[ai]|" |
| r"d[ae]|t[ae]|d[ae]n|t[ae]n|" |
| r"l[ae]r(?:[ai]|in|d[ae]|d[ae]n)?|" |
| r"[iu]n|n[ıiu]n|inin|unun|sinin|" |
| r"d[ai]r|t[ai]r|d[ae]ki|l[ıi]k|l[ıi]gi" |
| r")", |
| suffix, |
| ) |
| ) |
|
|
|
|
| def topic_term_overlap(question: str, evidence_texts: list[str], min_term_length: int = 2) -> float: |
| """Fuzzy (prefix and TRNLP/stem aware), stopword-filtered overlap calculation. |
| |
| Returns 0.5 (neutral) when the question has no usable content terms after filtering. |
| """ |
| raw_query_terms = [ |
| term for term in normalize_for_search(question).split() |
| if len(term) >= min_term_length and term not in TOPIC_STOPWORDS and not term.isdigit() |
| ] |
| if not raw_query_terms: |
| return 0.5 |
|
|
| query_stems = {get_turkish_stem(t) for t in raw_query_terms} |
| evidence_tokens = normalize_for_search(" ".join(evidence_texts)).split() |
| evidence_stems = {get_turkish_stem(t) for t in evidence_tokens} |
|
|
| if not evidence_stems: |
| return 0.0 |
|
|
| matched_stems = sum( |
| 1 for q_stem in query_stems |
| if q_stem in evidence_stems or any(search_terms_match(q_stem, e_stem) for e_stem in evidence_stems) |
| ) |
| return matched_stems / len(query_stems) |
|
|
|
|
| def compact_text(text: str, limit: int = 1100) -> str: |
| text = dedupe_repeated_text(text) |
| if len(text) <= limit: |
| return text |
| return text[:limit].rsplit(" ", 1)[0] + "..." |
|
|
|
|
| def strip_model_sources(text: str) -> str: |
| if not text: |
| return "" |
| cleaned = text.strip() |
| markers = [ |
| r"^#+\s*Kaynaklar\b", r"^#+\s*İlgili kaynaklar\b", r"^#+\s*Ilgili kaynaklar\b", |
| r"^#+\s*İlgili maddeler\b", r"^#+\s*Ilgili maddeler\b", r"^#+\s*Kaynak maddeler\b", |
| r"^Kaynaklar\b", r"^İlgili kaynaklar\b", r"^Ilgili kaynaklar\b", |
| r"^İlgili maddeler\b", r"^Ilgili maddeler\b", r"^Kaynak maddeler\b", r"^Resmi kaynak\b", |
| ] |
| for marker in markers: |
| match = re.search(marker, cleaned, flags=re.IGNORECASE | re.MULTILINE) |
| if match: |
| cleaned = cleaned[:match.start()].strip() |
| break |
| return cleaned |
|
|
|
|
| def clean_answer_text(answer: str, question: str = "") -> str: |
| text = strip_model_sources(answer or "").strip() |
| text = re.sub(r"^\s*(Soru|Question)\s*:\s*.*?\n+", "", text, flags=re.IGNORECASE | re.DOTALL) |
| text = re.sub(r"^\s*(Cevap|Answer)\s*:\s*", "", text, flags=re.IGNORECASE) |
|
|
| q = (question or "").strip() |
| if q: |
| text = re.sub(rf"^\s*{re.escape(q)}\s*\??\s*", "", text, flags=re.IGNORECASE) |
|
|
| parts = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()] |
| seen = set() |
| cleaned = [] |
| for part in parts: |
| part = dedupe_repeated_sentences(part) |
| key = re.sub(r"\s+", " ", part.lower()) |
| if key in seen: |
| continue |
| seen.add(key) |
| cleaned.append(part) |
|
|
| return re.sub(r"\n{3,}", "\n\n", "\n\n".join(cleaned)).strip() |
|
|
|
|
| def dedupe_repeated_text(text: str) -> str: |
| text = re.sub(r"\s+", " ", text or "").strip() |
| if not text: |
| return "" |
| return dedupe_repeated_sentences(text) |
|
|
|
|
|
|
| def dedupe_repeated_sentences(text: str) -> str:
|
| text = re.sub(r"\s+", " ", text or "").strip()
|
| if not text:
|
| return ""
|
|
|
| sentences = re.split(r"(?<=[.!?])\s+", text)
|
| cleaned = []
|
| seen = set()
|
| previous = ""
|
| for sentence in sentences:
|
| sentence = sentence.strip()
|
| if not sentence:
|
| continue
|
| key = normalize_for_search(sentence)
|
| if key in seen:
|
| continue
|
| if previous and _looks_like_near_duplicate(previous, sentence):
|
| continue
|
| seen.add(key)
|
| cleaned.append(sentence)
|
| previous = sentence
|
| return " ".join(cleaned)
|
|
|
|
|
| def _looks_like_near_duplicate(left: str, right: str) -> bool:
|
| left_tokens = set(normalize_for_search(left).split())
|
| right_tokens = set(normalize_for_search(right).split())
|
| if not left_tokens or not right_tokens:
|
| return False
|
| overlap = len(left_tokens & right_tokens) / max(min(len(left_tokens), len(right_tokens)), 1)
|
| return overlap >= 0.86
|
|
|
|
|
| def query_terms(question: str) -> set[str]:
|
| stopwords = {
|
| "2547", "sayili", "kanun", "kanunu", "kanununda", "hangi",
|
| "nedir", "nelerdir", "olarak", "duzenlenir", "duzenlenmistir",
|
| "madde", "maddede", "gorev", "gorevleri",
|
| }
|
| terms = set()
|
| for token in normalize_for_search(question).split():
|
| if len(token) < 4 or token in stopwords:
|
| continue
|
| terms.add(token)
|
| if len(token) >= 6:
|
| terms.add(token[:6])
|
| if len(token) >= 5:
|
| terms.add(token[:5])
|
| return terms |
|
|