File size: 36,432 Bytes
eff511c f7d0506 eff511c f7d0506 eff511c f7d0506 eff511c f7d0506 eff511c f7d0506 eff511c f7d0506 eff511c f7d0506 eff511c 8c534a8 eff511c 8c534a8 eff511c 8c534a8 eff511c 8c534a8 eff511c f7d0506 eff511c f7d0506 eff511c f7d0506 eff511c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 | 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
}
|