File size: 12,135 Bytes
eff511c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437396f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eff511c
 
 
 
467cf1e
437396f
467cf1e
 
437396f
467cf1e
 
 
 
 
 
 
 
 
437396f
eff511c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7353b3c
 
437396f
 
467cf1e
437396f
467cf1e
 
437396f
 
467cf1e
437396f
 
 
 
467cf1e
 
 
 
 
 
 
 
 
 
 
 
437396f
 
 
 
 
 
 
467cf1e
7353b3c
437396f
7353b3c
 
437396f
 
7353b3c
 
 
 
 
 
 
437396f
674e77f
 
 
 
437396f
 
 
 
 
 
 
 
674e77f
7353b3c
674e77f
7353b3c
437396f
 
7353b3c
 
 
 
 
 
674e77f
 
7353b3c
 
 
674e77f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7353b3c
b84ed4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eff511c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
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