YOKPilot3 / clause_retrieval.py
mfirat007's picture
Upload 2 files
ae5fe34 verified
Raw
History Blame Contribute Delete
94.7 kB
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from intent import detect_actors, intent_profile
from sources import format_clause_sources, set_article_sources
from utils import (
TOPIC_STOPWORDS,
clean_answer_text,
normalize_for_search,
query_terms,
search_terms_match,
)
from normative_roles import (
build_normative_knowledge_unit,
enrich_normative_frame,
extract_question_frame,
normative_kg_alignment_score,
semantic_role_match_score,
)
from llm import llm_available, call_chat_completion
from prompts import SYSTEM_PROMPT, response_style_instruction
from query_understanding import query_understanding, expand_query_terms
from rdfox_pilot import (
analyze_pilot_query,
apply_pilot_answer_micro_rules,
enrich_selected_evidence,
micro_rule_score,
)
from source_router import route_question
from normative_runtime import init_normative_runtime
from config import MCKF_DIR, RDFOX_PILOT_TRIPLES_PATH
from graph_runtime import MCKFGraphRuntime
from hybrid_retrieval import HybridRetrievalEngine
from hermeneutic_intent import analyze_interpretive_intent, legal_object_retrieval_factor
from proof_bundle import (
build_proof_bundle,
deterministic_claims,
llm_claim_prompt,
parse_and_validate_llm_claims,
render_proof_answer,
)
ONTOLOGY: dict[str, Any] = {}
CLAUSES: list[dict[str, Any]] = []
EVIDENCE_SPANS: list[dict[str, Any]] = []
CONCEPTS_BY_ID: dict[str, dict[str, Any]] = {}
CLAUSES_BY_ID: dict[str, dict[str, Any]] = {}
DOCUMENTS_BY_ID: dict[str, dict[str, Any]] = {}
CROSS_DOCUMENT_EDGES: list[dict[str, Any]] = []
GRAPH_RUNTIME: MCKFGraphRuntime | None = None
HYBRID_ENGINE: HybridRetrievalEngine | None = None
CORPUS_BUILD_ID = ""
MIN_EVIDENCE_SCORE = 8.0
MIN_SCORE_GAP = 0.35
def init_clause_retrieval(ontology: dict[str, Any] | None) -> None:
global ONTOLOGY, CLAUSES, EVIDENCE_SPANS, CONCEPTS_BY_ID, CLAUSES_BY_ID, DOCUMENTS_BY_ID, CROSS_DOCUMENT_EDGES
global GRAPH_RUNTIME, HYBRID_ENGINE, CORPUS_BUILD_ID
ONTOLOGY = ontology or {}
# Retrieval and routing consume the same immutable package. Initializing
# retrieval in isolation must not leave the canonical router on its empty
# bootstrap corpus; that creates environment-dependent, apparently stale
# behaviour between the app, tests and administrative jobs.
init_normative_runtime(ONTOLOGY)
CLAUSES = ONTOLOGY.get("clauses", []) or []
EVIDENCE_SPANS = ONTOLOGY.get("evidence_spans", []) or _flatten_evidence_spans(CLAUSES)
DOCUMENTS_BY_ID = {
document.get("document_id", ""): document
for document in ONTOLOGY.get("documents", []) or []
if document.get("document_id")
}
if not DOCUMENTS_BY_ID and ONTOLOGY.get("document_id"):
DOCUMENTS_BY_ID = {
ONTOLOGY.get("document_id", ""): {
"document_id": ONTOLOGY.get("document_id", ""),
"title": ONTOLOGY.get("document_title", ""),
"short_code": ONTOLOGY.get("short_code", ""),
"domain_tags": [],
}
}
CROSS_DOCUMENT_EDGES = ONTOLOGY.get("cross_document_edges", []) or []
CONCEPTS_BY_ID = {
concept.get("concept_id", ""): concept
for concept in ONTOLOGY.get("concepts", []) or []
if concept.get("concept_id")
}
CLAUSES_BY_ID = {
clause.get("clause_id", ""): clause
for clause in CLAUSES
if clause.get("clause_id")
}
_enrich_runtime_normative_units()
for evidence in EVIDENCE_SPANS:
parent_clause = CLAUSES_BY_ID.get(evidence.get("parent_clause_id", ""), {})
concept_id = parent_clause.get("parent_concept_id", "")
concept = CONCEPTS_BY_ID.get(concept_id, {})
evidence["_concept_id"] = concept_id
evidence["_semantic_address"] = concept.get("normative_metadata", {}) or {}
CORPUS_BUILD_ID = str(ONTOLOGY.get("build_id", "") or "")
GRAPH_RUNTIME = MCKFGraphRuntime(
EVIDENCE_SPANS,
CROSS_DOCUMENT_EDGES,
triples_path=RDFOX_PILOT_TRIPLES_PATH,
)
HYBRID_ENGINE = HybridRetrievalEngine(
EVIDENCE_SPANS,
GRAPH_RUNTIME,
build_id=CORPUS_BUILD_ID,
cache_dir=MCKF_DIR / "runtime_cache",
)
set_article_sources({
f"{concept.get('document_id', '')}::{concept.get('article_id', '')}": {
"document_id": concept.get("document_id", ""),
"document_title": concept.get("document_title", ""),
"article_id": concept.get("article_id", ""),
"title": concept.get("title", ""),
"source_text": concept.get("source_text", ""),
}
for concept in ONTOLOGY.get("concepts", []) or []
if concept.get("document_id") and concept.get("article_id")
})
def load_ontology(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
return json.loads(path.read_text(encoding="utf-8"))
def _enrich_runtime_normative_units() -> None:
for clause in CLAUSES:
text = clause.get("source_text", "")
frame = enrich_normative_frame(text, clause.get("semantic_frame", {}), clause.get("actors", []))
clause["semantic_frame"] = frame
clause["normative_unit"] = build_normative_knowledge_unit(
text,
frame,
{
"clause_id": clause.get("clause_id", ""),
"document_id": clause.get("document_id", ""),
"article_id": clause.get("article_id", ""),
"label": clause.get("label", ""),
},
)
for evidence in EVIDENCE_SPANS:
parent_clause = CLAUSES_BY_ID.get(evidence.get("parent_clause_id", ""), {})
text = evidence.get("source_text", "")
frame = enrich_normative_frame(text, evidence.get("semantic_frame", {}), parent_clause.get("actors", []))
evidence["semantic_frame"] = frame
evidence["normative_unit"] = build_normative_knowledge_unit(
text,
frame,
{
"evidence_id": evidence.get("evidence_id", ""),
"parent_clause_id": evidence.get("parent_clause_id", ""),
"document_id": evidence.get("document_id", ""),
"article_id": evidence.get("article_id", ""),
"label": evidence.get("label", ""),
},
)
def retrieve_evidence_spans(
question: str,
limit: int = 80,
min_score: float = MIN_EVIDENCE_SCORE,
allowed_articles: list[str] | None = None,
allowed_documents: list[str] | None = None,
allowed_scopes: list[tuple[str, str]] | None = None,
) -> list[dict[str, Any]]:
if not EVIDENCE_SPANS or HYBRID_ENGINE is None:
return []
profile = intent_profile(question)
profile["raw_question"] = question
profile.update(query_understanding(question))
profile["pilot_query"] = analyze_pilot_query(question)
source_route = route_question(question, DOCUMENTS_BY_ID, CROSS_DOCUMENT_EDGES)
profile["source_route"] = source_route
terms = query_terms(question)
terms |= expand_query_terms(
question,
interpretive=profile.get("interpretive_intent"),
practical_effect=profile.get("practical_effect_intent"),
)
explicit_refs = _explicit_article_references(question)
scope_constraints = {
(str(document_id), str(article_id))
for document_id, article_id in (allowed_scopes or [])
if document_id and article_id
}
allowed = set(allowed_articles or explicit_refs)
allowed.update(article_id for _document_id, article_id in scope_constraints)
explicit_documents = _explicit_document_ids(question)
if allowed_documents:
constrained_documents = set(allowed_documents)
elif source_route.get("cross_document"):
# In comparative questions, named laws describe the requested scope;
# they must not exclude a second law inferred by the relation graph.
constrained_documents = set(source_route.get("candidate_document_ids", []) or explicit_documents)
elif explicit_documents:
constrained_documents = set(explicit_documents)
elif (
len(source_route.get("candidate_document_ids", []) or []) == 1
and bool(source_route.get("scope_confident"))
):
# A high-confidence single-document route is also a scope boundary.
# This prevents graph expansion from leaking unrelated statutes into
# an otherwise unambiguous answer.
constrained_documents = set(source_route.get("candidate_document_ids", []) or [])
else:
constrained_documents = set()
allowed_ids: set[str] = set()
for evidence in EVIDENCE_SPANS:
evidence_scope = (
str(evidence.get("document_id", "") or ""),
str(evidence.get("article_id", "") or ""),
)
if scope_constraints and evidence_scope not in scope_constraints:
continue
if allowed and evidence.get("article_id", "") not in allowed:
continue
document_id = evidence.get("document_id", "")
if constrained_documents and document_id not in constrained_documents:
continue
evidence_id = str(evidence.get("evidence_id", "") or "")
if evidence_id:
allowed_ids.add(evidence_id)
question_frame = extract_question_frame(question)
question_frame["query_expansions"] = list(profile.get("expanded_terms", []) or [])
requested_roles = {str(profile.get("norm_type", "") or "")}
requested_roles.update(str(role) for role in (profile.get("pilot_query", {}) or {}).get("semantic_role_boost", []) or [])
requested_roles.update(str(role) for role in question_frame.get("requested_categories", []) or [])
requested_roles.discard("")
semantic_pool = HYBRID_ENGINE.semantic_prefilter(
question,
question_frame,
requested_roles,
allowed_ids,
set(allowed),
)
hint_scopes = {
(str(document_id), str(article_id))
for document_id, article_ids in (source_route.get("target_articles_by_document", {}) or {}).items()
for article_id in article_ids or []
}
routed_articles = {str(value) for value in profile.get("routed_articles", []) or [] if value}
routed_document = str(profile.get("routed_document_id", "") or source_route.get("top_document_id", "") or "")
if routed_document and routed_articles:
hint_scopes.update((routed_document, article_id) for article_id in routed_articles)
relation_scopes, matched_relation_ids = _matched_normative_relation_scopes(
question,
source_route.get("candidate_edges", []) or [],
)
canonical_scopes = _canonical_article_title_scopes(question)
# A unique canonical heading is the user's primary legal object. Graph
# relations remain useful evidence after that object is selected, but they
# must not replace it with a statute that merely defines or references it.
if canonical_scopes:
relation_scopes = set()
matched_relation_ids = []
profile["matched_normative_relation_ids"] = matched_relation_ids
profile["canonical_title_scopes"] = sorted(canonical_scopes)
if hint_scopes:
semantic_pool.update(
evidence_id
for evidence_id in allowed_ids
if (
str(HYBRID_ENGINE.evidence_by_id[evidence_id].get("document_id", "")),
str(HYBRID_ENGINE.evidence_by_id[evidence_id].get("article_id", "")),
) in hint_scopes
)
semantic_scores = {}
for evidence_id in sorted(semantic_pool):
evidence = HYBRID_ENGINE.evidence_by_id[evidence_id]
semantic_scores[evidence_id] = evidence_match_score(evidence, profile, terms, source_route=source_route)
ranked = HYBRID_ENGINE.retrieve(
question,
question_frame,
semantic_scores,
requested_roles=requested_roles,
allowed_ids=allowed_ids,
exact_articles=set(allowed),
hint_scopes=hint_scopes,
relation_scopes=relation_scopes,
canonical_scopes=canonical_scopes,
limit=limit,
)
return _attach_structural_context(ranked, allowed_ids, limit)
def _attach_structural_context(
ranked: list[dict[str, Any]],
allowed_ids: set[str],
limit: int,
) -> list[dict[str, Any]]:
"""Keep source fragments that form one sentence together.
Evidence extraction intentionally creates atomic spans. Legal sentences
frequently cross a semicolon, so an isolated span can omit the authority,
condition or legal effect that completes the proposition. Adjacent spans
are attached as structural context without giving them an independent
semantic match score.
"""
by_scope: dict[tuple[str, str, str], list[dict[str, Any]]] = {}
for evidence in EVIDENCE_SPANS:
evidence_id = str(evidence.get("evidence_id", "") or "")
if evidence_id not in allowed_ids:
continue
key = (
str(evidence.get("document_id", "") or ""),
str(evidence.get("article_id", "") or ""),
str(evidence.get("parent_clause_id", "") or ""),
)
by_scope.setdefault(key, []).append(evidence)
for values in by_scope.values():
values.sort(key=lambda item: int((item.get("source_span", {}) or {}).get("char_start", 10**9) or 0))
output: list[dict[str, Any]] = []
seen: set[str] = set()
for anchor in ranked:
anchor_id = str(anchor.get("evidence_id", "") or "")
if anchor_id and anchor_id not in seen:
output.append(anchor)
seen.add(anchor_id)
key = (
str(anchor.get("document_id", "") or ""),
str(anchor.get("article_id", "") or ""),
str(anchor.get("parent_clause_id", "") or ""),
)
siblings = by_scope.get(key, [])
index = next((i for i, item in enumerate(siblings) if item.get("evidence_id") == anchor_id), -1)
if index < 0:
continue
neighbors = []
if index > 0 and _source_fragments_continue(siblings[index - 1], anchor):
neighbors.append(siblings[index - 1])
if index + 1 < len(siblings) and _source_fragments_continue(anchor, siblings[index + 1]):
neighbors.append(siblings[index + 1])
for neighbor in neighbors:
neighbor_id = str(neighbor.get("evidence_id", "") or "")
if not neighbor_id or neighbor_id in seen:
continue
contextual = dict(neighbor)
contextual["_score"] = round(max(8.0, float(anchor.get("_score", 0.0) or 0.0) * 0.92), 4)
contextual["_retrieval_channels"] = sorted(
set(contextual.get("_retrieval_channels", []) or []) | {"structural_context"}
)
contextual["_channel_consensus"] = max(1, int(anchor.get("_channel_consensus", 0) or 0))
contextual["_score_breakdown"] = {
**(contextual.get("_score_breakdown", {}) or {}),
"structural_context_of": anchor_id,
}
output.append(contextual)
seen.add(neighbor_id)
if len(output) >= limit:
break
return output[:limit]
def _source_fragments_continue(previous: dict[str, Any], current: dict[str, Any]) -> bool:
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 ".?!")
_RELATION_STOPWORDS = {
"2547", "2809", "2914", "sayili", "kanun", "kanunu", "madde",
"hangi", "nedir", "arasindaki", "ifadesi", "hukum", "hukmune",
"baglam", "baglamiyla", "uyarinca",
}
def _canonical_article_title_scopes(question: str) -> set[tuple[str, str]]:
"""Resolve a unique statute heading before graph and evidence fusion.
Article headings and their canonical aliases are authoritative structural
data. This resolver does not guess an article from raw semantic proximity:
it activates only when at least two substantive query terms match a heading,
both query and heading coverage are strong, and the best scope has a clear
margin over the runner-up.
"""
normalized_question = normalize_for_search(question)
query_tokens = set(normalized_question.split())
# A heading such as "Yükseköğretim Kurulunun görevleri" is not a unique
# answer to the actor-less phrase "Kurulun görevleri". Canonical-title
# authority applies only after the governed body itself is resolved.
if (
query_tokens & {"kurul", "kurulu", "kurulun", "kurulunun"}
and not detect_actors(question)
):
return set()
query_terms_set = {
token
for token in normalized_question.split()
if len(token) >= 2 and token not in TOPIC_STOPWORDS and not token.isdigit()
}
if len(query_terms_set) < 2:
return set()
explicit_documents = set(_explicit_document_ids(question))
best_by_scope: dict[tuple[str, str], float] = {}
for concept in CONCEPTS_BY_ID.values():
document_id = str(concept.get("document_id", "") or "")
article_id = str(concept.get("article_id", "") or "")
if not document_id or not article_id:
continue
if explicit_documents and document_id not in explicit_documents:
continue
address = concept.get("normative_metadata", {}) or {}
title_values = [
concept.get("title", ""),
address.get("article_heading", ""),
address.get("regulates", ""),
*(address.get("query_aliases", []) or []),
*(address.get("canonical_concepts", []) or []),
]
candidate_scores = []
for title_value in title_values:
# Amendment/date annotations are provenance, not part of the
# canonical legal title users are expected to utter.
clean_title = re.sub(r"\([^)]*\)", " ", str(title_value or ""))
title_terms = {
token
for token in normalize_for_search(clean_title).split()
if len(token) >= 2 and token not in TOPIC_STOPWORDS and not token.isdigit()
}
if not title_terms:
continue
matched_query = {
query_term
for query_term in query_terms_set
if any(search_terms_match(query_term, title_term) for title_term in title_terms)
}
matched_title = {
title_term
for title_term in title_terms
if any(search_terms_match(title_term, query_term) for query_term in query_terms_set)
}
if len(matched_query) < 2:
continue
query_coverage = len(matched_query) / len(query_terms_set)
title_coverage = len(matched_title) / len(title_terms)
if query_coverage < 0.50 or title_coverage < 0.60:
continue
candidate_scores.append((query_coverage + title_coverage) / 2.0)
if not candidate_scores:
continue
score = max(candidate_scores)
scope = (document_id, article_id)
best_by_scope[scope] = max(best_by_scope.get(scope, 0.0), score)
ranked = sorted(best_by_scope.items(), key=lambda item: (-item[1], item[0]))
if not ranked or ranked[0][1] < 0.66:
return set()
if len(ranked) >= 2 and ranked[0][1] - ranked[1][1] < 0.10:
return set()
return {ranked[0][0]}
def _relation_terms(text: str) -> set[str]:
terms: set[str] = set()
for token in normalize_for_search(text).split():
if len(token) < 4 or token.isdigit() or token in _RELATION_STOPWORDS:
continue
terms.add(token if len(token) < 6 else token[:5])
return terms
def _matched_normative_relation_scopes(
question: str,
edges: list[dict[str, Any]],
) -> tuple[set[tuple[str, str]], list[str]]:
"""Resolve graph edges by their canonical relation description.
Relation descriptions are corpus data emitted by the document compiler.
Requiring at least two substantive concept matches keeps generic statutory
references from becoming article shortcuts.
"""
query = _relation_terms(question)
ranked: list[tuple[int, float, dict[str, Any]]] = []
for edge in edges:
relation_text = " ".join(
str(edge.get(field, "") or "")
for field in ("description", "relation_type")
)
relation = _relation_terms(relation_text)
matches = query & relation
if len(matches) < 2:
continue
coverage = len(matches) / max(min(len(query), len(relation)), 1)
ranked.append((len(matches), coverage, edge))
if not ranked:
return set(), []
ranked.sort(key=lambda item: (item[0], item[1]), reverse=True)
best_count, best_coverage, _ = ranked[0]
matched = [
edge for count, coverage, edge in ranked
if count == best_count and coverage >= best_coverage - 0.08
]
scopes: set[tuple[str, str]] = set()
for edge in matched:
scopes.add((str(edge.get("source_document_id", "")), str(edge.get("source_article_id", ""))))
scopes.add((str(edge.get("target_document_id", "")), str(edge.get("target_article_id", ""))))
scopes.discard(("", ""))
return scopes, [str(edge.get("edge_id", "")) for edge in matched if edge.get("edge_id")]
def retrieve_clauses(
question: str,
limit: int = 80,
min_score: float = MIN_EVIDENCE_SCORE,
allowed_articles: list[str] | None = None,
) -> list[dict[str, Any]]:
"""Compatibility wrapper: return parent clauses ranked by best evidence span.
v0.6 retrieval is evidence-span first. This function still returns clause objects so older
tests and callers remain compatible, but each clause carries `_selected_evidence`.
"""
evidence_spans = retrieve_evidence_spans(question, limit=limit, min_score=min_score, allowed_articles=allowed_articles)
clauses: list[dict[str, Any]] = []
seen: set[str] = set()
for evidence in evidence_spans:
clause_id = evidence.get("parent_clause_id", "")
if not clause_id or clause_id in seen:
continue
clause = dict(CLAUSES_BY_ID.get(clause_id, {}))
if not clause:
continue
clause["_score"] = evidence.get("_score", 0.0)
clause["_selected_evidence"] = evidence
clause["_score_breakdown"] = evidence.get("_score_breakdown", {})
clauses.append(clause)
seen.add(clause_id)
return clauses[:limit]
def evidence_match_score(
evidence: dict[str, Any],
profile: dict[str, Any],
terms: set[str],
source_route: dict[str, Any] | None = None,
) -> float:
q = profile.get("normalized_question", "")
norm_type = profile.get("norm_type", "")
evidence_text = normalize_for_search(evidence.get("source_text", ""))
article_id = normalize_for_search(evidence.get("article_id", ""))
label = normalize_for_search(evidence.get("label", ""))
roles = set(evidence.get("semantic_roles", []) or [])
parent_clause = CLAUSES_BY_ID.get(evidence.get("parent_clause_id", ""), {})
concept = CONCEPTS_BY_ID.get(parent_clause.get("parent_concept_id", ""), {})
concept_text = normalize_for_search(" ".join([
concept.get("title", ""),
" ".join(concept.get("aliases", []) or []),
" ".join(concept.get("question_patterns", []) or []),
]))
q_frame = extract_question_frame(profile.get("raw_question") or q)
e_frame = enrich_normative_frame(evidence.get("source_text", ""), evidence.get("semantic_frame", {}) or {}, parent_clause.get("actors", []))
evidence["semantic_frame"] = e_frame
srm = semantic_role_match_score(q_frame, e_frame)
kg_alignment = normative_kg_alignment_score(q_frame, e_frame)
norm_score = _norm_type_score(norm_type, evidence, roles, parent_clause, concept)
entity_scope_score = _entity_scope_score(profile, evidence, concept, e_frame)
lexical_score = _lexical_score(terms, evidence_text, concept_text, article_id, label)
structure_score = _structure_score(profile, evidence)
evidence_focus_score = _evidence_focus_score(profile, evidence_text, e_frame)
router_score = _source_router_score(evidence, source_route or {})
document_domain_score = _document_domain_score(evidence, source_route or {})
article_hint_score = _article_hint_score(evidence, source_route or {})
pilot_score, pilot_rule_hits = micro_rule_score(
profile.get("pilot_query"),
evidence,
parent_clause=parent_clause,
concept=concept,
)
cross_document_bonus = _cross_document_bonus(evidence, source_route or {})
wrong_document_penalty = _wrong_document_penalty(evidence, source_route or {})
generic_article_penalty = _generic_penalty(profile, evidence, e_frame)
concept_mismatch_penalty = _concept_mismatch_penalty(q_frame, evidence_text, concept_text)
query_focus_score, query_focus_penalty = _query_focus_match(q, evidence_text)
score01 = (
0.19 * srm.get("total", 0.0)
+ 0.13 * kg_alignment.get("total", 0.0)
+ 0.16 * entity_scope_score
+ 0.12 * norm_score
+ 0.12 * lexical_score
+ 0.06 * structure_score
+ 0.06 * evidence_focus_score
+ 0.11 * router_score
+ 0.06 * document_domain_score
+ 0.08 * article_hint_score
+ 0.05 * pilot_score
+ 0.14 * query_focus_score
+ cross_document_bonus
)
penalty = generic_article_penalty + wrong_document_penalty + concept_mismatch_penalty + query_focus_penalty
final01 = max(0.0, min(1.0, score01) - penalty)
evidence["_score_breakdown"] = {
"srm": srm,
"kg_alignment": round(kg_alignment.get("total", 0.0), 4),
"kg_alignment_components": kg_alignment,
"semantic_role_score": round(srm.get("total", 0.0), 4),
"norm_type": round(norm_score, 4),
"norm_type_score": round(norm_score, 4),
"entity_scope": round(entity_scope_score, 4),
"lexical": round(lexical_score, 4),
"lexical_score": round(lexical_score, 4),
"structure": round(structure_score, 4),
"structure_score": round(structure_score, 4),
"evidence_focus": round(evidence_focus_score, 4),
"evidence_focus_score": round(evidence_focus_score, 4),
"source_router_score": round(router_score, 4),
"document_domain_score": round(document_domain_score, 4),
"article_hint_score": round(article_hint_score, 4),
"pilot_rule_score": round(pilot_score, 4),
"pilot_rule_hits": pilot_rule_hits,
"query_focus_score": round(query_focus_score, 4),
"query_focus_penalty": round(query_focus_penalty, 4),
"cross_document_bonus": round(cross_document_bonus, 4),
"wrong_document_penalty": round(wrong_document_penalty, 4),
"generic_article_penalty": round(generic_article_penalty, 4),
"concept_mismatch_penalty": round(concept_mismatch_penalty, 4),
"penalty": round(penalty, 4),
"final_0_1": round(final01, 4),
}
return final01 * 20.0
def _query_focus_match(question: str, evidence_text: str) -> tuple[float, float]:
"""Match the governed object in narrow passive appointment questions."""
match = re.search(r"\b([a-z0-9]+)\s+kim\s+atar\b", question)
if not match:
match = re.search(r"\b([a-z0-9]+)\s+nasil\s+atan(?:ir|ir)\b", question)
if not match:
return 0.0, 0.0
target = match.group(1)
for suffix in ("leri", "lari", "ini", "unu", "unu", "yı", "yi", "yu", "yü", "i", "ı", "u", "ü"):
normalized_suffix = normalize_for_search(suffix)
if target.endswith(normalized_suffix) and len(target) - len(normalized_suffix) >= 4:
target = target[:-len(normalized_suffix)]
break
if target not in evidence_text:
return 0.0, 0.14
if re.search(rf"\b{re.escape(target)}\s+yardimci", evidence_text):
return 0.0, 0.20
if "atan" not in evidence_text:
return 0.0, 0.10
if re.search(
rf"\b{re.escape(target)}\b.{{0,140}}(?:cumhurbaskaninca|kurulunca|tarafindan|teklifi).{{0,100}}atan",
evidence_text,
):
return 1.0, 0.0
if re.search(rf"\b{re.escape(target)}\b.{{0,100}}\batar\b", evidence_text):
return 0.85, 0.0
# "Kim atar?" requires the appointing authority. A sentence that only says
# a new office holder is appointed does not answer that question.
return 0.0, 0.14
def answer_from_clauses(
question: str,
normativity_level=2,
allowed_articles: list[str] | None = None,
allowed_documents: list[str] | None = None,
allowed_scopes: list[tuple[str, str]] | None = None,
evidence_spans: list[dict[str, Any]] | None = None,
) -> str:
profile = intent_profile(question)
profile.update(query_understanding(question))
profile["strict_legal_object"] = bool(allowed_scopes)
profile["pilot_query"] = analyze_pilot_query(question)
profile["source_route"] = route_question(question, DOCUMENTS_BY_ID, CROSS_DOCUMENT_EDGES)
if evidence_spans is None:
evidence_spans = retrieve_evidence_spans(
question,
allowed_articles=allowed_articles,
allowed_documents=allowed_documents,
allowed_scopes=allowed_scopes,
)
if not evidence_spans:
return ""
selected = _select_proof_evidence(evidence_spans, profile, question)
if not selected:
return ""
selected = enrich_selected_evidence(profile.get("pilot_query"), selected)
for item in selected:
document = DOCUMENTS_BY_ID.get(str(item.get("document_id", "")), {})
item["document_domain_tags"] = document.get("domain_tags", []) or []
proof = build_proof_bundle(question, evidence_spans, selected, CORPUS_BUILD_ID)
if proof.get("status") != "supported":
return ""
claims = deterministic_claims(proof) if any(_canonical_title_anchor(item) for item in selected) else None
if claims is None and llm_available():
claims = _llm_claims_from_proof(proof, normativity_level)
# A model error, invalid JSON, or a fully rejected claim set must not
# silently turn into an extractive answer that looks model-validated.
if not claims:
return ""
answer = render_proof_answer(proof, claims)
if not answer:
return ""
return answer + "\n\n---\n\n**Kaynaklar**\n" + format_clause_sources(selected, question=question, answer=answer)
def evaluate_clause_query(
question: str,
allowed_articles: list[str] | None = None,
allowed_documents: list[str] | None = None,
allowed_scopes: list[tuple[str, str]] | None = None,
retrieval_question: str | None = None,
) -> dict[str, Any]:
evidence_spans = retrieve_evidence_spans(
retrieval_question or question,
allowed_articles=allowed_articles,
allowed_documents=allowed_documents,
allowed_scopes=allowed_scopes,
)
parent_clause_ids = []
for evidence in evidence_spans:
cid = evidence.get("parent_clause_id", "")
if cid and cid not in parent_clause_ids:
parent_clause_ids.append(cid)
profile = intent_profile(question)
profile.update(query_understanding(question))
profile["strict_legal_object"] = bool(allowed_scopes)
profile["source_route"] = route_question(question, DOCUMENTS_BY_ID, CROSS_DOCUMENT_EDGES)
selected = _select_proof_evidence(evidence_spans, profile, question)
proof = build_proof_bundle(question, evidence_spans, selected, CORPUS_BUILD_ID)
return {
"evidence_spans": evidence_spans,
"evidence_ids": [evidence.get("evidence_id", "") for evidence in evidence_spans],
"clauses": [CLAUSES_BY_ID.get(cid, {}) for cid in parent_clause_ids if cid in CLAUSES_BY_ID],
"clause_ids": parent_clause_ids,
"document_ids": [evidence.get("document_id", "") for evidence in evidence_spans],
"source_ids": [evidence.get("article_id", "") for evidence in evidence_spans],
"scores": [evidence.get("_score", 0.0) for evidence in evidence_spans],
"score_breakdowns": [evidence.get("_score_breakdown", {}) for evidence in evidence_spans],
"source_route": route_question(question, DOCUMENTS_BY_ID, CROSS_DOCUMENT_EDGES),
"selected_evidence_ids": [item.get("evidence_id", "") for item in selected],
"proof_bundle": proof,
}
def retrieval_runtime_status() -> dict[str, Any]:
if HYBRID_ENGINE is None:
return {"status": "not_initialized"}
return {"status": "ready", **HYBRID_ENGINE.status()}
def _explicit_document_ids(question: str) -> list[str]:
normalized = normalize_for_search(question)
found = []
for document_id, document in DOCUMENTS_BY_ID.items():
short_code = normalize_for_search(str(document.get("short_code", "") or ""))
title = normalize_for_search(str(document.get("title", "") or ""))
if short_code and re.search(rf"\b{re.escape(short_code)}\b", normalized):
found.append(document_id)
continue
meaningful_title = " ".join(term for term in title.split() if term not in {"sayili", "kanunu", "kanun"})
if meaningful_title and meaningful_title in normalized:
found.append(document_id)
return found
def _canonical_title_anchor(evidence: dict[str, Any]) -> bool:
breakdown = evidence.get("_score_breakdown", {}) or {}
channels = breakdown.get("channels", {}) or {}
return (
"canonical_title" in channels
and float(breakdown.get("canonical_title_factor", 1.0) or 1.0) > 1.0
)
def _select_canonical_parent_evidence(
evidence_spans: list[dict[str, Any]],
anchor: dict[str, Any],
limit: int = 8,
) -> list[dict[str, Any]]:
"""Keep deterministic answers inside the canonical heading's operative clause."""
scope = (anchor.get("document_id", ""), anchor.get("article_id", ""))
parent_clause_id = str(anchor.get("parent_clause_id", "") or "")
selected = [
item
for item in evidence_spans
if (item.get("document_id", ""), item.get("article_id", "")) == scope
and (not parent_clause_id or str(item.get("parent_clause_id", "") or "") == parent_clause_id)
and not _non_substantive_source_fragment(item)
]
return _dedupe_evidence(_sort_evidence_by_span(selected))[:limit]
def _select_classification_evidence(
evidence_spans: list[dict[str, Any]],
anchor: dict[str, Any],
limit: int = 10,
) -> list[dict[str, Any]]:
"""Return each source-defined class from one canonical classification article."""
scope = (anchor.get("document_id", ""), anchor.get("article_id", ""))
selected = []
for item in evidence_spans:
if (item.get("document_id", ""), item.get("article_id", "")) != scope:
continue
roles = set(item.get("semantic_roles", []) or [])
text = normalize_for_search(str(item.get("source_text", "") or ""))
if "kurul_olusumu" in roles or ("tanim" in roles and "sinif" in text):
selected.append(item)
return _dedupe_evidence(_sort_evidence_by_span(selected))[:limit]
def _select_proof_evidence(
evidence_spans: list[dict[str, Any]],
profile: dict[str, Any],
question: str,
limit: int = 5,
) -> list[dict[str, Any]]:
"""Select a coherent evidence set without question-specific article patches."""
if not evidence_spans:
return []
# Classification provisions deliberately use short structural spans such
# as ``A) Öğretim üyeleri sınıfı`` followed by their members. Those spans
# look non-substantive to the generic prose filter, but they are the legal
# structure the user explicitly asked for. Select them from the complete
# article before applying the generic filter.
normalized_question = normalize_for_search(question)
canonical_anchor = next(
(item for item in evidence_spans if _canonical_title_anchor(item)),
None,
)
if canonical_anchor and (
"siniflandir" in normalized_question or "siniflari" in normalized_question
):
classification = _select_classification_evidence(evidence_spans, canonical_anchor)
if classification:
return classification
substantive = [item for item in evidence_spans if not _non_substantive_source_fragment(item)]
if substantive:
evidence_spans = substantive
# Structural context is emitted in source order and may precede the span
# that actually won retrieval. Keep the retrieval-bearing span as the
# semantic anchor, then attach its source context below.
top = next(
(item for item in evidence_spans if not _structural_context_anchor(item)),
evidence_spans[0],
)
anchor = (top.get("document_id", ""), top.get("article_id", ""))
question_frame = extract_question_frame(question)
requested = {str(profile.get("norm_type", "") or "")}
requested.update(str(value) for value in question_frame.get("requested_categories", []) or [])
requested.discard("")
qualifier_roles = {"sart", "istisna", "sure", "tanim", "usul"}
selected = [top]
seen = {top.get("evidence_id", "")}
if _canonical_title_anchor(top):
canonical = _select_canonical_parent_evidence(evidence_spans, top)
if canonical:
return canonical
multi_part = (
str(profile.get("norm_type", "") or "") in {"gorev", "amac", "ilke", "usul"}
or len(requested) >= 2
or any(
term in normalized_question
for term in (
"nelerdir", "gorevleri", "adimlari", "asamalari", "birlikte", "nasil",
"kapsam", "istisna", "kosul", "basvuru suresi",
)
)
)
interpretation = analyze_interpretive_intent(question)
enforce_legal_object = bool(interpretation and interpretation.get("enforce_fidelity", True))
requested_mechanism = (interpretation.get("requested_mechanism", {}) or {}) if interpretation else {}
same_parent_required = requested_mechanism.get("selection_scope") == "same_parent_clause"
anchor_parent = str(top.get("parent_clause_id", "") or "")
if requested_mechanism.get("selection_scope") == "article_catalog":
catalog = _select_article_catalog_evidence(evidence_spans, requested_mechanism)
if catalog:
return catalog
if requested_mechanism.get("mechanism_id") == "body_membership_and_composition":
composition = _select_body_composition_proof(evidence_spans, top)
if composition:
return composition
# A legal article can contain appointment, delegation and duty paragraphs
# together. When the question asks for duties, keep the selected evidence
# inside the duty sub-clause instead of mixing in nearby appointment spans.
if _is_duty_question(profile, normalized_question):
duty_evidence = _select_duty_section_evidence(evidence_spans, top)
if duty_evidence:
return duty_evidence
explicit_documents = _explicit_document_ids(question)
if len(explicit_documents) >= 2:
selected = []
seen = set()
for document_id in explicit_documents:
document_candidates = [
item for item in evidence_spans
if item.get("document_id") == document_id
][:20]
candidate = max(
document_candidates,
key=lambda item: _comparative_scope_score(question, item),
default=None,
)
if candidate and candidate.get("evidence_id") not in seen:
selected.append(candidate)
seen.add(candidate.get("evidence_id"))
if selected:
anchor = (selected[0].get("document_id", ""), selected[0].get("article_id", ""))
for evidence in evidence_spans:
evidence_id = evidence.get("evidence_id", "")
if not evidence_id or evidence_id in seen:
continue
if (
same_parent_required
and anchor_parent
and str(evidence.get("parent_clause_id", "") or "") != anchor_parent
):
continue
same_scope = (evidence.get("document_id", ""), evidence.get("article_id", "")) == anchor
roles = set(evidence.get("semantic_roles", []) or []) | {str(evidence.get("norm_type", "") or "")}
channel_details = (evidence.get("_score_breakdown", {}) or {}).get("channels", {}) or {}
graph_linked = "graph_expansion" in channel_details or "sparql_graph" in channel_details
relevant_role = bool(roles & requested) or bool(roles & qualifier_roles)
structural_continuation = (
_structural_context_anchor(evidence) == str(top.get("evidence_id", "") or "")
or _structural_context_anchor(top) == str(evidence_id)
or _source_fragments_continue(top, evidence)
or _source_fragments_continue(evidence, top)
)
object_aligned = (
not enforce_legal_object
or legal_object_retrieval_factor(question, evidence, analysis=interpretation) >= 1.0
or (structural_continuation and not profile.get("strict_legal_object"))
)
if same_scope and structural_continuation and object_aligned:
selected.append(evidence)
elif same_scope and multi_part and object_aligned and (relevant_role or int(evidence.get("_channel_consensus", 0) or 0) >= 3):
selected.append(evidence)
elif same_scope and object_aligned and roles & {"sart", "istisna"} and float(evidence.get("_score", 0.0) or 0.0) >= 17.0:
selected.append(evidence)
elif graph_linked and profile.get("source_route", {}).get("cross_document") and object_aligned:
selected.append(evidence)
if evidence_id in {item.get("evidence_id") for item in selected}:
seen.add(evidence_id)
if len(selected) >= limit:
break
if (multi_part or any(_structural_context_anchor(item) for item in selected)) and len(selected) > 1 and all(
(item.get("document_id", ""), item.get("article_id", "")) == anchor
for item in selected
):
selected = _sort_evidence_by_span(_dedupe_evidence(selected))[:limit]
return selected
def _structural_context_anchor(evidence: dict[str, Any]) -> str:
return str((evidence.get("_score_breakdown", {}) or {}).get("structural_context_of", "") or "")
def _non_substantive_source_fragment(evidence: dict[str, Any]) -> bool:
"""Drop amendment headers and leaked neighboring headings from proof evidence."""
text = normalize_for_search(str(evidence.get("source_text", "") or ""))
if not text:
return True
normative_markers = (
"yetkili", "kurma", "kurul", "kapat", "birles", "degistir", "aktar",
"atan", "secil", "oden", "zorun", "gorev", "hak", "uygulan", "basvur",
"yapil", "veril", "alin", "sorumlu", "yasak", "muaf", "tabidir",
)
has_normative_marker = any(marker in text for marker in normative_markers)
if re.match(r"^(?:degisik|ek|mulga|iptal|yeniden duzenleme)\b", text) and not has_normative_marker:
return True
if re.match(r"^(?:ek |gecici )?madde\s+\w+\s+(?:ek|degisik|mulga|iptal)\b", text):
return not has_normative_marker
if len(text.split()) <= 6 and not has_normative_marker:
return True
return False
def _select_article_catalog_evidence(
evidence_spans: list[dict[str, Any]],
mechanism: dict[str, Any],
) -> list[dict[str, Any]]:
"""Select one substantive rule from each canonically titled provision.
The mechanism registry supplies concepts, not article numbers. This keeps
broad questions such as "hangi ödenekler var" portable to newly compiled
documents whose headings and article identifiers differ.
"""
title_terms = [
normalize_for_search(str(value))
for value in mechanism.get("catalog_title_terms", []) or []
if normalize_for_search(str(value))
]
preferred_terms = [
normalize_for_search(str(value))
for value in mechanism.get("catalog_preferred_terms", []) or []
if normalize_for_search(str(value))
]
limit = max(1, int(mechanism.get("catalog_limit", 6) or 6))
by_scope: dict[tuple[str, str], list[dict[str, Any]]] = {}
for item in evidence_spans:
if _non_substantive_source_fragment(item):
continue
title = normalize_for_search(str(item.get("article_title", "") or ""))
if title_terms and not any(_catalog_term_in_title(term, title) for term in title_terms):
continue
scope = (str(item.get("document_id", "") or ""), str(item.get("article_id", "") or ""))
if not all(scope):
continue
by_scope.setdefault(scope, []).append(item)
selected: list[dict[str, Any]] = []
for candidates in by_scope.values():
best = max(
candidates,
key=lambda item: (
sum(
1 for term in preferred_terms
if term in normalize_for_search(str(item.get("source_text", "") or ""))
),
"hesap" in normalize_for_search(str(item.get("source_text", "") or "")),
-int((item.get("source_span", {}) or {}).get("char_start", 10**9) or 0),
float(item.get("_score", 0.0) or 0.0),
),
)
selected.append(best)
selected.sort(
key=lambda item: (
str(item.get("document_id", "") or ""),
int((item.get("source_span", {}) or {}).get("char_start", 10**9) or 0),
)
)
return selected[:limit]
def _catalog_term_in_title(term: str, title: str) -> bool:
"""Match Turkish noun inflection without confusing it with a verb.
For example, canonical "ödenek" must match "ödeneği" (k->ğ/g) but not
the verb "ödenecek" in a title such as "ödenecek ücretler".
"""
normalized_term = normalize_for_search(term)
normalized_title = normalize_for_search(title)
if not normalized_term or not normalized_title:
return False
forms = [normalized_term]
if normalized_term.endswith("k") and len(normalized_term) >= 4:
forms.append(normalized_term[:-1] + "g")
return any(
re.search(rf"(?<![a-z0-9]){re.escape(form)}[a-z]*(?![a-z0-9])", normalized_title)
for form in forms
)
def _is_duty_question(profile: dict[str, Any], normalized_question: str) -> bool:
return (
str(profile.get("norm_type", "") or "") == "gorev"
or bool(
re.search(
r"\b(?:gorev(?:i|in|leri|lerinin)?|yetki(?:si|leri|lerinin)?|sorumluluk(?:lari|larinin)?)\b",
normalized_question,
)
)
)
def _select_body_composition_proof(
evidence_spans: list[dict[str, Any]],
anchor: dict[str, Any],
) -> list[dict[str, Any]]:
"""Select the central composition sentence of one named governing body."""
anchor_scope = (anchor.get("document_id", ""), anchor.get("article_id", ""))
scoped = [
item
for item in evidence_spans
if (item.get("document_id", ""), item.get("article_id", "")) == anchor_scope
]
composition = [item for item in scoped if _looks_like_composition_evidence(item)]
if not composition:
return []
central = max(
composition,
key=lambda item: (
"toplam" in normalize_for_search(str(item.get("source_text", "") or "")),
"kisiden olusur" in normalize_for_search(str(item.get("source_text", "") or "")),
float(item.get("_score", 0.0) or 0.0),
),
)
selected = [central]
for candidate in scoped:
if _source_fragments_continue(candidate, central):
selected.insert(0, candidate)
break
return _sort_evidence_by_span(_dedupe_evidence(selected))[:3]
def _select_duty_section_evidence(
evidence_spans: list[dict[str, Any]],
anchor: dict[str, Any],
) -> list[dict[str, Any]]:
"""Return substantive duty spans from the anchor sub-clause only."""
anchor_parent = str(anchor.get("parent_clause_id", "") or "")
anchor_scope = (anchor.get("document_id", ""), anchor.get("article_id", ""))
article_candidates = [
item for item in evidence_spans
if (item.get("document_id", ""), item.get("article_id", "")) == anchor_scope
]
candidates = [
item for item in article_candidates
if not anchor_parent or str(item.get("parent_clause_id", "") or "") == anchor_parent
]
duty = [
item for item in candidates
if "gorev" in (item.get("semantic_roles", []) or [])
and not _looks_like_non_duty_evidence(item)
and not _is_section_heading(item)
]
if not duty and _is_lettered_duty_heading(anchor):
duty = _lettered_section_duty_spans(article_candidates, anchor)
if not duty:
return []
return _dedupe_evidence(_sort_evidence_by_span(duty))[:8]
def _is_section_heading(evidence: dict[str, Any]) -> bool:
text = normalize_for_search(str(evidence.get("source_text", "") or "")).strip()
return bool(re.fullmatch(r"[a-z0-9]+ gorev yetki ve sorumluluklari", text))
def _is_lettered_duty_heading(evidence: dict[str, Any]) -> bool:
label = str(evidence.get("label", "") or "").strip().lower()
return len(label) == 1 and label.isalpha() and _is_section_heading(evidence)
def _lettered_section_duty_spans(
article_candidates: list[dict[str, Any]],
heading: dict[str, Any],
) -> list[dict[str, Any]]:
"""Collect numbered spans between a lettered duty heading and the next heading."""
start = int((heading.get("source_span", {}) or {}).get("char_start", -1) or -1)
if start < 0:
return []
next_heading_starts = [
int((item.get("source_span", {}) or {}).get("char_start", -1) or -1)
for item in article_candidates
if _is_letter_label(item.get("label"))
and int((item.get("source_span", {}) or {}).get("char_start", -1) or -1) > start
]
end = min(next_heading_starts) if next_heading_starts else float("inf")
spans = []
for item in article_candidates:
position = int((item.get("source_span", {}) or {}).get("char_start", -1) or -1)
if not (start < position < end):
continue
if not str(item.get("label", "") or "").strip().isdigit():
continue
if _looks_like_non_duty_evidence(item) or _is_section_heading(item):
continue
spans.append(item)
return spans
def _is_letter_label(value: Any) -> bool:
label = str(value or "").strip().lower()
return len(label) == 1 and label.isalpha()
def _comparative_scope_score(question: str, evidence: dict[str, Any]) -> float:
"""Select a document's scope provision for source-primacy comparisons."""
normalized_question = normalize_for_search(question)
text = normalize_for_search(str(evidence.get("source_text", "") or ""))
title = normalize_for_search(str(evidence.get("article_title", "") or ""))
anchors = {
term for term in normalized_question.split()
if len(term) >= 4 and term not in {
"sayili", "kanun", "kanunu", "hangi", "daha", "dogrudan", "kaynak",
"kabul", "edilmelidir", "hakkinda", "bilgi", "verirken",
} and not term.isdigit()
}
overlap = sum(1.0 for term in anchors if term in text or term in title)
scope = 4.0 if title in {"kapsam", "amac", "konu ve kapsam"} else 0.0
scope += 3.0 if "bu kanun" in text and any(marker in text for marker in ("kapsar", "uygulanir", "tabi")) else 0.0
retrieval = min(2.0, float(evidence.get("_score", 0.0) or 0.0) / 10.0)
return scope + overlap + retrieval
def _llm_claims_from_proof(proof: dict[str, Any], normativity_level: int) -> list[dict[str, Any]]:
prompt = llm_claim_prompt(proof, response_style_instruction(normativity_level))
try:
raw = call_chat_completion(
[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
response_format="json",
)
except Exception:
return []
return parse_and_validate_llm_claims(raw, proof)
def evidence_spans_by_ids(evidence_ids: list[str]) -> list[dict[str, Any]]:
"""Return exact corpus evidence for rule-backed multi-hop analysis."""
requested = set(evidence_ids or [])
if not requested:
return []
by_id = {
str(evidence.get("evidence_id", "")): evidence
for evidence in EVIDENCE_SPANS
if evidence.get("evidence_id")
}
return [dict(by_id[evidence_id]) for evidence_id in evidence_ids if evidence_id in by_id]
def evidence_spans_matching(selectors: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Resolve stable semantic selectors to exact corpus evidence spans."""
selected: list[dict[str, Any]] = []
seen: set[str] = set()
for selector in selectors or []:
required_terms = [normalize_for_search(str(term)) for term in selector.get("contains_all", [])]
for evidence in EVIDENCE_SPANS:
if selector.get("document_id") and evidence.get("document_id") != selector["document_id"]:
continue
if selector.get("article_id") and evidence.get("article_id") != selector["article_id"]:
continue
if selector.get("label") and str(evidence.get("label", "")) != str(selector["label"]):
continue
normalized_text = normalize_for_search(str(evidence.get("source_text", "") or ""))
if required_terms and not all(term in normalized_text for term in required_terms):
continue
evidence_id = str(evidence.get("evidence_id", "") or "")
if evidence_id and evidence_id not in seen:
selected.append(dict(evidence))
seen.add(evidence_id)
break
return selected
def _confident_enough(evidence_spans: list[dict[str, Any]]) -> bool:
if not evidence_spans:
return False
best = float(evidence_spans[0].get("_score", 0.0))
if best < MIN_EVIDENCE_SCORE:
return False
if len(evidence_spans) >= 2:
second = float(evidence_spans[1].get("_score", 0.0))
top_breakdown = evidence_spans[0].get("_score_breakdown", {}) or {}
kg_alignment = float(top_breakdown.get("kg_alignment", 0.0) or 0.0)
lexical = float(top_breakdown.get("lexical", 0.0) or 0.0)
entity_scope = float(top_breakdown.get("entity_scope", 0.0) or 0.0)
article_hint = float(top_breakdown.get("article_hint_score", 0.0) or 0.0)
if kg_alignment < 0.36 and lexical < 0.55 and entity_scope < 0.82 and article_hint <= 0:
return False
if best >= 9.25 and (
entity_scope >= 0.82
or lexical >= 0.55
or kg_alignment >= 0.58
):
return True
# If the evidence is weak and flat, do not force a wrong answer.
if best < 10.0 and (best - second) < MIN_SCORE_GAP:
return False
return True
def _select_answer_evidence(evidence_spans: list[dict[str, Any]], profile: dict[str, Any], question: str = "") -> list[dict[str, Any]]:
norm_type = profile.get("norm_type", "")
q = normalize_for_search(question)
source_route = profile.get("source_route", {}) or {}
if source_route.get("cross_document"):
selected = _select_cross_document_evidence(evidence_spans, source_route)
if selected:
return selected
if profile.get("route") == "law_purpose":
return _article_evidence("Madde 1")[:1] + _article_evidence("Madde 4")
if profile.get("route") == "institute_establishment":
return _select_institute_establishment_evidence()
if profile.get("route") == "research_application_center_establishment":
return _select_research_center_evidence()
if profile.get("interpretive_intent"):
anchor_article = _anchor_article_for_single_topic_query(evidence_spans, profile)
if not anchor_article:
anchor_article = evidence_spans[0].get("article_id", "")
scoped = [e for e in evidence_spans if e.get("article_id", "") == anchor_article]
focused = _interpretive_evidence(scoped or evidence_spans, profile)
return _dedupe_evidence(_sort_evidence_by_span(focused or scoped or evidence_spans))[:5]
if norm_type == "gorev":
top_article = evidence_spans[0].get("article_id", "")
duty = [
e for e in evidence_spans
if e.get("article_id") == top_article
and "gorev" in (e.get("semantic_roles", []) or [])
and not _looks_like_non_duty_evidence(e)
and str(e.get("label", "")) not in {"preamble", "b"}
]
return _dedupe_evidence(_sort_evidence_by_span(duty or evidence_spans))[:8]
if norm_type == "tanim":
selected = []
# Definition questions may ask both meaning and operational right, e.g. "ön lisans diploması nedir".
for evidence in evidence_spans:
frame = evidence.get("semantic_frame", {}) or {}
text = normalize_for_search(evidence.get("source_text", ""))
if evidence.get("norm_type") == "tanim" or frame.get("definition_term"):
selected.append(evidence)
break
if "diploma" in q:
for evidence in evidence_spans:
text = normalize_for_search(evidence.get("source_text", ""))
if "diploma" in text and any(term in text for term in ("alir", "alır", "almak")) and evidence not in selected:
selected.append(evidence)
break
return _dedupe_evidence(selected or evidence_spans[:2])[:3]
if norm_type == "kurul_olusumu":
# Composition questions are single-entity questions by default.
# Once the highest-ranked evidence establishes the governed institution/article,
# do not mix in composition spans from other articles merely because they also
# contain phrases such as "toplam ... kişiden oluşur". This is a general
# evidence-scoping rule, not a per-question patch.
anchor_article = _anchor_article_for_single_entity_query(evidence_spans, profile)
scoped = [e for e in evidence_spans if not anchor_article or e.get("article_id") == anchor_article]
composition = [e for e in scoped if _looks_like_composition_evidence(e)]
# A composition/count question normally asks for the composition of one named body.
# Return the best evidence span only; otherwise the answer can mix sub-bodies
# such as a council and its executive board.
return _dedupe_evidence(composition or scoped or evidence_spans)[:1]
if norm_type == "kurulus":
exact_establishment = [e for e in evidence_spans if _is_direct_establishment_evidence(e)]
if exact_establishment:
return _dedupe_evidence(exact_establishment)[:2]
same_topic = [e for e in evidence_spans if e.get("norm_type") == norm_type or norm_type in (e.get("semantic_roles", []) or [])]
return _dedupe_evidence(same_topic or evidence_spans)[:3]
if norm_type in {"ilke", "amac"}:
top_article = evidence_spans[0].get("article_id", "")
scoped = _article_evidence(top_article) or [e for e in evidence_spans if e.get("article_id", "") == top_article]
return _dedupe_evidence(_sort_evidence_by_span(scoped or evidence_spans))[:14]
if norm_type in {"atama", "sure", "sart", "istisna", "yaptirim"}:
anchor_article = _anchor_article_for_single_topic_query(evidence_spans, profile)
scoped = [e for e in evidence_spans if not anchor_article or e.get("article_id") == anchor_article]
same_topic = [e for e in scoped if e.get("norm_type") == norm_type or norm_type in (e.get("semantic_roles", []) or [])]
competitive = _competitive_evidence(same_topic or scoped or evidence_spans, max_gap=0.75)
if norm_type == "atama" and not profile.get("requires_condition"):
return _dedupe_evidence(competitive)[:1]
return _dedupe_evidence(competitive)[:2]
top_article = evidence_spans[0].get("article_id", "")
scoped = [e for e in evidence_spans if e.get("article_id", "") == top_article]
return _dedupe_evidence(scoped or evidence_spans)[:2]
def _llm_answer_from_selected_evidence(question: str, evidence_spans: list[dict[str, Any]], normativity_level=2) -> str:
context_blocks = []
document_titles = _selected_document_titles(evidence_spans)
for idx, evidence in enumerate(evidence_spans, start=1):
ref = _evidence_ref(evidence)
text = _clean_text(evidence.get("source_text", ""))
frame = evidence.get("semantic_frame", {}) or {}
frame_hint = _frame_hint(frame)
context_blocks.append(f"[Dayanak {idx}: {ref}]\n{frame_hint}\nMetin: {text}")
style_instruction = response_style_instruction(normativity_level)
profile = intent_profile(question)
profile.update(query_understanding(question))
intent_hint = _intent_hint(profile)
prompt = f"""Kullanıcı sorusu:\n{question}\n\nSeçilmiş belge kapsamı:\n{document_titles}\n\nSeçilmiş en küçük kanıt parçaları:\n{chr(10).join(context_blocks)}\n\nCevap stili:\n{style_instruction}\n\nGörev:\nTürkçe cevap ver.\n- Kullanıcı niyeti: {intent_hint}\n- Yalnızca seçilmiş belge(ler)den seçilmiş kanıt parçalarına dayan.\n- Kanıt metnini aynen kopyalama; maddeye sadık kalarak normatif ve anlaşılır biçimde sentezle.\n- Kanıt doğrudan cevap vermiyorsa mevcut MCKF corpus içinde açık hüküm bulunmadığını söyle.\n- Soru yorum, rol, anlam veya sonuç soruyorsa açık dayanaklardan makul hukuki anlam çıkar; dayanağı olmayan gerekçe veya dış bilgi ekleme.\n- Kaynakta olmayan kişi, gerekçe, istisna veya yorum uydurma.\n- Cevabın başında veya sonunda kaynak listesi yazma; kaynaklar arayüzde ayrıca gösterilecek.\n- Soru tek bir konu soruyorsa sadece o konuya cevap ver.\n- Cevap öz olsun ama soru uzun açıklama gerektiriyorsa yeterli açıklamayı ver.\n- Cevabı kesmeden tamamla, fakat gereksiz tekrar yapma.\n"""
try:
raw = call_chat_completion([
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
])
return clean_answer_text(raw, question).strip()
except Exception:
return ""
def _render_evidence_answer(
evidence_spans: list[dict[str, Any]],
norm_type: str,
normativity_level=2,
profile: dict[str, Any] | None = None,
question: str = "",
) -> str:
profile = profile or {}
if profile.get("route") == "law_purpose":
return _render_law_purpose_answer()
if profile.get("route") in {"foundational_principles", "foundational_purpose"} or norm_type in {"ilke", "amac"}:
return _render_foundational_answer(evidence_spans, norm_type)
if profile.get("route") == "institute_establishment":
return _render_institute_establishment_answer(question)
if profile.get("route") == "research_application_center_establishment":
return _render_research_center_establishment_answer()
refs = ", ".join(_unique_refs(_evidence_ref(e) for e in evidence_spans))
lead = f"Dayanak: {refs}."
if len(evidence_spans) == 1:
return f"{lead}\n\n{_clean_text(evidence_spans[0].get('source_text', ''))}"
bullets = "\n".join(f"- {_clean_text(e.get('source_text', ''))}" for e in evidence_spans)
return f"{lead}\n\n{bullets}"
def _unique_refs(refs) -> list[str]:
seen = set()
out = []
for ref in refs:
if ref in seen:
continue
seen.add(ref)
out.append(ref)
return out
def _selected_document_titles(evidence_spans: list[dict[str, Any]]) -> str:
titles = []
for evidence in evidence_spans:
title = evidence.get("document_title") or evidence.get("document_id", "")
if title and title not in titles:
titles.append(title)
return ", ".join(titles) if titles else "seçilmiş MCKF belgeleri"
def _select_institute_establishment_evidence() -> list[dict[str, Any]]:
selected = []
for evidence in _article_evidence("Madde 5"):
text = normalize_for_search(evidence.get("source_text", ""))
if "enstitu" in text and "kanunla kurulur" in text:
selected.append(evidence)
for evidence in _article_evidence("Madde 7"):
text = normalize_for_search(evidence.get("source_text", ""))
if "enstitu" in text and "acilmasi" in text:
selected.append(evidence)
elif "uygulama ve arastirma merkezi" in text and "acilmasi" in text:
selected.append(evidence)
return _dedupe_evidence(_sort_evidence_by_span(selected))[:4]
def _select_research_center_evidence() -> list[dict[str, Any]]:
selected = []
for evidence in _article_evidence("Madde 7"):
text = normalize_for_search(evidence.get("source_text", ""))
if "uygulama ve arastirma merkezi" in text and "acilmasi" in text:
selected.append(evidence)
return _dedupe_evidence(_sort_evidence_by_span(selected))[:2]
def _select_cross_document_evidence(evidence_spans: list[dict[str, Any]], source_route: dict[str, Any]) -> list[dict[str, Any]]:
candidates = set(source_route.get("candidate_document_ids", []) or [])
selected: list[dict[str, Any]] = []
for edge in source_route.get("candidate_edges", []) or []:
for document_id, article_id in (
(edge.get("source_document_id", ""), edge.get("source_article_id", "")),
(edge.get("target_document_id", ""), edge.get("target_article_id", "")),
):
for evidence in evidence_spans:
if evidence.get("document_id") == document_id and evidence.get("article_id") == article_id:
selected.append(evidence)
break
for doc_id in candidates:
if any(e.get("document_id") == doc_id for e in selected):
continue
for evidence in evidence_spans:
if evidence.get("document_id") == doc_id:
selected.append(evidence)
break
return _dedupe_evidence(selected)[:5]
def _render_foundational_answer(evidence_spans: list[dict[str, Any]], norm_type: str) -> str:
article = evidence_spans[0].get("article_id", "") if evidence_spans else ""
if article == "Madde 5" or norm_type == "ilke":
return _render_principles_answer()
if article == "Madde 4" or norm_type == "amac":
return _render_higher_education_purpose_answer()
return _render_plain_evidence_answer(evidence_spans)
def _render_law_purpose_answer() -> str:
return (
"Dayanak: Madde 1, Madde 4.\n\n"
"2547 sayılı Yükseköğretim Kanununun temel amacı iki düzeyde anlaşılır:\n\n"
"1. Kanunun düzenleme amacı: Madde 1'e göre kanun, yükseköğretimle ilgili amaç ve ilkeleri belirlemek; bütün yükseköğretim kurumları ile üst kuruluşlarının teşkilatlanmasını, işleyişini, görev, yetki ve sorumluluklarını ve eğitim-öğretim, araştırma, yayım, öğretim elemanları, öğrenciler ve diğer personelle ilgili esasları bir bütünlük içinde düzenlemek için çıkarılmıştır.\n"
"2. Yükseköğretimin amaç yönü: Madde 4'e göre yükseköğretim; öğrencilerin belirli yurttaşlık, kültür, bilimsel düşünce ve mesleki yeterliklerle yetişmesini, ülkenin ekonomik, sosyal ve kültürel kalkınmasına katkı sağlayacak programların uygulanmasını ve yükseköğretim kurumlarının bilimsel çalışma, araştırma, bilgi ve teknoloji üretimiyle ulusal ve evrensel gelişmeye katkı vermesini hedefler.\n\n"
"Kısaca, kanun yalnızca üniversite idaresini düzenleyen teknik bir metin değildir; yükseköğretimin amaçlarını, ilkelerini, kurumlarını, yetkilerini ve insan unsurunu tek bir normatif çerçevede toplar."
)
def _render_higher_education_purpose_answer() -> str:
return (
"Dayanak: Madde 4.\n\n"
"2547 sayılı Kanunda yükseköğretimin amacı üç ana eksende düzenlenir:\n\n"
"1. Öğrenci yetiştirme amacı: Öğrencilerin Atatürk inkılapları ve ilkeleri doğrultusunda, milli ve kültürel değerlere sahip, toplum yararını gözeten, insan haklarına saygılı, hür ve bilimsel düşünce gücü olan, dengeli gelişmiş ve mesleki bilgi-beceri kazanmış yurttaşlar olarak yetiştirilmesi hedeflenir.\n"
"2. Toplumsal ve kalkınmacı amaç: Türk Devletinin ülkesi ve milletiyle bölünmez bütünlüğü içinde refah ve mutluluğunu artıracak; ekonomik, sosyal ve kültürel kalkınmaya katkı sağlayacak programların uygulanması amaçlanır.\n"
"3. Bilimsel ve kurumsal amaç: Yükseköğretim kurumlarının bilimsel çalışma ve araştırma yapması, bilgi ve teknoloji üretmesi, bilim verilerini yayması, ulusal kalkınmaya destek olması, yurt içi ve yurt dışı kurumlarla işbirliği yapması ve evrensel gelişmeye katkıda bulunması öngörülür."
)
def _render_principles_answer() -> str:
return (
"Dayanak: Madde 5.\n\n"
"2547 sayılı Kanunda yükseköğretimin ana ilkeleri Madde 5'te düzenlenir. Bu ilkeler özetle şunlardır:\n\n"
"1. Atatürk inkılapları ve ilkeleri doğrultusunda hizmet bilinci kazandırılması.\n"
"2. Milli kültürün, örf ve adetlerin evrensel kültür içinde korunup geliştirilmesi; milli birlik ve beraberliği güçlendiren ruh ve iradenin kazandırılması.\n"
"3. Yükseköğretim kurumlarının özellikleri, eğitim-öğretim dalları ve amaçları gözetilerek eğitim-öğretimde birlik ilkesinin sağlanması.\n"
"4. Eğitim-öğretim plan ve programlarının bilimsel ve teknolojik esaslara, ülke ve yöre ihtiyaçlarına göre hazırlanıp sürekli geliştirilmesi.\n"
"5. Yükseköğretimde imkan ve fırsat eşitliğini sağlayacak önlemlerin alınması.\n"
"6. Üniversite, yüksek teknoloji enstitüsü ve bağlı fakülte, enstitü ve yüksekokulların yükseköğretim planlaması çerçevesinde kanunla kurulması.\n"
"7. Yükseköğretim kurumlarının geliştirilmesi, yaygınlaştırılması, kaynak ve insan gücü dağılımının milli eğitim politikası ve kalkınma planları doğrultusunda planlanması.\n\n"
"Bu nedenle Madde 5, yükseköğretimin yalnız akademik değil; kültürel, planlamacı, eşitlikçi ve kalkınmacı çerçevesini de belirler."
)
def _render_institute_establishment_answer(question: str = "") -> str:
q = normalize_for_search(question)
qualifier = "Araştırma enstitüsü ifadesi 2547 içinde ayrı ve tek başına bir kuruluş usulü olarak değil, enstitü ve araştırma/uygulama merkezi hükümleriyle birlikte okunmalıdır."
if "arastirma" not in q:
qualifier = "Enstitü kuruluşu 2547 içinde hem kanunla kuruluş hem de üniversite içinde açılma süreci bakımından düzenlenir."
return (
"Dayanak: Madde 5/f, Madde 7/2.\n\n"
f"{qualifier}\n\n"
"1. Üniversiteler, yüksek teknoloji enstitüleri ve bunların içindeki fakülte, enstitü ve yüksekokullar, Cumhurbaşkanınca yapılan yükseköğretim planlaması çerçevesinde kanunla kurulur.\n"
"2. Bir üniversite içinde enstitü açılması, birleştirilmesi veya kapatılması konusunda Yükseköğretim Kurulu doğrudan ya da üniversiteden gelen öneriye dayanarak karar alır ve gereği için Milli Eğitim Bakanlığına sunar.\n"
"3. Kast edilen birim 'uygulama ve araştırma merkezi' ise, bunların açılması, birleştirilmesi veya kapatılması da Madde 7/2 kapsamında Yükseköğretim Kurulunun karar sürecine bağlanmıştır."
)
def _render_research_center_establishment_answer() -> str:
return (
"Dayanak: Madde 7/2.\n\n"
"Uygulama ve araştırma merkezlerinin açılması, birleştirilmesi veya kapatılması konusunda Yükseköğretim Kurulu doğrudan ya da üniversiteden gelen öneri üzerine karar verir. Bu nedenle 2547 bakımından araştırma merkezi kuruluşu, YÖK karar süreciyle ilişkilidir."
)
def _render_plain_evidence_answer(evidence_spans: list[dict[str, Any]]) -> str:
refs = ", ".join(_unique_refs(_evidence_ref(e) for e in evidence_spans))
bullets = "\n".join(f"- {_clean_text(e.get('source_text', ''))}" for e in evidence_spans)
return f"Dayanak: {refs}.\n\n{bullets}"
def _numbered_evidence_lines(evidence_spans: list[dict[str, Any]], skip_labels: set[str] | None = None) -> str:
skip_labels = skip_labels or set()
lines = []
seen = set()
for evidence in evidence_spans:
label = str(evidence.get("label", "") or "")
if label in skip_labels:
continue
text = _clean_foundational_text(evidence.get("source_text", ""))
if not text:
continue
key = normalize_for_search(text)
if key in seen:
continue
seen.add(key)
lines.append(f"{len(lines) + 1}. {text}")
return "\n".join(lines)
def _clean_foundational_text(text: str) -> str:
cleaned = _clean_text(text)
cleaned = re.split(
r"\b(?:ÜÇÜNCÜ|UCUNCU)\s+BÖLÜM\b|\bÜst Kuruluşlar\b|\bYükseköğretim Kurulu:\b",
cleaned,
maxsplit=1,
flags=re.IGNORECASE,
)[0].strip()
return cleaned
def _interpretive_evidence(evidence_spans: list[dict[str, Any]], profile: dict[str, Any]) -> list[dict[str, Any]]:
preferred_roles = {"gorev", "yetki", "sart", "istisna", "yaptirim", "tanim", "kurulus", "atama"}
if profile.get("practical_effect_intent"):
preferred_roles.update({"sart", "istisna", "yaptirim"})
selected = []
for evidence in evidence_spans:
roles = set(evidence.get("semantic_roles", []) or [])
if evidence.get("norm_type") in preferred_roles or roles & preferred_roles:
selected.append(evidence)
return selected or evidence_spans
def _article_evidence(article_id: str, document_id: str = "TR-KANUN-2547") -> list[dict[str, Any]]:
if not article_id:
return []
return [
dict(evidence)
for evidence in EVIDENCE_SPANS
if evidence.get("article_id", "") == article_id
and (not document_id or evidence.get("document_id", "") == document_id)
]
def _intent_hint(profile: dict[str, Any]) -> str:
parts = []
norm_type = profile.get("norm_type")
actors = profile.get("actors") or []
if norm_type:
parts.append(f"norm_tipi={norm_type}")
if actors:
parts.append("aktor=" + ", ".join(actors))
if profile.get("interpretive_intent"):
parts.append("kaynakli_yorum")
if profile.get("practical_effect_intent"):
parts.append("pratik_sonuc")
return "; ".join(parts) if parts else "dar kaynakli cevap"
def _competitive_evidence(evidence_spans: list[dict[str, Any]], max_gap: float = 0.75) -> list[dict[str, Any]]:
"""Keep only evidence spans that are genuinely competitive with the best span.
This is used for narrow factual questions such as appointment, duration, condition
and sanction queries. It prevents low-relevance clauses with the same broad norm
label from being added to the final answer.
"""
if not evidence_spans:
return []
best = float(evidence_spans[0].get("_score", 0.0))
return [e for e in evidence_spans if best - float(e.get("_score", 0.0)) <= max_gap]
def _anchor_article_for_single_topic_query(evidence_spans: list[dict[str, Any]], profile: dict[str, Any]) -> str:
"""Choose a dominant article when the question expresses one clear topic.
This prevents answer composition from mixing unrelated provisions that share the
same generic role label such as condition, duration or sanction. The anchor is
based on the top evidence score plus lexical/norm-role support; it is not tied
to a specific article number.
"""
if not evidence_spans:
return ""
top = evidence_spans[0]
top_article = top.get("article_id", "")
if not top_article:
return ""
breakdown = top.get("_score_breakdown", {}) or {}
lexical = float(breakdown.get("lexical", 0.0))
norm = float(breakdown.get("norm_type", 0.0))
entity = float(breakdown.get("entity_scope", 0.0))
score = float(top.get("_score", 0.0))
if score >= 10.0 and (lexical >= 0.25 or norm >= 0.72 or entity >= 0.5):
return top_article
return ""
def _anchor_article_for_single_entity_query(evidence_spans: list[dict[str, Any]], profile: dict[str, Any]) -> str:
"""Choose a dominant article for queries about one governed entity.
Legal retrieval frequently finds many structurally similar spans across the law
(for example, several boards "consist of" a number of members). For a query
that names one entity, answer selection must stay inside the article that best
matches that entity unless the question type explicitly requires multiple legal
bases.
"""
if not evidence_spans:
return ""
if len(profile.get("actors") or []) != 1:
return ""
top = evidence_spans[0]
top_article = top.get("article_id", "")
if not top_article:
return ""
# Anchor only when the top evidence is reasonably strong and entity-scoped.
breakdown = top.get("_score_breakdown", {}) or {}
if float(top.get("_score", 0.0)) >= 10.0 and float(breakdown.get("entity_scope", 0.0)) >= 0.5:
return top_article
return ""
def _entity_scope_score(profile: dict[str, Any], evidence: dict[str, Any], concept: dict[str, Any], frame: dict[str, Any]) -> float:
"""Prefer evidence whose parent article/concept is about the entity asked.
This is a core MCKF disambiguation rule, not a question patch: a legal text may mention
many entities inside one provision, but the article title and concept aliases usually
identify the governed entity of that provision. Queries about an institution or role
should therefore prefer evidence scoped to that entity over evidence merely mentioning it.
"""
actors = profile.get("actors") or []
if not actors:
return 0.5
title_alias_text = normalize_for_search(" ".join([
concept.get("title", ""),
" ".join(concept.get("aliases", []) or []),
" ".join(concept.get("question_patterns", []) or []),
]))
evidence_text = normalize_for_search(evidence.get("source_text", ""))
frame_actors = {normalize_for_search(str(actor)) for actor in frame.get("actor", []) or []}
best = _concept_title_match_score(profile, concept)
for actor in actors:
actor_norm = normalize_for_search(str(actor))
if not actor_norm:
continue
if actor_norm in title_alias_text:
best = max(best, 1.0)
elif _distinctive_title_terms_match_actor(title_alias_text, actor_norm):
best = max(best, 0.9)
elif evidence_text.startswith(actor_norm) or f"{actor_norm}," in evidence_text[:120] or f"{actor_norm} " in evidence_text[:80]:
best = max(best, 0.82)
elif actor_norm in frame_actors:
best = max(best, 0.42)
elif actor_norm in evidence_text:
best = max(best, 0.28)
return best
def _distinctive_title_terms_match_actor(title_alias_text: str, actor_norm: str) -> bool:
generic_terms = {"kurul", "kurulu", "kurulunun", "yonetim", "gorevleri", "madde"}
terms = [
term for term in title_alias_text.split()
if len(term) >= 5 and term not in generic_terms and not term.isdigit()
]
if not terms:
return False
if len(terms) == 1:
return terms[0] in actor_norm
return all(term in actor_norm for term in terms)
def _concept_title_match_score(profile: dict[str, Any], concept: dict[str, Any]) -> float:
q = profile.get("normalized_question", "")
title = normalize_for_search(concept.get("title", ""))
aliases = [normalize_for_search(alias) for alias in (concept.get("aliases", []) or [])]
candidates = [candidate for candidate in [title, *aliases] if candidate and not candidate.startswith("madde ")]
if not q or not candidates:
return 0.0
meaningful_q_terms = set(q.split())
best = 0.0
for candidate in candidates:
candidate_terms = [term for term in candidate.split() if len(term) >= 4 and term not in {"madde"}]
if not candidate_terms:
continue
if candidate in q and len(candidate_terms) >= 2:
best = max(best, min(1.22, 1.0 + 0.05 * len(candidate_terms)))
elif all(term in meaningful_q_terms for term in candidate_terms):
best = max(best, 0.92)
elif len(candidate_terms) == 1 and candidate_terms[0] in meaningful_q_terms:
best = max(best, 0.78)
return best
def _norm_type_score(norm_type: str, evidence: dict[str, Any], roles: set[str], parent_clause: dict[str, Any], concept: dict[str, Any]) -> float:
concept_title = normalize_for_search(concept.get("title", ""))
if norm_type and norm_type in concept_title:
return 1.2
if norm_type and (norm_type == evidence.get("norm_type") or norm_type in roles):
return 1.0
if norm_type and (norm_type == parent_clause.get("norm_type") or norm_type in (parent_clause.get("semantic_roles", []) or [])):
return 0.72
if norm_type and norm_type in (concept.get("semantic_roles", []) or []):
return 0.55
if not norm_type:
return 0.5
return 0.0
def _lexical_score(terms: set[str], evidence_text: str, concept_text: str, article_id: str, label: str) -> float:
if not terms:
return 0.45
hits = 0.0
for term in terms:
if term in evidence_text:
hits += 1.0
elif term in concept_text:
hits += 0.65
elif term in article_id or term == label:
hits += 0.35
return min(1.0, hits / max(1.0, min(len(terms), 7)))
def _structure_score(profile: dict[str, Any], evidence: dict[str, Any]) -> float:
q = profile.get("normalized_question", "")
label = normalize_for_search(str(evidence.get("label", "")))
article_id = normalize_for_search(evidence.get("article_id", ""))
if q and article_id in q:
return 1.0
if label not in {"article", "preamble", ""}:
return 0.68
return 0.35
def _evidence_focus_score(profile: dict[str, Any], evidence_text: str, frame: dict[str, Any]) -> float:
q = profile.get("normalized_question", "")
score = 0.0
if profile.get("requires_condition") and frame.get("condition"):
score += 0.35
if profile.get("requires_exception") and frame.get("exception"):
score += 0.35
if any(term in q for term in ("kac", "kaç", "sure", "suresi", "ne kadar")) and frame.get("temporal_constraint"):
score += 0.30
if "diploma" in q and any(term in q for term in ("almak", "alma", "gerekir")):
if "diploma" in evidence_text and any(term in evidence_text for term in ("alir", "alır", "basari ile tamam", "başarı ile tamam", "tamamlamalari", "tamamlamaları")):
score += 0.45
# Penalize huge generic spans; v0.6 prefers exact evidence units.
length = len(evidence_text)
if length <= 420:
score += 0.30
elif length <= 700:
score += 0.15
return min(score, 1.0)
def _generic_penalty(profile: dict[str, Any], evidence: dict[str, Any], frame: dict[str, Any]) -> float:
penalty = 0.0
norm_type = profile.get("norm_type", "")
if norm_type and evidence.get("norm_type") and norm_type != evidence.get("norm_type"):
# Do not punish a span whose primary label differs if the requested role is
# explicitly present among its semantic roles. This avoids false negatives
# in complex clauses that encode, for example, both condition and composition.
if norm_type not in (evidence.get("semantic_roles", []) or []):
penalty += 0.08 if norm_type == "tanim" else 0.13
if profile.get("requires_condition") and not frame.get("condition"):
penalty += 0.18
if profile.get("requires_exception") and not frame.get("exception"):
penalty += 0.18
article_id = evidence.get("article_id", "")
q = profile.get("normalized_question", "")
if article_id.startswith("Geçici Madde") and not profile.get("allows_temporary_article"):
penalty += 0.22
if article_id.startswith("Ek Madde") and not any(term in q for term in ("ek madde", "vakif", "vakıf", "ozel", "özel", "ilave")):
penalty += 0.16
evidence_text = normalize_for_search(evidence.get("source_text", ""))
if "diploma" in q and any(term in q for term in ("almak", "alma", "gerekir")) and "mezun olamayan" in evidence_text:
penalty += 0.18
if len(evidence.get("source_text", "")) > 900:
penalty += 0.10
return penalty
def _concept_mismatch_penalty(q_frame: dict[str, Any], evidence_text: str, concept_text: str) -> float:
text = f"{evidence_text} {concept_text}"
penalty = 0.0
for concept in q_frame.get("related_concept", []) or []:
concept_norm = normalize_for_search(str(concept))
terms = [term for term in concept_norm.split() if len(term) >= 3]
if len(terms) < 2:
continue
hits = sum(1 for term in terms if term in text)
if hits == 0:
penalty += 0.20
elif hits < len(terms):
penalty += 0.22
return min(penalty, 0.32)
def _source_router_score(evidence: dict[str, Any], source_route: dict[str, Any]) -> float:
document_id = evidence.get("document_id", "")
scores = source_route.get("document_scores", {}) or {}
top_score = max(scores.values() or [0.0])
if not document_id or top_score <= 0:
return 0.5
return min(1.0, float(scores.get(document_id, 0.0)) / max(0.01, float(top_score)))
def _document_domain_score(evidence: dict[str, Any], source_route: dict[str, Any]) -> float:
document_id = evidence.get("document_id", "")
candidates = set(source_route.get("candidate_document_ids", []) or [])
if not candidates:
return 0.5
if document_id in candidates:
return 1.0
return 0.0
def _article_hint_score(evidence: dict[str, Any], source_route: dict[str, Any]) -> float:
document_id = evidence.get("document_id", "")
targets = (source_route.get("target_articles_by_document", {}) or {}).get(document_id) or []
if not targets:
return 0.0
return 1.0 if _matches_article_hint(evidence.get("article_id", ""), targets) else 0.0
def _wrong_document_penalty(evidence: dict[str, Any], source_route: dict[str, Any]) -> float:
if source_route.get("cross_document"):
return 0.0
candidates = set(source_route.get("candidate_document_ids", []) or [])
if not candidates or float(source_route.get("top_score", 0.0) or 0.0) < 0.3:
return 0.0
document_id = evidence.get("document_id", "")
if document_id in candidates:
return 0.0
if _is_cross_document_edge_article(document_id, evidence.get("article_id", ""), source_route):
return 0.0
return 0.22
def _cross_document_bonus(evidence: dict[str, Any], source_route: dict[str, Any]) -> float:
if not source_route.get("cross_document"):
return 0.0
if _is_cross_document_edge_article(evidence.get("document_id", ""), evidence.get("article_id", ""), source_route):
return 0.08
if evidence.get("document_id", "") in set(source_route.get("candidate_document_ids", []) or []):
return 0.03
return 0.0
def _is_cross_document_edge_article(document_id: str, article_id: str, source_route: dict[str, Any]) -> bool:
if not document_id or not article_id:
return False
for edge in source_route.get("candidate_edges", []) or []:
if document_id == edge.get("source_document_id") and article_id == edge.get("source_article_id"):
return True
if document_id == edge.get("target_document_id") and article_id == edge.get("target_article_id"):
return True
return False
def _matches_article_hint(article_id: str, target_articles: list[str]) -> bool:
article_norm = normalize_for_search(article_id)
for target in target_articles:
target_norm = normalize_for_search(target)
if article_norm == target_norm:
return True
if target_norm.startswith("ek madde "):
continue
if target_norm and not target_norm.startswith("madde ") and article_norm.startswith(target_norm):
return True
return False
def _is_direct_establishment_evidence(evidence: dict[str, Any]) -> bool:
text = normalize_for_search(evidence.get("source_text", ""))
return any(term in text for term in (
"kanunla kurulur", "karari ile kurulur", "kararı ile kurulur",
"cumhurbaskani karari ile kurulur", "cumhurbaşkanı kararı ile kurulur",
))
def _looks_like_non_duty_evidence(evidence: dict[str, Any]) -> bool:
text = normalize_for_search(evidence.get("source_text", ""))
return any(term in text for term in (
"atanir", "atanabilir", "atanmasi", "secilir", "vekalet", "yardimci",
"goreve vekalet", "göreve vekalet", "sure ile secilir",
))
def _looks_like_composition_evidence(evidence: dict[str, Any]) -> bool:
text = normalize_for_search(evidence.get("source_text", ""))
return any(term in text for term in ("toplam", "kisiden olusur", "uyeden olusur", "yedi", "yirmi bir"))
def _sort_evidence_by_span(evidence_spans: list[dict[str, Any]]) -> list[dict[str, Any]]:
return sorted(evidence_spans, key=lambda e: (e.get("source_span", {}).get("char_start", 0), -float(e.get("_score", 0.0))))
def _dedupe_evidence(evidence_spans: list[dict[str, Any]]) -> list[dict[str, Any]]:
seen = set()
result = []
for evidence in evidence_spans:
key = (evidence.get("evidence_id") or normalize_for_search(evidence.get("source_text", ""))[:160])
if key in seen:
continue
seen.add(key)
result.append(evidence)
return result
def _evidence_ref(evidence: dict[str, Any]) -> str:
label = str(evidence.get("label", "") or "").strip()
article = evidence.get("article_id", "")
document_title = evidence.get("document_title") or evidence.get("document_id", "")
prefix = f"{document_title} — " if document_title else ""
if label and label not in {"article", "preamble"}:
return f"{prefix}{article}/{label}"
return f"{prefix}{article}"
def _clean_text(text: str) -> str:
return " ".join(str(text or "").split())
def _frame_hint(frame: dict[str, Any]) -> str:
parts = []
for key in ("actor", "action", "object", "condition", "exception", "temporal_constraint", "modality"):
value = frame.get(key)
if value:
parts.append(f"{key}: {value}")
return "Semantik çerçeve: " + "; ".join(parts) if parts else "Semantik çerçeve: yok"
def _flatten_evidence_spans(clauses: list[dict[str, Any]]) -> list[dict[str, Any]]:
flattened = []
for clause in clauses:
flattened.extend(clause.get("evidence_spans", []) or [])
return flattened
def _explicit_article_references(question: str) -> list[str]:
refs: list[str] = []
for match in re.finditer(r"\bEk\s+Madde\s+(\d+)\b", question or "", flags=re.IGNORECASE):
refs.append(f"Ek Madde {match.group(1)}")
for match in re.finditer(
r"\bGe[cç]ici\s+Madde\s+(\d+(?:/[A-Za-zÇĞİÖŞÜçğıöşü])?)\b",
question or "",
flags=re.IGNORECASE,
):
ref = f"Geçici Madde {match.group(1).upper()}"
if ref not in refs:
refs.append(ref)
for match in re.finditer(r"\bMadde\s+(\d+(?:/[A-Za-zÇĞİÖŞÜçğıöşü])?)\b", question or "", flags=re.IGNORECASE):
prefix = (question or "")[max(0, match.start() - 8):match.start()].strip().lower()
if prefix in {"ek", "geçici", "gecici"}:
continue
ref = f"Madde {match.group(1).upper()}"
if ref not in refs:
refs.append(ref)
return refs