| 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:
|
|
|
|
|
| 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
|
|
|
|
|
| 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: |
|
|
| 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 |
| ): |
| |
| 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:
|
|
|
|
|
|
|
|
|
| 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 |
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
| |
| |
| |
| 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 |
| |
| |
| 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
|
| } |
|
|