| from __future__ import annotations
|
|
|
| import json |
| import re |
| from typing import Any, Callable |
|
|
| from hermeneutic_intent import analyze_interpretive_intent, build_grounded_interpretation_options |
| from intent import detect_actors |
| from normative_metadata import semantic_address_score |
| from normative_roles import extract_question_frame |
| from query_understanding import expand_query_terms |
| from utils import normalize_for_search, topic_term_overlap |
|
|
|
|
| MARKER = "MCKF_CLARIFY_OPTIONS:" |
|
|
| |
| |
| |
| |
| ANSWER_SUPPORT_THRESHOLD = 0.68 |
| COMPETING_ARTICLE_MARGIN = 4.0 |
| COMPETING_ARTICLE_RATIO = 0.82 |
| MIN_OPTION_SCORE = 8.9 |
| MIN_OPTION_SCORE_RATIO = 0.62 |
| MIN_SEMANTIC_RELEVANCE = 0.66 |
|
|
|
|
| def needs_semantic_scope_clarification(question: str) -> bool: |
| """Detect topic-only questions that do not identify a legal relation. |
| |
| A strong retrieval hit is not enough for questions such as "X ile ilgili |
| ne var?". The user may mean a right, duty, procedure, authority, time |
| limit, exception, or sanction. Treating the top provision as the intended |
| one would turn ranking confidence into legal-intent confidence. |
| """ |
| normalized = normalize_for_search(question) |
| broad_patterns = ( |
| r"\bile ilgili (?:ne var|neler var|madde var mi|hangi maddeler)\b", |
| r"\bhakkinda (?:ne var|neler var|bilgi var mi|hangi maddeler)\b", |
| r"\bkonusunda (?:ne var|neler var|hangi maddeler)\b", |
| r"\bilgili (?:bir |herhangi bir )?(?:hukum|madde|bilgi) var mi\b", |
| ) |
| if not any(re.search(pattern, normalized) for pattern in broad_patterns): |
| return False |
|
|
| frame = extract_question_frame(question) |
| discriminative_fields = ( |
| "action", "condition", "exception", "temporal_constraint", |
| "quantitative_rule", "norm_category", "requested_categories", |
| "competent_authority", "beneficiary", "sanction", "remedy", |
| ) |
| return not any(frame.get(field) for field in discriminative_fields) |
|
|
|
|
| def build_semantic_scope_guidance(question: str) -> str: |
| """Ask for the ontology slots that materially improve legal retrieval.""" |
| topic = re.sub( |
| r"\s+(?:ile ilgili|hakkinda|konusunda).*?$", |
| "", |
| normalize_for_search(question), |
| ).strip() or "bu konu" |
| return ( |
| "Sorunuz konuyu belirtiyor, ancak hangi kurumsal sonucu aradığınızı " |
| "ayırt etmek için yeterli değil. Kesin olmayan bir maddeyi doğrudan " |
| "cevap olarak vermek istemiyorum.\n\n" |
| "Şu noktalardan birini açıklığa kavuşturabilir misiniz?\n" |
| "- Hangi kişi, kurum veya öğrenci statüsü söz konusu?\n" |
| "- Aradığınız şey bir hak/yükümlülük, yetkili makam, başvuru usulü, " |
| "süre, istisna, ücret ya da yaptırım mı?\n" |
| "- Belirli bir kanun, işlem veya yaşanmış olay var mı?\n\n" |
| f"Örneğin `{topic} öğrencilerinin kayıt yenileme şartları nelerdir?` " |
| f"veya `{topic} konusunda karar vermeye hangi makam yetkilidir?` " |
| "biçiminde sorarsanız daha güvenilir ve dar kapsamlı bir sonuç verebilirim." |
| ) |
|
|
|
|
| def resolve_clarification_selection(message: str, history: Any) -> str: |
| resolved = resolve_clarification_selection_details(message, history) |
| return resolved.get("resolved_question", "") if resolved else "" |
|
|
|
|
| def resolve_clarification_selection_details(message: str, history: Any) -> dict[str, Any]: |
| payload = _latest_payload(history) |
| selection = _selection_key(message, payload) |
| options = payload.get("options", {}) if payload else {} |
| resolved_question = options.get(selection, "") if selection else "" |
| if not resolved_question: |
| return {} |
| return { |
| "selected_key": selection, |
| "resolved_question": resolved_question, |
| "source_question": payload.get("source_question", "") if payload else "", |
| "clarification_type": payload.get("clarification_type", "") if payload else "", |
| "option_metadata": (payload.get("option_metadata", {}) or {}).get(selection, {}) if payload else {}, |
| "options": options, |
| } |
|
|
|
|
| def build_pre_retrieval_clarification(question: str) -> str: |
| """Clarify materially different legal mechanisms before retrieval. |
| |
| Options are emitted only after hermeneutic_intent verifies that their |
| grounding phrases exist in the active corpus. |
| """ |
| interpretation = build_grounded_interpretation_options(question) |
| choices = interpretation.get("options", []) if interpretation else [] |
| if not choices: |
| return "" |
| options = {str(index): item.get("resolved_question", "") for index, item in enumerate(choices, start=1)} |
| visible = {str(index): item.get("label", "") for index, item in enumerate(choices, start=1)} |
| metadata = {str(index): item.get("metadata", {}) for index, item in enumerate(choices, start=1)} |
| return _render_options( |
| str(interpretation.get("prompt", "") or ""), |
| options, |
| visible_options=visible, |
| source_question=question, |
| clarification_type=f"hermeneutic:{interpretation.get('family_id', '')}", |
| option_metadata=metadata, |
| ) |
|
|
|
|
| def build_canonical_scope_clarification(question: str, choices: list[dict[str, Any]]) -> str: |
| """Render expert-approved topic branches without choosing one for the user.""" |
| if not choices: |
| return "" |
| document_scope = all(item.get("clarification_type") == "document_scope" for item in choices) |
| options: dict[str, str] = {} |
| visible: dict[str, str] = {} |
| metadata: dict[str, dict[str, Any]] = {} |
| for index, item in enumerate(choices, start=1): |
| key = str(index) |
| document_id = str(item.get("document_id", "") or "") |
| document_code = document_id.rsplit("-", 1)[-1] |
| article_id = str(item.get("article_id", "") or "") |
| title = str(item.get("title", "") or "") |
| summary = str(item.get("summary", "") or "") |
| document_title = str(item.get("document_title", "") or document_id) |
| if document_scope: |
| options[key] = f"{question} ({document_title}, {article_id})" |
| visible[key] = " - ".join(part for part in (document_title, article_id, title) if part) |
| else: |
| options[key] = ( |
| f"{document_code} sayılı Kanun {article_id} kapsamında şu soruyu yanıtla: {question}" |
| ) |
| visible[key] = f"{document_code} {article_id} — {title}" |
| metadata[key] = { |
| "family_id": "document_scope" if document_scope else "canonical_topic_scope", |
| "definition": summary, |
| "source_scopes": [{"document_id": document_id, "article_id": article_id}], |
| } |
| prompt = ( |
| "Bu madde numarası birden fazla kaynak belgede bulunuyor. Hangi kanunu kastettiğinizi seçer misiniz?" |
| if document_scope |
| else "Bu konu yayımlanmış pakette birden fazla kurumsal boyutta düzenleniyor. " |
| "Aradığınız boyutu seçebilir veya kişi/statü, işlem/olay ve beklediğiniz sonucu açıklayabilirsiniz." |
| ) |
| return _render_options( |
| prompt, |
| options, |
| visible_options=visible, |
| source_question=question, |
| clarification_type="document_scope" if document_scope else "canonical_topic_scope", |
| option_metadata=metadata, |
| ) |
|
|
|
|
| def build_document_scope_clarification(question: str, candidates: list[dict[str, Any]] | None = None) -> str: |
| """Disambiguate an article number that exists in more than one source document.""" |
| candidates = candidates or [] |
| options = [] |
| seen = set() |
| for item in candidates: |
| channels = ((item.get("_score_breakdown", {}) or {}).get("channels", {}) or {}) |
| if "exact_reference" not in channels: |
| continue |
| document_id = str(item.get("document_id", "") or "") |
| article_id = str(item.get("article_id", "") or "") |
| key = f"{document_id}::{article_id}" |
| if not document_id or not article_id or key in seen: |
| continue |
| seen.add(key) |
| document_title = str(item.get("document_title", "") or document_id) |
| article_title = str(item.get("article_title", "") or "") |
| resolved = f"{question} ({document_title}, {article_id})" |
| label = " - ".join(part for part in (document_title, article_id, article_title) if part) |
| options.append((resolved, label)) |
| if len(options) < 2: |
| return "" |
| machine = {str(index): resolved for index, (resolved, _label) in enumerate(options[:3], start=1)} |
| visible = {str(index): label for index, (_resolved, label) in enumerate(options[:3], start=1)} |
| return _render_options( |
| "Bu madde numarası birden fazla kaynak belgede bulunuyor. Hangi kanunu kastettiğinizi seçer misiniz?", |
| machine, |
| visible_options=visible, |
| source_question=question, |
| clarification_type="document_scope", |
| ) |
|
|
|
|
| def build_low_confidence_clarification( |
| question: str, |
| candidates: list[dict[str, Any]] | None = None, |
| candidate_validator: Callable[..., bool] | None = None, |
| ) -> str: |
| domain_clarification = _domain_specific_clarification(question, candidate_validator) |
| if domain_clarification: |
| return domain_clarification |
|
|
| candidates = candidates or [] |
| distinct = [] |
| for item in _distinct_grounded_article_candidates(question, candidates, limit=3): |
| article = str(item.get("article_id", "") or "") |
| document_id = str(item.get("document_id", "") or "") |
| document_title = str(item.get("document_title", "") or document_id).strip() |
| title = str(item.get("article_title", "") or item.get("title", "") or item.get("norm_type", "") or "").strip() |
| scope = " - ".join(part for part in (document_title, article, title) if part) |
| resolved_question = f"{scope} kapsamında şu soruyu yanıtla: {question}" |
| if candidate_validator: |
| try: |
| valid = candidate_validator(resolved_question, article, document_id) |
| except TypeError: |
| valid = candidate_validator(resolved_question, article) |
| if not valid: |
| continue |
| label = f"{document_title} - {article} - {title}".strip(" -") |
| distinct.append((article, label, resolved_question)) |
|
|
| if not distinct: |
| return "" |
|
|
| options = { |
| str(index): resolved_question |
| for index, (_article, _label, resolved_question) in enumerate(distinct, start=1) |
| } |
| visible = { |
| str(index): f"{_label}" |
| for index, (_article, _label, _resolved_question) in enumerate(distinct, start=1) |
| } |
| if len(distinct) >= 2: |
| prompt = ( |
| "Sorunuz bir konu adı veriyor; ancak aradığınız kurumsal ilişkiyi veya sonucu " |
| "tek başına belirlemiyor. Kesin olmayan en üst sonucu cevap olarak vermiyorum.\n\n" |
| "Eğer merak ettiğiniz husus aşağıdaki başlıklardan biriyse, konu belirtilen " |
| "maddede düzenlenmiştir. Uygun olanı seçebilirsiniz. Hiçbiri değilse kişi/kurum " |
| "ve statüyü; aradığınız hak, yükümlülük, yetki, usul, süre, istisna veya yaptırımı; " |
| "varsa belirli kanun ya da işlemi açıklayabilir misiniz?\n\n" |
| "Daha sağlıklı soru örneği: `Açık öğretim öğrencisinin kayıt yenilememesinin sonucu " |
| "nedir?` veya `Bu işlemde karar vermeye hangi makam yetkilidir?`" |
| ) |
| else: |
| prompt = ( |
| "Sorunuzla corpus içinde koşullu olarak yakın görünen doğrulanmış düzenleme " |
| "aşağıdadır; fakat bunu doğrudan kastettiğiniz kesin değil. Eğer merak ettiğiniz " |
| "husus seçenek başlığındaki konuysa, bu konu belirtilen maddede düzenlenmiştir. " |
| "Eğer bunu kastetmediyseniz kişi/kurum ve statüyü, işlemi ve beklediğiniz kurumsal " |
| "sonucu (hak, yükümlülük, yetki, usul, süre, istisna veya yaptırım) açıklayabilir " |
| "misiniz? Örneğin `Açık öğretim öğrencisinin kayıt yenilememesinin sonucu nedir?` " |
| "gibi özne + işlem + sonuç belirten bir soru daha güvenilir sonuç verir." |
| ) |
| return _render_options( |
| prompt, |
| options,
|
| visible_options=visible, |
| source_question=question, |
| clarification_type="low_confidence", |
| )
|
|
|
|
|
| def should_request_evidence_clarification( |
| question: str, |
| clause_eval: dict[str, Any] | None, |
| allow_domain_specific: bool = True, |
| ) -> bool: |
| decision = evidence_confidence_decision( |
| question, |
| clause_eval, |
| allow_domain_specific=allow_domain_specific, |
| ) |
| return decision["action"] == "clarify" |
|
|
|
|
| def evidence_confidence_decision( |
| question: str, |
| clause_eval: dict[str, Any] | None, |
| allow_domain_specific: bool = True, |
| ) -> dict[str, Any]: |
| """Choose answer/clarify/abstain for every retrieval result. |
| |
| Retrieval confidence is evaluated at the legal-provision level. Multiple |
| evidence fragments from one article cannot hide a close competing article, |
| and a high fused score cannot cure weak semantic-address relevance. |
| """ |
| clause_eval = clause_eval or {} |
| evidence_spans = clause_eval.get("evidence_spans", []) or [] |
| proof = clause_eval.get("proof_bundle", {}) or {} |
| proof_status = str(proof.get("status", "insufficient") or "insufficient") |
| support = proof.get("support", {}) or {} |
| support_score = float(support.get("score", 0.0) or 0.0) |
| signals = support.get("signals", {}) or {} |
| q = normalize_for_search(question) |
|
|
| if not evidence_spans: |
| return _confidence_result("abstain", "no_evidence", support_score) |
|
|
| interpretation = analyze_interpretive_intent(question) |
| if ( |
| allow_domain_specific |
| and _looks_like_amnesty_query(q) |
| and bool(interpretation.get("requires_clarification")) |
| ): |
| return _confidence_result("clarify", "domain_term_requires_confirmation", support_score) |
|
|
| if proof_status != "supported": |
| return _confidence_result("clarify", "proof_not_supported", support_score) |
|
|
| top = evidence_spans[0] |
| top_breakdown = top.get("_score_breakdown", {}) or {} |
| top_channels = top_breakdown.get("channels", {}) or {} |
| if "exact_reference" in top_channels: |
| return _confidence_result("answer", "exact_reference", support_score) |
| if _has_unresolved_generic_actor(question): |
| distinct_scopes = { |
| _article_scope(item) for item in evidence_spans |
| if item.get("article_id") |
| } |
| if len(distinct_scopes) >= 2: |
| |
| |
| |
| return _confidence_result("clarify", "unresolved_generic_actor", support_score) |
| canonical_title_match = ( |
| "canonical_title" in top_channels |
| and float(top_breakdown.get("canonical_title_factor", 1.0) or 1.0) > 1.0 |
| ) |
|
|
| if _is_explicit_multi_document_question(q): |
| if support_score >= ANSWER_SUPPORT_THRESHOLD: |
| return _confidence_result("answer", "explicit_multi_document_scope", support_score) |
| return _confidence_result("clarify", "weak_multi_document_support", support_score) |
|
|
| if support_score < ANSWER_SUPPORT_THRESHOLD: |
| return _confidence_result("clarify", "support_below_answer_threshold", support_score) |
|
|
| |
| |
| |
| |
| if ( |
| signals.get("legal_object_status") == "aligned" |
| and interpretation |
| and not interpretation.get("requires_clarification") |
| and float((interpretation.get("requested_mechanism", {}) or {}).get("score", 0.0) or 0.0) >= 5.0 |
| ): |
| return _confidence_result("answer", "interpreted_object_supported", support_score) |
|
|
| grounded = _distinct_grounded_article_candidates(question, evidence_spans, limit=5) |
| if not grounded: |
| return _confidence_result("abstain", "no_semantically_grounded_article", support_score) |
| top_grounded = grounded[0] |
| top_score = float(top_grounded.get("_score", 0.0) or 0.0) |
| top_relevance = _candidate_semantic_relevance(question, top_grounded) |
| address_match = semantic_address_score(question, top_grounded.get("_semantic_address", {}) or {}) |
| article_hint = float(top_breakdown.get("article_hint_score", 0.0) or 0.0) |
| route = clause_eval.get("source_route", {}) or {} |
| route_targets = route.get("target_articles_by_document", {}) or {} |
| routed_article = any( |
| str(top_grounded.get("article_id", "") or "") in (articles or []) |
| for articles in route_targets.values() |
| ) |
|
|
| |
| |
| top_scope = _article_scope(top) |
| if top_scope != _article_scope(top_grounded): |
| return _confidence_result("clarify", "ungrounded_top_result", support_score) |
|
|
| competitors = [item for item in grounded[1:] if _article_scope(item) != _article_scope(top_grounded)] |
| close_competitor = None |
| for competitor in competitors: |
| competitor_score = float(competitor.get("_score", 0.0) or 0.0) |
| competitor_relevance = _candidate_semantic_relevance(question, competitor) |
| score_ratio = competitor_score / max(top_score, 0.0001) |
| if ( |
| competitor_relevance >= MIN_SEMANTIC_RELEVANCE |
| and ( |
| top_score - competitor_score < COMPETING_ARTICLE_MARGIN |
| or score_ratio >= COMPETING_ARTICLE_RATIO |
| ) |
| ): |
| close_competitor = competitor |
| break |
|
|
| if close_competitor: |
| return _confidence_result("clarify", "competing_grounded_articles", support_score) |
|
|
| if ( |
| top_relevance < MIN_SEMANTIC_RELEVANCE |
| and address_match < MIN_SEMANTIC_RELEVANCE |
| and article_hint <= 0 |
| and not routed_article |
| and not canonical_title_match |
| and not _has_triangulated_grounding(top_grounded, top_relevance, top_score) |
| ): |
| return _confidence_result("clarify", "weak_semantic_relevance", support_score) |
|
|
| if int(signals.get("channel_consensus", 0) or 0) < 2 and not routed_article: |
| return _confidence_result("clarify", "weak_channel_consensus", support_score) |
|
|
| return _confidence_result("answer", "calibrated_support", support_score) |
|
|
|
|
| def _domain_specific_clarification( |
| question: str, |
| candidate_validator: Callable[[str, str], bool] | None = None, |
| ) -> str: |
| q = normalize_for_search(question) |
| interpretation = analyze_interpretive_intent(question) |
| if _looks_like_amnesty_query(q) and ( |
| not interpretation or bool(interpretation.get("requires_clarification")) |
| ): |
| resolved_question = ( |
| "2547 sayili Kanun Gecici Madde 83 kapsaminda ilisigi kesilen " |
| "ogrencilerin basvuru ve yeniden ogrenime baslama haklari nasil duzenlenir?" |
| ) |
| if candidate_validator and not candidate_validator(resolved_question, "Geçici Madde 83"): |
| return "" |
| return _render_options( |
| "Genel af hakkında corpus içinde doğrudan bir hüküm yok. Aşağıdaki corpus hükmünü mü kastediyorsunuz?", |
| { |
| "1": resolved_question, |
| }, |
| visible_options={ |
| "1": "Öğrenci affı olarak bilinen geçici başvuru ve yeniden kayıt hakları (Geçici Madde 83)", |
| }, |
| source_question=question, |
| clarification_type="domain_amnesty", |
| ) |
|
|
| return "" |
|
|
|
|
| def _distinct_grounded_article_candidates( |
| question: str, |
| candidates: list[dict[str, Any]], |
| limit: int = 3, |
| ) -> list[dict[str, Any]]: |
| if not candidates: |
| return [] |
| top_score = float(candidates[0].get("_score", 0.0) or 0.0) |
| distinct = [] |
| seen = set() |
| for item in candidates[:16]: |
| scope = _article_scope(item) |
| if not all(scope) or scope in seen: |
| continue |
| if not _is_grounded_ambiguity_candidate(question, item, top_score): |
| continue |
| seen.add(scope) |
| distinct.append(item) |
| if len(distinct) >= limit: |
| break |
| return distinct |
|
|
|
|
| def _is_grounded_ambiguity_candidate(question: str, item: dict[str, Any], top_score: float) -> bool: |
| score = float(item.get("_score", 0.0) or 0.0) |
| if score < MIN_OPTION_SCORE or score / max(top_score, 0.0001) < MIN_OPTION_SCORE_RATIO: |
| return False |
| source_text = str(item.get("source_text", "") or "").strip() |
| if len(source_text) < 30 or _has_unmentioned_named_university(question, item): |
| return False |
| breakdown = item.get("_score_breakdown", {}) or {} |
| channels = breakdown.get("channels", {}) or {} |
| consensus = int(breakdown.get("channel_consensus", len(channels)) or 0) |
| if consensus < 2: |
| return False |
|
|
| title = " ".join( |
| str(item.get(key, "") or "") for key in ("article_title", "title", "article_id") |
| ) |
| expanded_question = " ".join([question, *sorted(expand_query_terms(question))]) |
| title_overlap = max( |
| topic_term_overlap(question, [title]), |
| topic_term_overlap(expanded_question, [title]), |
| ) |
| address_match = semantic_address_score(question, item.get("_semantic_address", {}) or {}) |
| relevance = _candidate_semantic_relevance(question, item) |
| |
| |
| |
| |
| unresolved_generic_actor = _has_unresolved_generic_actor(question) |
| if ( |
| unresolved_generic_actor |
| and "gorev" in normalize_for_search(question) |
| and "gorev" not in normalize_for_search(title) |
| ): |
| |
| |
| |
| return False |
| triangulated_retrieval = _has_triangulated_grounding(item, relevance, top_score) |
| routed_hint_grounding = ( |
| consensus >= 4 |
| and (title_overlap >= 0.25 or relevance >= 0.35) |
| and "router_hint" in channels |
| and "concept_focus" in channels |
| ) |
| canonical_title_grounding = ( |
| score >= top_score * 0.95 |
| and "canonical_title" in channels |
| and float(breakdown.get("canonical_title_factor", 1.0) or 1.0) > 1.0 |
| ) |
| return ( |
| title_overlap >= MIN_SEMANTIC_RELEVANCE |
| or (address_match >= 0.70 and relevance >= 0.70 and not unresolved_generic_actor) |
| or (triangulated_retrieval and not unresolved_generic_actor) |
| or routed_hint_grounding |
| or canonical_title_grounding |
| ) |
|
|
|
|
| def _candidate_semantic_relevance(question: str, item: dict[str, Any]) -> float: |
| address = item.get("_semantic_address", {}) or {} |
| expanded_question = " ".join([question, *sorted(expand_query_terms(question))]) |
| evidence_texts = [ |
| str(item.get("source_text", "") or ""), |
| str(item.get("article_title", "") or ""), |
| str(address.get("canonical_address", "") or ""), |
| str(address.get("regulates", "") or ""), |
| " ".join(str(value) for value in address.get("domain_path", []) or []), |
| " ".join(str(value) for value in address.get("canonical_concepts", []) or []), |
| " ".join(str(value) for value in address.get("query_aliases", []) or []), |
| ] |
| return max( |
| topic_term_overlap(question, evidence_texts), |
| topic_term_overlap(expanded_question, evidence_texts), |
| semantic_address_score(question, address), |
| semantic_address_score(expanded_question, address), |
| ) |
|
|
|
|
| def _has_triangulated_grounding( |
| item: dict[str, Any], |
| relevance: float, |
| top_score: float, |
| ) -> bool: |
| score = float(item.get("_score", 0.0) or 0.0) |
| breakdown = item.get("_score_breakdown", {}) or {} |
| channels = breakdown.get("channels", {}) or {} |
| consensus = int(breakdown.get("channel_consensus", len(channels)) or 0) |
| return ( |
| score >= top_score * 0.95 |
| and consensus >= 4 |
| and relevance >= 0.35 |
| and "bm25" in channels |
| and "semantic_frame" in channels |
| and any(channel in channels for channel in ("lsa_dense", "embedding_dense")) |
| ) |
|
|
|
|
| def _has_unresolved_generic_actor(question: str) -> bool: |
| normalized = normalize_for_search(question) |
| tokens = set(normalized.split()) |
| mentions_generic_body = bool(tokens & {"kurul", "kurulu", "kurulun", "kurulunun"}) |
| return mentions_generic_body and not detect_actors(question) |
|
|
|
|
| def _has_unmentioned_named_university(question: str, item: dict[str, Any]) -> bool: |
| """Reject one-off university establishment articles unless named by user.""" |
| title = normalize_for_search(str(item.get("article_title", "") or "")) |
| if not re.search(r"\buniversitesi\b", title): |
| return False |
| name_tokens = [ |
| token for token in title.split() |
| if token not in {"universitesi", "universite", "vakif", "devlet"} and len(token) >= 3 |
| ] |
| if not name_tokens: |
| return False |
| q = normalize_for_search(question) |
| return not any(token in q for token in name_tokens) |
|
|
|
|
| def _article_scope(item: dict[str, Any]) -> tuple[str, str]: |
| return ( |
| str(item.get("document_id", "") or ""), |
| str(item.get("article_id", "") or ""), |
| ) |
|
|
|
|
| def _is_explicit_multi_document_question(normalized_question: str) -> bool: |
| document_codes = set(re.findall(r"\b(?:2547|2914|2809)\b", normalized_question)) |
| if len(document_codes) < 2: |
| return False |
| return any( |
| marker in normalized_question |
| for marker in ("hangisi", "daha dogrudan", "birlikte", "karsilastir", "arasinda") |
| ) |
|
|
|
|
| def _confidence_result(action: str, reason: str, support_score: float) -> dict[str, Any]: |
| return { |
| "action": action, |
| "reason": reason, |
| "support_score": round(support_score, 4), |
| } |
|
|
|
|
| def _text_anchor_overlap(question: str, text: str) -> float: |
| anchors = _anchor_tokens(question) |
| if not anchors: |
| return 0.0 |
| normalized = normalize_for_search(text) |
| hits = sum(1 for anchor in anchors if anchor in normalized) |
| return hits / len(anchors) |
|
|
|
|
| def _looks_like_amnesty_query(q: str) -> bool: |
| if "genel af" in q or "ogrenci aff" in q: |
| return True |
| return bool(re.search(r"\baf\b|\baflar\b|\baffi\b|\baftan\b", q)) |
|
|
|
|
| def _looks_like_exam_rights_query(q: str) -> bool: |
| has_exam = "sinav" in q |
| has_right = any(term in q for term in ("hak", "hakki", "haklari")) |
| has_three = bool(re.search(r"\b3\b|\buc\b", q)) |
| return has_exam and has_right and has_three |
|
|
|
|
| def _query_anchor_overlap(question: str, evidence: dict[str, Any]) -> float: |
| query_tokens = _anchor_tokens(question) |
| if not query_tokens: |
| return 0.5 |
| evidence_text = " ".join( |
| str(value or "") |
| for value in ( |
| evidence.get("source_text", ""), |
| evidence.get("article_id", ""), |
| evidence.get("article_title", ""), |
| evidence.get("title", ""), |
| " ".join(evidence.get("semantic_roles", []) or []), |
| ) |
| ) |
| evidence_norm = normalize_for_search(evidence_text) |
| hits = sum(1 for token in query_tokens if token in evidence_norm) |
| return hits / max(1, len(query_tokens)) |
|
|
|
|
| def _anchor_tokens(text: str) -> list[str]: |
| stopwords = { |
| "2547", "2914", "2809", "sayili", "kanun", "kanunu", "kanunda", "kanununda", |
| "nedir", "nelerdir", "nasil", "hangi", "hakkinda", "bilgi", "ver", "verir", |
| "gore", "ile", "icin", "olan", "bir", "bu", "su", "mi", "mu", "midir", |
| } |
| tokens = [] |
| for token in normalize_for_search(text).split(): |
| if len(token) < 2 or token in stopwords: |
| continue |
| tokens.append(token) |
| return tokens[:8] |
|
|
|
|
| def _render_options( |
| prompt: str, |
| options: dict[str, str], |
| visible_options: dict[str, str] | None = None, |
| source_question: str = "", |
| clarification_type: str = "", |
| option_metadata: dict[str, dict[str, Any]] | None = None, |
| ) -> str: |
| visible_options = visible_options or options |
| option_metadata = option_metadata or {} |
| lines = [prompt, ""] |
| for key in sorted(options, key=int): |
| lines.append(f"{key}. {visible_options[key]}") |
| payload = { |
| "prompt": prompt, |
| "options": options, |
| "visible_options": visible_options, |
| "source_question": source_question, |
| "clarification_type": clarification_type, |
| "option_metadata": option_metadata, |
| } |
| lines.extend([ |
| "", |
| "Devam etmek için uygun seçeneğe tıklayın.", |
| f"<!-- {MARKER}{json.dumps(payload, ensure_ascii=False)} -->", |
| ]) |
| return "\n".join(lines) |
|
|
|
|
| def _selection_number(message: str) -> str: |
| text = normalize_for_search(message)
|
| match = re.search(r"\b([1-3])\b", text)
|
| return match.group(1) if match else "" |
|
|
|
|
| def _selection_key(message: str, payload: dict[str, Any]) -> str: |
| selection = _selection_number(message) |
| if selection: |
| return selection |
| selected_label = normalize_for_search(message) |
| for key, label in (payload.get("visible_options", {}) or {}).items(): |
| if selected_label == normalize_for_search(str(label)): |
| return str(key) |
| if payload.get("clarification_type") == "document_scope": |
| document_codes = set(re.findall(r"\b(?:2547|2809|2914)\b", selected_label)) |
| if len(document_codes) == 1: |
| document_code = next(iter(document_codes)) |
| matching_keys = [ |
| str(key) |
| for key, label in (payload.get("visible_options", {}) or {}).items() |
| if re.search(rf"\b{re.escape(document_code)}\b", normalize_for_search(str(label))) |
| ] |
| if len(matching_keys) == 1: |
| return matching_keys[0] |
| |
| |
| |
| if any(term in selected_label for term in ("kast", "demek ist", "bahsed", "olan")): |
| scored = [] |
| metadata = payload.get("option_metadata", {}) or {} |
| for key, label in (payload.get("visible_options", {}) or {}).items(): |
| detail = metadata.get(str(key), {}) or {} |
| candidate = " ".join((str(label), str(detail.get("definition", "")))) |
| scored.append((topic_term_overlap(message, [candidate]), str(key))) |
| scored.sort(reverse=True) |
| if scored and scored[0][0] >= 0.3 and (len(scored) == 1 or scored[0][0] - scored[1][0] >= 0.15): |
| return scored[0][1] |
| return "" |
|
|
|
|
| def _latest_options(history: Any) -> dict[str, str]: |
| payload = _latest_payload(history) |
| return payload.get("options", {}) if payload else {} |
|
|
|
|
| def _latest_payload(history: Any) -> dict[str, Any]: |
| for content in _assistant_messages_reversed(history): |
| marker_index = content.rfind(MARKER) |
| if marker_index < 0: |
| continue |
| payload = content[marker_index + len(MARKER):]
|
| payload = payload.split("-->", 1)[0].strip()
|
| try:
|
| data = json.loads(payload) |
| except json.JSONDecodeError: |
| continue |
| if isinstance(data, dict): |
| if "options" in data and isinstance(data.get("options"), dict): |
| data["options"] = {str(key): str(value) for key, value in data.get("options", {}).items()} |
| data["visible_options"] = { |
| str(key): str(value) for key, value in data.get("visible_options", {}).items() |
| } |
| data["option_metadata"] = { |
| str(key): value for key, value in (data.get("option_metadata", {}) or {}).items() |
| if isinstance(value, dict) |
| } |
| return data |
| return { |
| "options": {str(key): str(value) for key, value in data.items()}, |
| "source_question": "", |
| "clarification_type": "", |
| } |
| return {} |
|
|
|
|
| def clarification_payload(content: str) -> dict[str, Any]: |
| """Return the embedded option payload used by the Gradio renderer.""" |
| return _payload_from_content(content) |
|
|
|
|
| def clarification_display_text(content: str) -> str: |
| """Remove the machine payload before content reaches the Chatbot DOM.""" |
| payload = _payload_from_content(content) |
| if not payload: |
| return content |
| prompt = str(payload.get("prompt", "") or "").strip() |
| return f"{prompt}\n\nDevam etmek için aşağıdaki seçeneklerden birine tıklayın." |
|
|
|
|
| def clarification_history_text(content: str) -> str: |
| """Keep clarification state inside this chat's assistant history. |
| |
| The HTML comment remains invisible in the rendered chatbot, while Gradio |
| returns the original content on the next turn. Option resolution is thus |
| session-scoped rather than stored in a process-global registry. |
| """ |
| payload = _payload_from_content(content) |
| if not payload: |
| return content |
| display = clarification_display_text(content) |
| return f"{display}\n\n<!-- {MARKER}{json.dumps(payload, ensure_ascii=False)} -->" |
|
|
|
|
| def _payload_from_content(content: str) -> dict[str, Any]: |
| marker_index = (content or "").rfind(MARKER) |
| if marker_index < 0: |
| return {} |
| raw_payload = content[marker_index + len(MARKER):].split("-->", 1)[0].strip() |
| try: |
| data = json.loads(raw_payload) |
| except json.JSONDecodeError: |
| return {} |
| return data if isinstance(data, dict) else {} |
|
|
|
|
| def _assistant_messages_reversed(history: Any):
|
| if not history:
|
| return
|
| for item in reversed(history):
|
| if isinstance(item, dict) and item.get("role") == "assistant":
|
| yield str(item.get("content", "") or "")
|
| elif isinstance(item, (list, tuple)) and len(item) >= 2:
|
| yield str(item[1] or "")
|
|
|