from __future__ import annotations import re from typing import Any from normative_roles import build_normative_knowledge_unit, extract_normative_frame from structural_parser import CLAUSE_MARKER_RES from utils import normalize_for_search EVIDENCE_SCHEMA_CLASS = "NormativeEvidenceSpan" CONNECTIVE_STARTERS = ( "ancak", "fakat", "ama", "bununla birlikte", "buna karşılık", "bu durumda", "şu kadar ki", "su kadar ki", "saklı kalmak", "saklidir", "saklıdır", ) _STOPWORDS = { "madde", "kanun", "kanunda", "2547", "hangi", "neler", "nedir", "nasildir", "nasıl", "nasil", "gorevleri", "görevleri", "yetkileri", "sorumluluklari", "sorumlulukları", "duzenlenir", "düzenlenir", "var", "mi", "mı", "ne", "kac", "kaç", "kadar", "olarak", "icin", "için", "ilgili", } def build_evidence_spans( clause_id: str, article_id: str, label: str, clause_text: str, clause_source_span: dict[str, Any] | None = None, inherited_actors: list[Any] | None = None, ) -> list[dict[str, Any]]: """Split a clause into minimal evidence spans and attach deterministic role frames. v0.6 does not treat a full clause as the answer evidence unless the clause is already short. Long clauses are split by legal drafting boundaries: numbered items, sentence stops, semicolons, and exception/condition connectives. The resulting spans are small enough to highlight without painting an entire article or long paragraph. """ text = " ".join(str(clause_text or "").split()) if not text: return [] local_units = _evidence_unit_spans(text) base_start = 0 if clause_source_span and isinstance(clause_source_span.get("char_start"), int): base_start = int(clause_source_span.get("char_start", 0)) spans: list[dict[str, Any]] = [] for ordinal, (start, end) in enumerate(local_units, start=1): span_text = text[start:end].strip() if len(span_text) < 10: continue frame = extract_normative_frame(span_text, inherited_actors or []) roles = infer_evidence_roles(span_text, frame) evidence_id = f"{clause_id}__ev_{ordinal:03d}" source_ref = { "parent_clause_id": clause_id, "article_id": article_id, "label": label, "evidence_id": evidence_id, } spans.append({ "evidence_id": evidence_id, "class": EVIDENCE_SCHEMA_CLASS, "parent_clause_id": clause_id, "article_id": article_id, "label": label, "norm_type": primary_evidence_norm_type(roles, frame), "semantic_roles": roles, "semantic_frame": frame, "source_span": { "article_id": article_id, "label": label, "clause_char_start": start, "clause_char_end": end, "char_start": base_start + start, "char_end": base_start + end, }, "source_text": span_text, "normative_unit": build_normative_knowledge_unit(span_text, frame, source_ref), }) return spans or [_fallback_evidence_span(clause_id, article_id, label, text, clause_source_span, inherited_actors)] def select_best_evidence_spans( source_text: str, question: str = "", answer: str = "", max_spans: int = 4, ) -> list[tuple[int, int]]: """Return minimal source spans for UI highlighting. This function is intentionally independent from the ontology file so legacy paths still get fine-grained highlighting. It prefers evidence units that match query terms and answer terms; it never returns the whole source if smaller candidates exist. """ text = str(source_text or "") if not text: return [] units = _evidence_unit_spans(text) if len(units) == 1 and units[0] == (0, len(text)): return units if len(text) <= 500 else [_trim_window_around_terms(text, 0, len(text), question, answer)] q_terms = _meaningful_terms(question) a_terms = _meaningful_terms(answer) scored: list[tuple[float, int, int]] = [] for start, end in units: unit_text = text[start:end] unit_norm = normalize_for_search(unit_text) score = 0.0 score += 2.0 * _term_overlap_ratio(unit_norm, q_terms) score += 1.0 * _term_overlap_ratio(unit_norm, a_terms) if any(marker in unit_norm for marker in ("halinde", "takdirde", "kaydiyla", "kaydiyla", "ancak", "haric", "sakli")): if any(term in normalize_for_search(question) for term in ("hangi", "sart", "şart", "kosul", "istisna", "haric", "ancak")): score += 0.75 if any(term in unit_norm for term in ("yil", "ay", "gun", "sure", "suresi")): if any(term in normalize_for_search(question) for term in ("kac", "kaç", "sure", "suresi", "ne kadar")): score += 0.75 # Prefer compact evidence over very long units. length = max(1, end - start) score -= min(0.45, max(0, length - 420) / 1200) if score > 0: scored.append((score, start, end)) if not scored: return [] scored.sort(key=lambda item: item[0], reverse=True) spans = [(start, end) for _, start, end in scored[:max_spans]] return _merge_spans(spans) def infer_evidence_roles(text: str, frame: dict[str, Any]) -> list[str]: q = normalize_for_search(text) roles: set[str] = set() if frame.get("definition_term") or frame.get("modality") == "definition": roles.add("tanim") if frame.get("condition"): roles.add("sart") if frame.get("exception"): roles.add("istisna") if frame.get("temporal_constraint"): roles.add("sure") if frame.get("procedure_step"): roles.add("usul") if frame.get("eligibility_criteria"): roles.add("sart") if frame.get("sanction"): roles.add("yaptirim") if frame.get("remedy"): roles.add("itiraz") if frame.get("quantitative_rule"): roles.add("olcut") if frame.get("competent_authority"): roles.add("yetki") if frame.get("beneficiary"): roles.add("hak") if any(action in set(frame.get("action", []) or []) for action in ("atamak", "seçmek")): roles.add("atama") if any(action in set(frame.get("action", []) or []) for action in ("kurmak", "açmak", "kapatmak")): roles.add("kurulus") if any(term in q for term in ("gorev", "görev", "baskanlik", "başkanlık", "rapor", "denetim", "yapmak", "bildirmek", "sunmak", "saglamak", "sağlamak")): roles.add("gorev") if any(term in q for term in ("yetki", "yetkili", "karar", "belirler", "tespit", "onay")): roles.add("yetki") if any(term in q for term in ("sorumlu", "zorundadir", "zorundadır", "yukumludur", "gerekir")): roles.add("sorumluluk") if any(term in q for term in ("ceza", "yaptirim", "ilişiği kes", "ilisigi kes", "kapatilir")): roles.add("yaptirim") if any(term in q for term in ("olusur", "oluşur", "uyeden", "üyeden", "toplam")): roles.add("kurul_olusumu") return sorted(roles) or ["norm"] def primary_evidence_norm_type(roles: list[str], frame: dict[str, Any]) -> str: priority = [ "kurulus", "atama", "yaptirim", "itiraz", "tanim", "sart", "istisna", "sure", "olcut", "hak", "kurul_olusumu", "gorev", "yetki", "sorumluluk", "usul", "norm", ] role_set = set(roles) for role in priority: if role in role_set: return role modality = frame.get("modality", "") if modality == "definition": return "tanim" if modality == "prohibition": return "yaptirim" if modality in {"power", "permission"}: return "yetki" if modality == "obligation": return "sorumluluk" return "norm" def _evidence_unit_spans(text: str) -> list[tuple[int, int]]: text = str(text or "") if not text: return [] boundaries = {0, len(text)} # Legal texts often encode independent rules with semicolons. for match in re.finditer(r"[.;!?]\s+", text): if match.group(0).startswith(";") and _semicolon_continues_predicate(text, match.end()): continue boundaries.add(match.end()) # New lines and list markers are hard structural signals. for match in re.finditer(r"\n+", text): boundaries.add(match.end()) # Reuse the exact legal-marker definitions used by the structural parser. # In particular, ``(b) fıkrası`` is an inline cross-reference rather than # a new evidence unit. Only the additional unparenthesized ``1)`` form is # evidence-specific here. marker_patterns = [ *CLAUSE_MARKER_RES, re.compile(r"(? 0: boundaries.add(match.start()) ordered = sorted(b for b in boundaries if 0 <= b <= len(text)) spans: list[tuple[int, int]] = [] for start, end in zip(ordered, ordered[1:]): start, end = _trim(text, start, end) if end <= start: continue unit = text[start:end].strip() if len(unit) > 720: spans.extend(_split_long_unit(text, start, end)) else: spans.append((start, end)) return spans or [(0, len(text))] def _semicolon_continues_predicate(text: str, boundary: int) -> bool: """Keep one normative proposition intact across a drafting semicolon. Turkish provisions commonly place the authority at the end of a sequence: ``Cumhurbaşkanı ... kurmaya; ... değiştirmeye yetkilidir.`` Splitting at that semicolon separates actor/action from modality and makes a true power look unsupported to the proof contract. """ before = normalize_for_search(text[max(0, boundary - 180) : boundary]).rstrip() after = normalize_for_search(text[boundary : boundary + 240]) incomplete_infinitive = bool( re.search(r"\b[a-z0-9]+(?:maya|meye|maga|mege)\s*$", before) ) completing_modality = bool( re.search(r"\b(?:yetkilidir|yetkilidirler)\b", after) ) return incomplete_infinitive and completing_modality def _split_long_unit(text: str, start: int, end: int) -> list[tuple[int, int]]: unit = text[start:end] local_boundaries = {0, len(unit)} # Split long legal lists and embedded subitems. This is structural, not a question patch. split_patterns = [ r",\s+(?=(?:ancak|bu|ilgili|yükseköğretim|öğrenci|öğretim|üniversite|fakülte|enstitü|rektör|dekan)\b)", r",\s+(?=\d{3,5}\s+sayılı)", r"(? s: spans.append((s, e)) return spans or [(start, end)] def _fallback_evidence_span(clause_id: str, article_id: str, label: str, text: str, clause_source_span: dict[str, Any] | None, inherited_actors: list[Any] | None) -> dict[str, Any]: frame = extract_normative_frame(text, inherited_actors or []) roles = infer_evidence_roles(text, frame) base_start = int((clause_source_span or {}).get("char_start", 0) or 0) return { "evidence_id": f"{clause_id}__ev_001", "class": EVIDENCE_SCHEMA_CLASS, "parent_clause_id": clause_id, "article_id": article_id, "label": label, "norm_type": primary_evidence_norm_type(roles, frame), "semantic_roles": roles, "semantic_frame": frame, "source_span": {"article_id": article_id, "label": label, "clause_char_start": 0, "clause_char_end": len(text), "char_start": base_start, "char_end": base_start + len(text)}, "source_text": text, "normative_unit": build_normative_knowledge_unit( text, frame, {"parent_clause_id": clause_id, "article_id": article_id, "label": label, "evidence_id": f"{clause_id}__ev_001"}, ), } def _meaningful_terms(text: str) -> set[str]: normalized = normalize_for_search(text or "") terms = {term for term in normalized.split() if len(term) >= 4 and term not in _STOPWORDS} compact = normalized.replace(" ", "") if "onlisans" in compact: terms.update({"on", "lisans", "onlisans", "önlisans", "diploma"}) return terms def _term_overlap_ratio(unit_norm: str, terms: set[str]) -> float: if not terms: return 0.0 hits = sum(1 for term in terms if normalize_for_search(term) in unit_norm) return hits / max(1, min(len(terms), 8)) def _trim_window_around_terms(text: str, start: int, end: int, question: str, answer: str) -> tuple[int, int]: terms = list(_meaningful_terms(question) or _meaningful_terms(answer)) norm_text = normalize_for_search(text) idx = -1 for term in terms: idx = norm_text.find(normalize_for_search(term)) if idx >= 0: break if idx < 0: return (start, min(end, start + 480)) return _expand_to_word_bounds(text, max(start, idx - 160), min(end, idx + 320)) def _expand_to_word_bounds(text: str, start: int, end: int) -> tuple[int, int]: while start > 0 and not text[start - 1].isspace(): start -= 1 while end < len(text) and not text[end - 1].isspace(): end += 1 return start, end def _trim(text: str, start: int, end: int) -> tuple[int, int]: while start < end and text[start].isspace(): start += 1 while end > start and text[end - 1].isspace(): end -= 1 return start, end def _merge_spans(spans: list[tuple[int, int]]) -> list[tuple[int, int]]: if not spans: return [] ordered = sorted((max(0, s), max(0, e)) for s, e in spans if e > s) merged = [ordered[0]] for start, end in ordered[1:]: last_start, last_end = merged[-1] if start <= last_end: merged[-1] = (last_start, max(last_end, end)) else: merged.append((start, end)) return merged def _looks_like_date_context(text: str, position: int) -> bool: window = text[max(0, position - 16):position + 16] return bool(re.search(r"\d{1,2}/\d{1,2}/\d{4}", window)) def _starter_variants(starter: str) -> list[str]: variants = {starter, starter.replace("ş", "s").replace("ı", "i").replace("ğ", "g").replace("ü", "u").replace("ö", "o").replace("ç", "c")} if starter == "ancak": variants.update({"Ancak"}) return list(variants)