| 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
|
|
|
| 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)}
|
|
|
|
|
| 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()) |
|
|
|
|
| for match in re.finditer(r"\n+", text):
|
| boundaries.add(match.end())
|
| |
| |
| |
| |
| marker_patterns = [ |
| *CLAUSE_MARKER_RES, |
| re.compile(r"(?<![\w(])\d+\)\s"), |
| ] |
| for pattern in marker_patterns: |
| for match in pattern.finditer(text): |
| if not _looks_like_date_context(text, match.start()): |
| boundaries.add(match.start()) |
|
|
|
|
| norm = normalize_for_search(text)
|
| for starter in CONNECTIVE_STARTERS:
|
|
|
| for variant in _starter_variants(starter):
|
| for match in re.finditer(r"\b" + re.escape(variant) + r"\b", text, flags=re.IGNORECASE):
|
| if match.start() > 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_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"(?<!\w)\d+\)\s",
|
| ]
|
| for pattern in split_patterns:
|
| for match in re.finditer(pattern, unit, flags=re.IGNORECASE):
|
| boundary = match.start() if pattern.startswith("(?<!") else match.end()
|
| if 0 < boundary < len(unit):
|
| local_boundaries.add(boundary)
|
|
|
| ordered = sorted(local_boundaries)
|
| spans = []
|
| for a, b in zip(ordered, ordered[1:]):
|
| s, e = _trim(text, start + a, start + b)
|
| if e > 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)
|
|
|