| 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 {} |
| |
| |
| |
| |
| 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"):
|
|
|
|
|
| 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")) |
| ): |
|
|
|
|
|
|
| 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) |
| |
| |
| |
| 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()) |
| |
| |
| |
| 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: |
| |
| |
| 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
|
|
|
|
|
| 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) |
| |
| |
| 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 [] |
| |
| |
| |
| |
| |
| 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 |
| |
| |
| |
| 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 |
|
|
|
|
|
|
|
|
| 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 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 = []
|
|
|
| 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":
|
|
|
|
|
|
|
|
|
|
|
| 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)]
|
|
|
|
|
|
|
| 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 ""
|
|
|
| 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
|
|
|
|
|
| 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"):
|
|
|
|
|
|
|
| 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
|
|
|