YOKPilot3 / proof_bundle.py
mfirat007's picture
Upload 4 files
f7d0506 verified
Raw
History Blame Contribute Delete
36.4 kB
from __future__ import annotations
import json
import re
from typing import Any
from hermeneutic_intent import analyze_interpretive_intent, legal_object_fidelity
from normative_roles import extract_normative_frame, extract_question_frame
from normative_metadata import semantic_address_score, semantic_address_text
from utils import normalize_for_search, topic_term_overlap
def build_proof_bundle(
question: str,
candidates: list[dict[str, Any]],
selected: list[dict[str, Any]],
build_id: str,
) -> dict[str, Any]:
frame = extract_question_frame(question)
evidence = [_evidence_record(item, index) for index, item in enumerate(selected, start=1)]
support = _support_assessment(question, frame, candidates, selected)
relation_contract = _relation_contract_assessment(frame, selected)
status = "supported" if support["score"] >= 0.55 and evidence else "insufficient"
if relation_contract["required"] and not relation_contract["aligned"]:
status = "insufficient"
support["reason"] = relation_contract["reason"]
support.setdefault("signals", {})["relation_contract"] = "mismatch"
if _explicit_reference_scope_ambiguity(question, candidates):
status = "ambiguous"
if status == "insufficient" and _is_ambiguous(candidates):
status = "ambiguous"
return {
"schema": "MCKF-ProofBundle-v1.0",
"build_id": build_id,
"question": question,
"query_frame": frame,
"relation_contract": relation_contract,
"status": status,
"support": support,
"evidence": evidence,
"claims": [],
"unresolved_facts": _unresolved_facts(frame, selected),
"rules": _applied_rules(selected),
"abstention_reason": "" if status == "supported" else support["reason"],
}
def deterministic_claims(proof: dict[str, Any]) -> list[dict[str, Any]]:
claims = []
for item in proof.get("evidence", []) or []:
text = str(item.get("source_text", "") or "").strip()
evidence_id = str(item.get("evidence_id", "") or "")
if text and evidence_id:
claims.append({"type": "atomic", "text": text, "evidence_ids": [evidence_id]})
return claims
def parse_and_validate_llm_claims(raw: str, proof: dict[str, Any]) -> list[dict[str, Any]]:
payload = _json_payload(raw)
if not isinstance(payload, dict):
return []
allowed_records = {
str(item.get("evidence_id", "")): item
for item in proof.get("evidence", []) or []
if item.get("evidence_id")
}
allowed = {
evidence_id: str(item.get("source_text", ""))
for evidence_id, item in allowed_records.items()
}
claims = []
synthesis_count = 0
atomic_count = 0
for claim in payload.get("claims", []) or []:
text = str(claim.get("text", "") or "").strip()
claim_type = str(claim.get("type", "atomic") or "atomic").strip().lower()
if claim_type not in {"atomic", "synthesis"}:
claim_type = "atomic"
evidence_ids = [str(value) for value in claim.get("evidence_ids", []) or [] if str(value) in allowed]
if not text or not evidence_ids:
continue
if claim_type == "synthesis" and len(evidence_ids) < 2:
# A synthesis claim must genuinely combine more than one source;
# otherwise treat it as atomic so validation is not weakened.
claim_type = "atomic"
if not _claim_has_source_support(text, [allowed[evidence_id] for evidence_id in evidence_ids], claim_type):
continue
if not _claim_relation_supported(
text,
[allowed_records[evidence_id] for evidence_id in evidence_ids],
claim_type,
):
continue
if claim_type == "synthesis":
synthesis_count += 1
else:
atomic_count += 1
# Never let synthesis claims outnumber atomic claims — a guard against
# the model drifting into unsupported generalization.
if claim_type == "synthesis" and synthesis_count > max(1, atomic_count):
continue
claims.append({"type": claim_type, "text": text, "evidence_ids": evidence_ids})
return claims[:8]
def render_proof_answer(proof: dict[str, Any], claims: list[dict[str, Any]] | None = None) -> str:
claims = claims or deterministic_claims(proof)
normalized_claims = []
for claim in claims:
text = str(claim.get("text", "") or "").strip()
if not text:
continue
normalized_claims.append({
"type": str(claim.get("type", "atomic") or "atomic"),
"text": text,
"evidence_ids": claim.get("evidence_ids", []) or [],
})
if not normalized_claims:
return ""
return _natural_answer(proof, normalized_claims)
def llm_claim_prompt(proof: dict[str, Any], style_instruction: str) -> str:
evidence_blocks = []
for item in proof.get("evidence", []) or []:
evidence_blocks.append(
f"EVIDENCE_ID={item.get('evidence_id')}\n"
f"KAYNAK={item.get('document_title')} {item.get('article_id')}\n"
f"METIN={item.get('source_text')}"
)
return f"""Kullanıcı sorusu:
{proof.get('question', '')}
Doğrulanmış kanıtlar:
{chr(10).join(evidence_blocks)}
Stil:
{style_instruction}
Yalnızca JSON döndür:
{{"claims":[
{{"type":"atomic","text":"Tek bir kanıttan doğrudan çıkan Türkçe iddia","evidence_ids":["tam EVIDENCE_ID"]}},
{{"type":"synthesis","text":"Birden fazla kanıtı birleştiren, aralarındaki ilişkiyi açıklayan Türkçe cümle","evidence_ids":["EVIDENCE_ID_1","EVIDENCE_ID_2"]}}
]}}
ÖRNEK (doğru format — atomik + sentez birlikte):
Soru: "Rektör görev süresi ve senato yapısı nasıldır?"
Doğru çıktı:
{{"claims":[
{{"type":"atomic","text":"Rektörün görev süresi dört yıldır.","evidence_ids":["EV-2547-13-1"]}},
{{"type":"atomic","text":"Bir kişi en fazla iki dönem rektör olarak görev yapabilir.","evidence_ids":["EV-2547-13-2"]}},
{{"type":"synthesis","text":"Bu iki hüküm birlikte değerlendirildiğinde, rektörlük görevi belirli süreli ve tekrar sınırlı bir görevdir; bu sınır kurumun üst yönetiminde süreklilik yerine dönemsel yenilenmeyi esas alır.","evidence_ids":["EV-2547-13-1","EV-2547-13-2"]}}
]}}
YANLIŞ (kaçınılması gereken):
- Sentez cümlesinde kaynakta geçmeyen yeni bir sayı, kurum veya sonuç eklemek.
- Her claim'i tek bir kelimeye indirgemek (aşırı kısaltma, bağlam kaybı).
- Kaynaklar arasında gerçek bir ilişki yokken zorla sentez claim üretmek —
ilişki yoksa sadece atomic claim'lerle yetin, synthesis ekleme.
Kurallar:
- Her claim en az bir geçerli EVIDENCE_ID taşımalıdır.
- Özne, eylem, nesne/muhatap ve yükümlülük-yetki-izin-yasak modalitesi aynı
kanıt biriminde birlikte kurulmalıdır. Bir kanıttan özneyi, başka bir
kanıttan yükümlülüğü alıp yeni bir hukuki ilişki kurma.
- Bir kurumun bünyesinde bir birimin bulunması, o kurumun başka kurumlara o
birim aracılığıyla hizmet vermekle yükümlü olduğu anlamına gelmez.
- "aracılığıyla", "sayesinde", "sonucunda", "alt/üst düzey" gibi ilişki
kuran ifadeleri yalnızca kaynakta açıkça varsa kullan.
- "synthesis" tipi claim'ler yalnızca gerçekten birbiriyle ilişkili
(aynı konuyu farklı açılardan düzenleyen) kanıtlar arasında kurulmalı.
- Kanıtın doğrudan desteklemediği sonuç, kişi, süre, istisna veya tavsiye
ekleme — sentez claim'de de bu kural geçerlidir; sentez yalnızca mevcut
atomic claim'lerin ilişkisini açıklar, yeni bilgi üretmez.
- Aynı iddiayı tekrarlama.
- Kaynak metin soruyu cevaplamıyorsa claims boş liste olsun.
- Soru birden fazla kanunu/maddeyi kapsıyorsa, önce ilgili atomic claim'leri
üret, ardından uygunsa TEK bir synthesis claim ekle. Synthesis claim
sayısı asla atomic claim sayısını geçmesin.
"""
def _evidence_record(evidence: dict[str, Any], index: int) -> dict[str, Any]:
return {
"citation": f"[K{index}]",
"evidence_id": evidence.get("evidence_id", ""),
"document_id": evidence.get("document_id", ""),
"document_title": evidence.get("document_title", ""),
"article_id": evidence.get("article_id", ""),
"article_title": evidence.get("article_title", ""),
"source_span": evidence.get("source_span", {}),
"source_text": evidence.get("source_text", ""),
"semantic_frame": evidence.get("semantic_frame", {}),
"semantic_address": evidence.get("_semantic_address", {}) or {},
"retrieval_channels": evidence.get("_retrieval_channels", []),
"document_domain_tags": evidence.get("document_domain_tags", []),
"channel_consensus": evidence.get("_channel_consensus", 0),
}
def _natural_answer(proof: dict[str, Any], claims: list[dict[str, Any]]) -> str:
# Backward-compat: some callers may still pass plain text lists.
if claims and isinstance(claims[0], str):
claims = [{"type": "atomic", "text": text, "evidence_ids": []} for text in claims]
question = normalize_for_search(str(proof.get("question", "") or ""))
evidence = proof.get("evidence", []) or []
interpretation = analyze_interpretive_intent(str(proof.get("question", "") or ""))
requested_mechanism = interpretation.get("requested_mechanism", {}) or {}
if requested_mechanism.get("selection_scope") == "article_catalog":
return _render_article_catalog_answer(evidence)
if len({item.get("document_id") for item in evidence}) >= 2 and any(
marker in question for marker in ("hangisi", "daha dogrudan", "birlikte dikkate")
):
ranked = sorted(evidence, key=lambda item: _document_fitness(question, item), reverse=True)
primary = ranked[0]
others = [item for item in ranked[1:] if item.get("document_id") != primary.get("document_id")]
if "daha dogrudan" in question or "hangisi" in question:
answer = (
f"{primary.get('document_title')} daha doğrudan kaynaktır. "
f"Bu kanunun kapsam hükmü, sorudaki konu ve kişi grubunu doğrudan düzenlemektedir."
)
if others:
answer += f" {others[0].get('document_title')} ise konunun genel kurumsal çerçevesini tamamlar."
return answer
if not claims:
return ""
atomic = [c for c in claims if c.get("type", "atomic") != "synthesis"]
synthesis = [c for c in claims if c.get("type") == "synthesis"]
evidence_by_id = {str(item.get("evidence_id", "")): item for item in evidence}
atomic = _merge_structural_claims(atomic, evidence_by_id)
lines: list[str] = []
prev_document_id = None
for index, claim in enumerate(atomic):
text = _clean_legal_sentence(claim.get("text", ""))
if not text:
continue
claim_evidence_ids = claim.get("evidence_ids", []) or []
claim_evidence = evidence_by_id.get(claim_evidence_ids[0], {}) if claim_evidence_ids else {}
document_id = claim_evidence.get("document_id") if claim_evidence else None
text = _prefix_governing_actor(question, text, claim_evidence)
if index > 0:
connector = _connector_for_transition(prev_document_id, document_id)
if connector:
text = f"{connector} {text[0].lower()}{text[1:]}" if text else text
lines.append(text)
prev_document_id = document_id or prev_document_id
for claim in synthesis:
text = _clean_legal_sentence(claim.get("text", ""))
if text:
lines.append(text)
lines = [line for line in lines if line]
if not lines:
return ""
requested_mechanism = (interpretation.get("requested_mechanism", {}) or {}).get("mechanism_id", "")
if requested_mechanism == "internal_duty_place_change" and "islem turu kesin olarak" in question:
lines.insert(0, "Bu ayrımda işlem, kadro aktarımı değil görev yeri değişikliğidir.")
if len(lines) == 1:
return lines[0]
return " ".join(lines)
def _render_article_catalog_answer(evidence: list[dict[str, Any]]) -> str:
if not evidence:
return ""
document_title = str(evidence[0].get("document_title", "") or "Kanun")
lines = [
f"{document_title}, bu ödemeleri tek bir ödenek ve tek bir hesaplama yöntemi olarak düzenlemez. "
"Başlıca ödenek türleri ve temel esasları şöyledir:"
]
seen = set()
for item in evidence:
scope = (item.get("document_id"), item.get("article_id"))
if scope in seen:
continue
seen.add(scope)
title = str(item.get("article_title", "") or item.get("article_id", "") or "Düzenleme")
article_id = str(item.get("article_id", "") or "")
summary = _clean_legal_sentence(str(item.get("source_text", "") or ""))
summary = _concise_catalog_summary(summary)
if summary:
lines.append(f"- **{title} ({article_id}):** {summary}")
if len(lines) == 1:
return ""
lines.append("Bu nedenle kesin oran ve yararlanma şartı, sorulan ödenek türüne göre belirlenir.")
return "\n".join(lines)
def _concise_catalog_summary(text: str, limit: int = 360) -> str:
value = re.sub(r"\s+", " ", str(text or "")).strip()
if len(value) <= limit:
return value
shortened = value[:limit]
sentence_end = max(shortened.rfind("."), shortened.rfind(";"))
if sentence_end >= 100:
return shortened[: sentence_end + 1].strip()
return shortened.rsplit(" ", 1)[0].rstrip(" ,;:") + "..."
def _connector_for_transition(prev_document_id, document_id) -> str:
"""Pick a light connective phrase between consecutive atomic claims.
Keeps answers readable without adding unsupported content: the connector
is purely structural, never introduces a new fact.
"""
if not document_id:
return ""
if prev_document_id and document_id == prev_document_id:
return "Ayrıca,"
if prev_document_id and document_id != prev_document_id:
return "Bununla birlikte,"
return ""
def _merge_structural_claims(
claims: list[dict[str, Any]],
evidence_by_id: dict[str, dict[str, Any]],
) -> list[dict[str, Any]]:
"""Rejoin atomic spans that are adjacent pieces of one legal sentence."""
merged: list[dict[str, Any]] = []
for claim in claims:
current = dict(claim)
if not merged:
merged.append(current)
continue
previous = merged[-1]
previous_ids = previous.get("evidence_ids", []) or []
current_ids = current.get("evidence_ids", []) or []
previous_evidence = evidence_by_id.get(str(previous_ids[-1]), {}) if previous_ids else {}
current_evidence = evidence_by_id.get(str(current_ids[0]), {}) if current_ids else {}
if _evidence_fragments_continue(previous_evidence, current_evidence):
previous["text"] = (
str(previous.get("text", "") or "").rstrip()
+ " "
+ str(current.get("text", "") or "").lstrip()
).strip()
previous["evidence_ids"] = list(dict.fromkeys([*previous_ids, *current_ids]))
else:
merged.append(current)
return merged
def _evidence_fragments_continue(previous: dict[str, Any], current: dict[str, Any]) -> bool:
if not previous or not current:
return False
if (
previous.get("document_id") != current.get("document_id")
or previous.get("article_id") != current.get("article_id")
):
return False
previous_span = previous.get("source_span", {}) or {}
current_span = current.get("source_span", {}) or {}
previous_end = int(previous_span.get("char_end", -1) or -1)
current_start = int(current_span.get("char_start", -1) or -1)
if previous_end < 0 or current_start < 0 or current_start - previous_end not in {0, 1, 2}:
return False
previous_text = str(previous.get("source_text", "") or "").rstrip()
return bool(previous_text) and (previous_text.endswith((";", ",", ":")) or previous_text[-1] not in ".?!")
def _document_fitness(question: str, evidence: dict[str, Any]) -> float:
terms = set(question.split())
searchable = normalize_for_search(
" ".join([
str(evidence.get("document_title", "") or ""),
" ".join(str(value) for value in evidence.get("document_domain_tags", []) or []),
str(evidence.get("source_text", "") or ""),
])
)
score = sum(1.0 for term in terms if len(term) >= 4 and term in searchable)
title = normalize_for_search(str(evidence.get("article_title", "") or ""))
if title in {"kapsam", "amac", "konu ve kapsam"}:
score += 4.0
tag_aliases = {
"personnel": {"personel", "ozluk", "akademik"},
"salary": {"mali", "maas", "aylik", "ucret"},
"allowance": {"mali", "odenek", "tazminat"},
"academic_staff": {"akademik", "ogretim elemani", "personel"},
"framework": {"cerceve", "genel", "kurumsal"},
}
for tag in evidence.get("document_domain_tags", []) or []:
score += 1.5 * sum(1 for alias in tag_aliases.get(str(tag), set()) if alias in question)
return score
def _clean_legal_sentence(text: str) -> str:
value = re.sub(
r"^\s*(?:(?:Geçici|Ek)\s+)?Madde\s+\d+\s*[-–—:]?\s*",
"",
str(text or ""),
flags=re.IGNORECASE,
)
value = re.sub(r"^\s*(?:\(\d+\)|[a-zçğıöşü]\))\s*", "", value, flags=re.IGNORECASE)
value = re.sub(
r"^\s*(?:\([^)]*(?:Değişik|Ek|Mülga|İptal|Yeniden)[^)]*\)\s*)+",
"",
value,
flags=re.IGNORECASE,
)
value = re.sub(
r"^\s*(?:Değişik|Ek|Mülga|İptal|Yeniden\s+düzenleme)\s*:[^)]*\)\s*",
"",
value,
flags=re.IGNORECASE,
)
value = re.sub(r"\(\s*(?:…|\.\.\.)\s*\)\s*\d*", "", value)
value = re.sub(r"\s+", " ", value).strip(" -;:,")
if value and value[-1] not in ".?!":
value += "."
return value
def _prefix_governing_actor(question: str, text: str, evidence: dict[str, Any]) -> str:
if not any(marker in question for marker in ("hangi makam", "yetkisindedir", "kim yetkili")):
return text
title = str(evidence.get("article_title", "") or "").strip()
title_norm = normalize_for_search(title)
actor_titles = {
"rektor", "dekan", "senato", "universite yonetim kurulu",
"fakulte kurulu", "fakulte yonetim kurulu", "yuksekogretim kurulu",
"universitelerarasi kurul",
}
if title_norm not in actor_titles or title_norm in normalize_for_search(text[:80]):
return text
return f"{title}, {text[0].lower()}{text[1:]}" if text else text
def _support_assessment(
question: str,
frame: dict[str, Any],
candidates: list[dict[str, Any]],
selected: list[dict[str, Any]],
) -> dict[str, Any]:
if not selected:
return {"score": 0.0, "reason": "Seçilmiş kanıt yok.", "signals": {}}
top = max(
selected,
key=lambda item: (
bool(((item.get("_score_breakdown", {}) or {}).get("channels", {}) or {})),
float(item.get("_score", 0.0) or 0.0),
int(item.get("_channel_consensus", 0) or 0),
),
)
channels = (top.get("_score_breakdown", {}) or {}).get("channels", {}) or {}
bm25 = float((channels.get("bm25", {}) or {}).get("raw_score", 0.0) or 0.0)
dense_name = "lsa_dense" if "lsa_dense" in channels else "dense_hash_fallback"
dense = float((channels.get(dense_name, {}) or {}).get("raw_score", 0.0) or 0.0)
semantic = float((channels.get("semantic_frame", {}) or {}).get("raw_score", 0.0) or 0.0)
graph = "sparql_graph" in channels
concept_focus = "concept_focus" in channels
exact = "exact_reference" in channels
coverage = _frame_coverage(frame, selected)
anchor_overlap = _topic_anchor_overlap(question, selected)
address_match = max(
[semantic_address_score(question, item.get("_semantic_address", {}) or {}) for item in selected] or [0.0]
)
object_fidelity = legal_object_fidelity(question, selected)
score = 0.0
score += 0.28 if exact else 0.0
score += 0.20 if bm25 >= 2.0 else 0.10 if bm25 >= 0.8 else 0.0
score += 0.15 if dense >= 0.12 else 0.07 if dense >= 0.05 else 0.0
score += 0.24 if semantic >= 8.0 else 0.12 if semantic >= 6.0 else 0.0
score += 0.18 if graph else 0.0
score += 0.20 if concept_focus else 0.0
score += 0.20 * coverage
score += 0.16 * anchor_overlap
score += 0.24 * address_match
if object_fidelity.get("status") != "not_applicable":
score += 0.18 * float(object_fidelity.get("score", 0.0) or 0.0)
selected_documents = {str(item.get("document_id", "")) for item in selected if item.get("document_id")}
normalized_question = normalize_for_search(question)
if len(selected_documents) >= 2 and any(
marker in normalized_question for marker in ("hangisi", "daha dogrudan", "birlikte dikkate")
) and anchor_overlap >= 0.20:
score = max(score, 0.68)
score = min(1.0, score)
aligned_concept = concept_focus and object_fidelity.get("status") == "aligned"
if aligned_concept and float(top.get("_score", 0.0) or 0.0) >= 18.0:
score = max(score, 0.58)
if anchor_overlap == 0.0 and not graph and not exact and not aligned_concept:
score = min(score, 0.35)
if (
object_fidelity.get("status") != "not_applicable"
and float(object_fidelity.get("score", 0.0) or 0.0) < 0.5
):
# A high lexical/router score cannot cure a legal-object mismatch.
score = min(score, 0.34)
signals = {
"bm25": round(bm25, 4),
"dense": round(dense, 4),
"semantic_frame": round(semantic, 4),
"graph_match": graph,
"concept_focus": concept_focus,
"exact_reference": exact,
"frame_coverage": round(coverage, 4),
"topic_anchor_overlap": round(anchor_overlap, 4),
"semantic_address_match": round(address_match, 4),
"legal_object_fidelity": round(float(object_fidelity.get("score", 0.0) or 0.0), 4),
"legal_object_status": object_fidelity.get("status", "not_applicable"),
"legal_object_mechanism": object_fidelity.get("mechanism_id", ""),
"channel_consensus": int(top.get("_channel_consensus", 0) or 0),
}
if score < 0.55 and object_fidelity.get("status") in {"mechanism_conflict", "object_unproven"}:
reason = str(object_fidelity.get("reason", "") or "Kanıt sorulan hukuki işlem türünü doğrulamıyor.")
else:
reason = "Kanıt kanalları ve semantik kapsam yeterli." if score >= 0.55 else "Kanıt kanalları soruyu doğrudan desteklemek için yeterli değil."
return {"score": round(score, 4), "reason": reason, "signals": signals}
def _topic_anchor_overlap(question: str, selected: list[dict[str, Any]]) -> float:
# Delegates to utils.topic_term_overlap so this scoring rule and the
# answering.py suggestion filter share one stopword list and one
# matching strategy. Do not reintroduce a local stopword set here —
# see the note on TOPIC_STOPWORDS in utils.py for why.
return topic_term_overlap(
question,
[
" ".join(
[
str(item.get("source_text", "") or ""),
semantic_address_text(item.get("_semantic_address", {}) or {}),
]
)
for item in selected
],
)
def _frame_coverage(frame: dict[str, Any], selected: list[dict[str, Any]]) -> float:
requested = []
available = []
for field in ("actor", "action", "object", "competent_authority", "beneficiary"):
values = frame.get(field, []) or []
requested.extend(normalize_for_search(str(value)) for value in values if value)
for evidence in selected:
evidence_frame = evidence.get("semantic_frame", {}) or {}
available.extend(normalize_for_search(str(value)) for value in evidence_frame.get(field, []) or [] if value)
if not requested:
return 0.55
matched = sum(1 for value in requested if any(value in candidate or candidate in value for candidate in available))
return matched / len(requested)
def _is_ambiguous(candidates: list[dict[str, Any]]) -> bool:
if len(candidates) < 2:
return False
first, second = candidates[0], candidates[1]
different_scope = (
first.get("document_id"), first.get("article_id")
) != (
second.get("document_id"), second.get("article_id")
)
close = abs(float(first.get("_rrf_score", 0.0)) - float(second.get("_rrf_score", 0.0))) < 0.0025
return different_scope and close and int(first.get("_channel_consensus", 0)) >= 2 and int(second.get("_channel_consensus", 0)) >= 2
def _explicit_reference_scope_ambiguity(question: str, candidates: list[dict[str, Any]]) -> bool:
normalized = normalize_for_search(question)
if not re.search(r"\b(?:gecici |ek )?madde\s+\d+", normalized):
return False
if re.search(r"\b(?:2547|2914|2809)\b", normalized):
return False
exact_candidates = [
item
for item in candidates
if "exact_reference" in ((item.get("_score_breakdown", {}) or {}).get("channels", {}) or {})
]
article_documents: dict[str, set[str]] = {}
for item in exact_candidates:
article_documents.setdefault(str(item.get("article_id", "")), set()).add(str(item.get("document_id", "")))
return any(len(document_ids) > 1 for document_ids in article_documents.values())
def _unresolved_facts(frame: dict[str, Any], selected: list[dict[str, Any]]) -> list[str]:
unresolved = []
if frame.get("condition") and not any((item.get("semantic_frame", {}) or {}).get("condition") for item in selected):
unresolved.append("condition")
if frame.get("temporal_constraint") and not any((item.get("semantic_frame", {}) or {}).get("temporal_constraint") for item in selected):
unresolved.append("temporal_constraint")
return unresolved
def _applied_rules(selected: list[dict[str, Any]]) -> list[dict[str, Any]]:
rules = []
for evidence in selected:
for rule in (evidence.get("_score_breakdown", {}) or {}).get("pilot_rule_hits", []) or []:
if rule not in {item.get("rule_id") for item in rules}:
rules.append({"rule_id": rule, "evidence_id": evidence.get("evidence_id", "")})
return rules
def _json_payload(raw: str) -> Any:
text = str(raw or "").strip()
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.IGNORECASE)
try:
return json.loads(text)
except json.JSONDecodeError:
start, end = text.find("{"), text.rfind("}")
if start >= 0 and end > start:
try:
return json.loads(text[start:end + 1])
except json.JSONDecodeError:
return None
return None
def _claim_has_source_support(claim: str, sources: list[str], claim_type: str = "atomic") -> bool:
claim_terms = _content_terms(claim)
source_terms = _content_terms(" ".join(sources))
if not claim_terms:
return False
threshold = 0.34 if claim_type == "atomic" else 0.42
# Synthesis claims combine multiple sources and use more connective
# language; the threshold is higher (not lower) because a synthesis
# claim's remaining *content* words (after stopword/connector removal)
# must still map back to the union of its cited sources at a strict
# rate — this prevents "synthesis" from becoming a loophole for adding
# unsupported inference while still allowing fluent phrasing.
return len(claim_terms & source_terms) / len(claim_terms) >= threshold
_STRONG_MODALITIES = {"obligation", "permission", "prohibition", "power"}
_NOVEL_RELATION_MARKERS = (
"araciligiyla", "vasitasiyla", "sayesinde", "sonucunda", "bu nedenle",
"alt duzey", "ust duzey", "bagli olarak",
)
def _relation_contract_assessment(
question_frame: dict[str, Any],
selected: list[dict[str, Any]],
) -> dict[str, Any]:
"""Require a requested legal relation to exist inside one evidence unit.
Retrieval-channel consensus proves that a passage is topically close. It
does not prove that an actor, action and deontic operator belong to the
same proposition. For obligation/permission/prohibition/power questions,
this gate performs that missing proposition-level check.
"""
modality = str(question_frame.get("modality", "") or "")
question_actors = list(question_frame.get("actor", []) or [])
question_actions = list(question_frame.get("action", []) or [])
required = (
modality in _STRONG_MODALITIES
and bool(question_actors)
and _explicit_relation_request(question_frame)
)
diagnostics = []
if not required:
return {
"required": False,
"aligned": True,
"reason": "Soru proposition-level deontik ilişki doğrulaması gerektirmiyor.",
"matches": diagnostics,
}
for item in selected:
source_text = str(item.get("source_text", "") or "")
source_frame = extract_normative_frame(source_text, [])
source_actors = list(source_frame.get("actor", []) or [])
if not source_actors:
title_frame = extract_normative_frame(str(item.get("article_title", "") or ""), [])
source_actors = list(title_frame.get("actor", []) or [])
source_actions = list(source_frame.get("action", []) or [])
source_modality = str(source_frame.get("modality", "") or "")
actor_match = _frame_values_overlap(question_actors, source_actors)
action_match = not question_actions or _frame_values_overlap(question_actions, source_actions)
modality_match = _modalities_compatible(modality, source_modality)
diagnostics.append({
"evidence_id": item.get("evidence_id", ""),
"actor_match": actor_match,
"action_match": action_match,
"modality_match": modality_match,
"source_actors": source_actors,
"source_actions": source_actions,
"source_modality": source_modality,
})
if actor_match and action_match and modality_match:
return {
"required": True,
"aligned": True,
"reason": "Sorulan özne-eylem-modalite ilişkisi tek bir kanıt biriminde doğrulandı.",
"matches": diagnostics,
}
return {
"required": True,
"aligned": False,
"reason": (
"En yakın hükümler konu bakımından ilişkili olsa da sorudaki özne, eylem ve "
"hukuki modaliteyi aynı normatif önerme içinde birlikte kurmuyor."
),
"matches": diagnostics,
}
def _explicit_relation_request(question_frame: dict[str, Any]) -> bool:
"""Distinguish a proposition test from a broad duty/power catalogue.
"Görev, yetki ve sorumlulukları nelerdir?" asks for a list and should be
answered from several subclauses. "X yükümlü müdür?" or "X'i kim atar?"
asks whether one concrete legal relation exists and must pass the stricter
same-unit entailment gate.
"""
normalized = str(question_frame.get("normalized_question", "") or "")
explicit_markers = (
"yukumlu", "zorunlu", "zorunda", "mecbur", "gerekir", "sart midir",
"yapabilir mi", "mumkun mudur", "izinli midir",
"yasak midir", "yapamaz mi", "olamaz mi",
"yetkili midir", "yetkilidir",
"kim atar", "kim tarafindan atanir", "nasil atanir",
"kim secer", "kim belirler", "kim karar verir",
)
return any(marker in normalized for marker in explicit_markers)
def _claim_relation_supported(
claim: str,
evidence_records: list[dict[str, Any]],
claim_type: str,
) -> bool:
normalized_claim = normalize_for_search(claim)
normalized_sources = normalize_for_search(
" ".join(str(item.get("source_text", "") or "") for item in evidence_records)
)
if any(marker in normalized_claim and marker not in normalized_sources for marker in _NOVEL_RELATION_MARKERS):
return False
if not _numeric_fidelity(claim, normalized_sources):
return False
claim_frame = extract_normative_frame(claim, [])
claim_modality = str(claim_frame.get("modality", "") or "")
claim_actors = list(claim_frame.get("actor", []) or [])
claim_actions = list(claim_frame.get("action", []) or [])
if claim_modality not in _STRONG_MODALITIES:
return True
# Even a synthesis claim may explain several propositions, but it may not
# manufacture a new legal relation by taking the actor from one source and
# the duty/power from another. At least one cited unit must entail it whole.
for item in evidence_records:
source_frame = extract_normative_frame(str(item.get("source_text", "") or ""), [])
source_actors = list(source_frame.get("actor", []) or [])
if not source_actors:
title_frame = extract_normative_frame(str(item.get("article_title", "") or ""), [])
source_actors = list(title_frame.get("actor", []) or [])
actor_match = not claim_actors or _frame_values_overlap(claim_actors, source_actors)
action_match = not claim_actions or _frame_values_overlap(
claim_actions,
list(source_frame.get("action", []) or []),
)
modality_match = _modalities_compatible(
claim_modality,
str(source_frame.get("modality", "") or ""),
)
if actor_match and action_match and modality_match:
return True
return False
def _modalities_compatible(requested: str, actual: str) -> bool:
if requested == actual:
return True
# Passive appointment/selection rules are encoded as power even if the
# sentence does not literally contain "yetkilidir".
return (requested, actual) in {("permission", "power"), ("power", "permission")}
def _frame_values_overlap(left: list[Any], right: list[Any]) -> bool:
left_values = [normalize_for_search(str(value)) for value in left if str(value).strip()]
right_values = [normalize_for_search(str(value)) for value in right if str(value).strip()]
return any(_term_value_match(a, b) for a in left_values for b in right_values)
def _term_value_match(left: str, right: str) -> bool:
if not left or not right:
return False
if left == right or left in right or right in left:
return True
left_terms = set(left.split())
right_terms = set(right.split())
return bool(left_terms and right_terms and len(left_terms & right_terms) / min(len(left_terms), len(right_terms)) >= 0.6)
def _numeric_fidelity(claim: str, normalized_sources: str) -> bool:
claim_numbers = set(re.findall(r"\b\d+(?:[.,]\d+)?\b", normalize_for_search(claim)))
source_numbers = set(re.findall(r"\b\d+(?:[.,]\d+)?\b", normalized_sources))
return claim_numbers.issubset(source_numbers)
_CONNECTIVE_STOPWORDS = {
"olan", "olarak", "icin", "için", "ile", "veya", "göre", "gore", "bu", "bir", "de", "da",
"nedenle", "birlikte", "degerlendirildiginde", "değerlendirildiğinde", "iliski", "ilişki",
"cercevesinde", "çerçevesinde", "esasina", "esasına", "kapsaminda", "kapsamında",
"boylece", "böylece", "dolayisiyla", "dolayısıyla", "ayrica", "ayrıca", "bununla",
"yaninda", "yanında", "yani", "ancak", "fakat", "hem", "hemde", "hem de",
}
def _content_terms(text: str) -> set[str]:
return {
term for term in normalize_for_search(text).split()
if len(term) >= 4 and term not in _CONNECTIVE_STOPWORDS
}