YOKPilot3 / evidence.py
mfirat007's picture
Fix canonical legal routing and session-scoped clarification
674e77f verified
Raw
History Blame Contribute Delete
15.7 kB
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"(?<![\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())
# Split exception/condition tails even when they remain inside one sentence.
norm = normalize_for_search(text)
for starter in CONNECTIVE_STARTERS:
# Match on original text approximately by looking for Turkish variants.
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 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"(?<!\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)