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
    }