YOKPilot3 / utils.py
mfirat007's picture
Upload utils.py
b84ed4e verified
Raw
History Blame Contribute Delete
12.1 kB
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",
}
# Ortak "konu örtüşmesi" hesaplaması için genel/soru-kalıbı kelimeleri.
# Bu liste bilinçli olarak GENERIC_TERMS'ten AYRI tutuldu: GENERIC_TERMS
# başka amaçlarla (ör. terim vurgulama) kullanılıyor olabilir ve kapsamı
# farklı. TOPIC_STOPWORDS yalnızca topic_term_overlap() için, "bu kelime
# hemen hemen her maddede geçebilir, gerçek bir konu sinyali değildir"
# kriteriyle seçildi. Yeni bir yanlış-pozitif/yanlış-negatif örneği
# görüldüğünde önce burayı güncelle — proof_bundle.py ve answering.py
# ayrı ayrı kendi stopword listelerini TUTMAMALI, ikisi de bu fonksiyonu
GENERIC_TERMS = {
"kanun", "kanunu", "madde", "yuksekogretim", "kurum", "kurumu",
"kurumlari", "universite", "esaslari", "gorevleri", "yetkileri",
"sartlari", "nedir", "nelerdir",
}
# Ortak "konu örtüşmesi" hesaplaması için genel/soru-kalıbı kelimeleri.
# Bu liste bilinçli olarak GENERIC_TERMS'ten AYRI tutuldu: GENERIC_TERMS
# başka amaçlarla (ör. terim vurgulama) kullanılıyor olabilir ve kapsamı
# farklı. TOPIC_STOPWORDS yalnızca topic_term_overlap() için, "bu kelime
# hemen hemen her maddede geçebilir, gerçek bir konu sinyali değildir"
# kriteriyle seçildi. Yeni bir yanlış-pozitif/yanlış-negatif örneği
# görüldüğünde önce burayı güncelle — proof_bundle.py ve answering.py
# ayrı ayrı kendi stopword listelerini TUTMAMALI, ikisi de bu fonksiyonu
# çağırmalı; aksi halde aynı hata iki yerde ayrı ayrı yamanır.
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
# 1. TRNLP stem equivalence check
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
# 2. Suffix heuristic check
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
# 3. Bounded common prefix rule
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