This commit is contained in:
saidsurucu
2025-05-21 20:33:43 +03:00
parent 2935c686ea
commit 672337f9d4
19 changed files with 2439 additions and 0 deletions
+3
View File
@@ -158,3 +158,6 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
.DS_Store
hello.py
*.toml
View File
+356
View File
@@ -0,0 +1,356 @@
# anayasa_mcp_module/bireysel_client.py
# This client is for Bireysel Başvuru: https://kararlarbilgibankasi.anayasa.gov.tr
import httpx
from bs4 import BeautifulSoup, Tag
from typing import Dict, Any, List, Optional, Tuple
import logging
import html
import re
import tempfile
import os
from urllib.parse import urlencode, urljoin, quote
from markitdown import MarkItDown
import math # For math.ceil for pagination
from .models import (
AnayasaBireyselReportSearchRequest,
AnayasaBireyselReportDecisionDetail,
AnayasaBireyselReportDecisionSummary,
AnayasaBireyselReportSearchResult,
AnayasaBireyselBasvuruDocumentMarkdown, # Model for Bireysel Başvuru document
)
logger = logging.getLogger(__name__)
if not logger.hasHandlers():
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
class AnayasaBireyselBasvuruApiClient:
BASE_URL = "https://kararlarbilgibankasi.anayasa.gov.tr"
SEARCH_PATH = "/Ara"
DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000 # Character limit per page
def __init__(self, request_timeout: float = 60.0):
self.http_client = httpx.AsyncClient(
base_url=self.BASE_URL,
headers={
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
},
timeout=request_timeout,
verify=True,
follow_redirects=True
)
def _build_query_params_for_bireysel_report(self, params: AnayasaBireyselReportSearchRequest) -> List[Tuple[str, str]]:
query_params: List[Tuple[str, str]] = []
query_params.append(("KararBulteni", "1")) # Specific to this report type
if params.keywords:
for kw in params.keywords:
query_params.append(("KelimeAra[]", kw))
if params.page_to_fetch and params.page_to_fetch > 1:
query_params.append(("page", str(params.page_to_fetch)))
return query_params
async def search_bireysel_basvuru_report(
self,
params: AnayasaBireyselReportSearchRequest
) -> AnayasaBireyselReportSearchResult:
final_query_params = self._build_query_params_for_bireysel_report(params)
request_url = self.SEARCH_PATH
logger.info(f"AnayasaBireyselBasvuruApiClient: Performing Bireysel Başvuru Report search. Path: {request_url}, Params: {final_query_params}")
try:
response = await self.http_client.get(request_url, params=final_query_params)
response.raise_for_status()
html_content = response.text
except httpx.RequestError as e:
logger.error(f"AnayasaBireyselBasvuruApiClient: HTTP request error during Bireysel Başvuru Report search: {e}")
raise
except Exception as e:
logger.error(f"AnayasaBireyselBasvuruApiClient: Error processing Bireysel Başvuru Report search request: {e}")
raise
soup = BeautifulSoup(html_content, 'html.parser')
total_records = None
bulunan_karar_div = soup.find("div", class_="bulunankararsayisi")
if bulunan_karar_div:
match_records = re.search(r'(\d+)\s*Karar Bulundu', bulunan_karar_div.get_text(strip=True))
if match_records:
total_records = int(match_records.group(1))
processed_decisions: List[AnayasaBireyselReportDecisionSummary] = []
report_content_area = soup.find("div", class_="HaberBulteni")
if not report_content_area:
logger.warning("HaberBulteni div not found, attempting to parse decision divs from the whole page.")
report_content_area = soup
decision_divs = report_content_area.find_all("div", class_="KararBulteniBirKarar")
if not decision_divs:
logger.warning("No KararBulteniBirKarar divs found.")
for decision_div in decision_divs:
title_tag = decision_div.find("h4")
title_text = title_tag.get_text(strip=True) if title_tag and title_tag.strong else (title_tag.get_text(strip=True) if title_tag else None)
alti_cizili_div = decision_div.find("div", class_="AltiCizili")
ref_no, dec_type, body, app_date, dec_date, url_path = None, None, None, None, None, None
if alti_cizili_div:
link_tag = alti_cizili_div.find("a", href=True)
if link_tag:
ref_no = link_tag.get_text(strip=True)
url_path = link_tag['href']
parts_text = alti_cizili_div.get_text(separator="|", strip=True)
parts = [part.strip() for part in parts_text.split("|")]
# Clean ref_no from the first part if it was extracted from link
if ref_no and parts and parts[0].strip().startswith(ref_no):
parts[0] = parts[0].replace(ref_no, "").strip()
if not parts[0]: parts.pop(0) # Remove empty string if ref_no was the only content
# Assign parts based on typical order, adjusting for missing ref_no at start
current_idx = 0
if not ref_no and len(parts) > current_idx and re.match(r"\d+/\d+", parts[current_idx]): # Check if first part is ref_no
ref_no = parts[current_idx]
current_idx += 1
dec_type = parts[current_idx] if len(parts) > current_idx else None
current_idx += 1
body = parts[current_idx] if len(parts) > current_idx else None
current_idx += 1
app_date_raw = parts[current_idx] if len(parts) > current_idx else None
current_idx += 1
dec_date_raw = parts[current_idx] if len(parts) > current_idx else None
if app_date_raw and "Başvuru Tarihi :" in app_date_raw:
app_date = app_date_raw.replace("Başvuru Tarihi :", "").strip()
elif app_date_raw: # If label is missing but format matches
app_date_match = re.search(r'(\d{1,2}/\d{1,2}/\d{4})', app_date_raw)
if app_date_match: app_date = app_date_match.group(1)
if dec_date_raw and "Karar Tarihi :" in dec_date_raw:
dec_date = dec_date_raw.replace("Karar Tarihi :", "").strip()
elif dec_date_raw: # If label is missing but format matches
dec_date_match = re.search(r'(\d{1,2}/\d{1,2}/\d{4})', dec_date_raw)
if dec_date_match: dec_date = dec_date_match.group(1)
subject_div = decision_div.find(lambda tag: tag.name == 'div' and not tag.has_attr('class') and tag.get_text(strip=True).startswith("BAŞVURU KONUSU :"))
subject_text = subject_div.get_text(strip=True).replace("BAŞVURU KONUSU :", "").strip() if subject_div else None
details_list: List[AnayasaBireyselReportDecisionDetail] = []
karar_detaylari_div = decision_div.find_next_sibling("div", id="KararDetaylari") # Corrected: was KararDetaylari
if karar_detaylari_div:
table = karar_detaylari_div.find("table", class_="table")
if table and table.find("tbody"):
for row in table.find("tbody").find_all("tr"):
cells = row.find_all("td")
if len(cells) == 4: # Hak, Müdahale İddiası, Sonuç, Giderim
details_list.append(AnayasaBireyselReportDecisionDetail(
hak=cells[0].get_text(strip=True) or None,
mudahale_iddiasi=cells[1].get_text(strip=True) or None,
sonuc=cells[2].get_text(strip=True) or None,
giderim=cells[3].get_text(strip=True) or None,
))
full_decision_page_url = urljoin(self.BASE_URL, url_path) if url_path else None
processed_decisions.append(AnayasaBireyselReportDecisionSummary(
title=title_text,
decision_reference_no=ref_no,
decision_page_url=full_decision_page_url,
decision_type_summary=dec_type,
decision_making_body=body,
application_date_summary=app_date,
decision_date_summary=dec_date,
application_subject_summary=subject_text,
details=details_list
))
return AnayasaBireyselReportSearchResult(
decisions=processed_decisions,
total_records_found=total_records,
retrieved_page_number=params.page_to_fetch
)
def _convert_html_to_markdown_bireysel(self, full_decision_html_content: str) -> Optional[str]:
if not full_decision_html_content:
return None
processed_html = html.unescape(full_decision_html_content)
soup = BeautifulSoup(processed_html, "html.parser")
html_input_for_markdown = ""
karar_tab_content = soup.find("div", id="Karar")
if karar_tab_content:
karar_html_span = karar_tab_content.find("span", class_="kararHtml")
if karar_html_span:
word_section = karar_html_span.find("div", class_="WordSection1")
if word_section:
for s in word_section.select('script, style, .item.col-xs-12.col-sm-12, center:has(b)'):
s.decompose()
html_input_for_markdown = str(word_section)
else:
logger.warning("AnayasaBireyselBasvuruApiClient: WordSection1 not found in span.kararHtml. Using span.kararHtml content.")
for s in karar_html_span.select('script, style, .item.col-xs-12.col-sm-12, center:has(b)'):
s.decompose()
html_input_for_markdown = str(karar_html_span)
else:
logger.warning("AnayasaBireyselBasvuruApiClient: span.kararHtml not found in div#Karar. Using div#Karar content.")
for s in karar_tab_content.select('script, style, .item.col-xs-12.col-sm-12, center:has(b)'):
s.decompose()
html_input_for_markdown = str(karar_tab_content)
else:
logger.warning("AnayasaBireyselBasvuruApiClient: div#Karar (KARAR tab) not found. Trying WordSection1 fallback.")
word_section_fallback = soup.find("div", class_="WordSection1")
if word_section_fallback:
for s in word_section_fallback.select('script, style, .item.col-xs-12.col-sm-12, center:has(b)'):
s.decompose()
html_input_for_markdown = str(word_section_fallback)
else:
body_tag = soup.find("body")
if body_tag:
for s in body_tag.select('script, style, .item.col-xs-12.col-sm-12, center:has(b), .banner, .footer, .yazdirmaalani, .filtreler, .menu, .altmenu, .geri, .arabuton, .temizlebutonu, form#KararGetir, .TabBaslik, #KararDetaylari, .share-button-container'):
s.decompose()
html_input_for_markdown = str(body_tag)
else:
html_input_for_markdown = processed_html
markdown_text = None
temp_file_path = None
try:
md_converter = MarkItDown(enable_plugins=False)
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as tmp_file:
if not html_input_for_markdown.strip().lower().startswith(("<html", "<!doctype")):
tmp_file.write(f"<html><head><meta charset=\"UTF-8\"></head><body>{html_input_for_markdown}</body></html>")
else:
tmp_file.write(html_input_for_markdown)
temp_file_path = tmp_file.name
conversion_result = md_converter.convert(temp_file_path)
markdown_text = conversion_result.text_content
except Exception as e:
logger.error(f"AnayasaBireyselBasvuruApiClient: MarkItDown conversion error: {e}")
finally:
if temp_file_path and os.path.exists(temp_file_path):
os.remove(temp_file_path)
return markdown_text
async def get_decision_document_as_markdown(
self,
document_url_path: str, # e.g. /BB/2021/20295
page_number: int = 1
) -> AnayasaBireyselBasvuruDocumentMarkdown:
full_url = urljoin(self.BASE_URL, document_url_path)
logger.info(f"AnayasaBireyselBasvuruApiClient: Fetching Bireysel Başvuru document for Markdown (page {page_number}) from URL: {full_url}")
basvuru_no_from_page = None
karar_tarihi_from_page = None
basvuru_tarihi_from_page = None
karari_veren_birim_from_page = None
karar_turu_from_page = None
resmi_gazete_info_from_page = None
try:
response = await self.http_client.get(full_url)
response.raise_for_status()
html_content_from_api = response.text
if not isinstance(html_content_from_api, str) or not html_content_from_api.strip():
logger.warning(f"AnayasaBireyselBasvuruApiClient: Received empty HTML from {full_url}.")
return AnayasaBireyselBasvuruDocumentMarkdown(
source_url=full_url, markdown_chunk=None, current_page=page_number, total_pages=0, is_paginated=False
)
soup = BeautifulSoup(html_content_from_api, 'html.parser')
meta_desc_tag = soup.find("meta", attrs={"name": "description"})
if meta_desc_tag and meta_desc_tag.get("content"):
content = meta_desc_tag["content"]
bn_match = re.search(r"B\.\s*No:\s*([\d\/]+)", content)
if bn_match: basvuru_no_from_page = bn_match.group(1).strip()
date_match = re.search(r"(\d{1,2}\/\d{1,2}\/\d{4}),\s*§", content)
if date_match: karar_tarihi_from_page = date_match.group(1).strip()
karar_detaylari_tab = soup.find("div", id="KararDetaylari")
if karar_detaylari_tab:
table = karar_detaylari_tab.find("table", class_="table")
if table:
rows = table.find_all("tr")
for row in rows:
cells = row.find_all("td")
if len(cells) == 2:
key = cells[0].get_text(strip=True)
value = cells[1].get_text(strip=True)
if "Kararı Veren Birim" in key: karari_veren_birim_from_page = value
elif "Karar Türü (Başvuru Sonucu)" in key: karar_turu_from_page = value
elif "Başvuru No" in key and not basvuru_no_from_page: basvuru_no_from_page = value
elif "Başvuru Tarihi" in key: basvuru_tarihi_from_page = value
elif "Karar Tarihi" in key and not karar_tarihi_from_page: karar_tarihi_from_page = value
elif "Resmi Gazete Tarih / Sayı" in key: resmi_gazete_info_from_page = value
full_markdown_content = self._convert_html_to_markdown_bireysel(html_content_from_api)
if not full_markdown_content:
return AnayasaBireyselBasvuruDocumentMarkdown(
source_url=full_url,
basvuru_no_from_page=basvuru_no_from_page,
karar_tarihi_from_page=karar_tarihi_from_page,
basvuru_tarihi_from_page=basvuru_tarihi_from_page,
karari_veren_birim_from_page=karari_veren_birim_from_page,
karar_turu_from_page=karar_turu_from_page,
resmi_gazete_info_from_page=resmi_gazete_info_from_page,
markdown_chunk=None,
current_page=page_number,
total_pages=0,
is_paginated=False
)
content_length = len(full_markdown_content)
total_pages = math.ceil(content_length / self.DOCUMENT_MARKDOWN_CHUNK_SIZE)
if total_pages == 0: total_pages = 1
current_page_clamped = max(1, min(page_number, total_pages))
start_index = (current_page_clamped - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE
end_index = start_index + self.DOCUMENT_MARKDOWN_CHUNK_SIZE
markdown_chunk = full_markdown_content[start_index:end_index]
return AnayasaBireyselBasvuruDocumentMarkdown(
source_url=full_url,
basvuru_no_from_page=basvuru_no_from_page,
karar_tarihi_from_page=karar_tarihi_from_page,
basvuru_tarihi_from_page=basvuru_tarihi_from_page,
karari_veren_birim_from_page=karari_veren_birim_from_page,
karar_turu_from_page=karar_turu_from_page,
resmi_gazete_info_from_page=resmi_gazete_info_from_page,
markdown_chunk=markdown_chunk,
current_page=current_page_clamped,
total_pages=total_pages,
is_paginated=(total_pages > 1)
)
except httpx.RequestError as e:
logger.error(f"AnayasaBireyselBasvuruApiClient: HTTP error fetching Bireysel Başvuru document from {full_url}: {e}")
raise
except Exception as e:
logger.error(f"AnayasaBireyselBasvuruApiClient: General error processing Bireysel Başvuru document from {full_url}: {e}")
raise
async def close_client_session(self):
if hasattr(self, 'http_client') and self.http_client and not self.http_client.is_closed:
await self.http_client.aclose()
logger.info("AnayasaBireyselBasvuruApiClient: HTTP client session closed.")
+354
View File
@@ -0,0 +1,354 @@
# anayasa_mcp_module/client.py
# This client is for Norm Denetimi: https://normkararlarbilgibankasi.anayasa.gov.tr
import httpx
from bs4 import BeautifulSoup
from typing import Dict, Any, List, Optional, Tuple
import logging
import html
import re
import tempfile
import os
from urllib.parse import urlencode, urljoin, quote
from markitdown import MarkItDown
import math # For math.ceil for pagination
from .models import (
AnayasaNormDenetimiSearchRequest,
AnayasaDecisionSummary,
AnayasaReviewedNormInfo,
AnayasaSearchResult,
AnayasaDocumentMarkdown, # Model for Norm Denetimi document
)
logger = logging.getLogger(__name__)
if not logger.hasHandlers():
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
class AnayasaMahkemesiApiClient:
BASE_URL = "https://normkararlarbilgibankasi.anayasa.gov.tr"
SEARCH_PATH_SEGMENT = "Ara"
DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000 # Character limit per page
def __init__(self, request_timeout: float = 60.0):
self.http_client = httpx.AsyncClient(
base_url=self.BASE_URL,
headers={
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
},
timeout=request_timeout,
verify=True,
follow_redirects=True
)
def _build_search_query_params_for_aym(self, params: AnayasaNormDenetimiSearchRequest) -> List[Tuple[str, str]]:
query_params: List[Tuple[str, str]] = []
if params.keywords_all:
for kw in params.keywords_all: query_params.append(("KelimeAra[]", kw))
if params.keywords_any:
for kw in params.keywords_any: query_params.append(("HerhangiBirKelimeAra[]", kw))
if params.keywords_exclude:
for kw in params.keywords_exclude: query_params.append(("BulunmayanKelimeAra[]", kw))
if params.period and params.period.value: query_params.append(("Donemler_id", params.period.value))
if params.case_number_esas: query_params.append(("EsasNo", params.case_number_esas))
if params.decision_number_karar: query_params.append(("KararNo", params.decision_number_karar))
if params.first_review_date_start: query_params.append(("IlkIncelemeTarihiIlk", params.first_review_date_start))
if params.first_review_date_end: query_params.append(("IlkIncelemeTarihiSon", params.first_review_date_end))
if params.decision_date_start: query_params.append(("KararTarihiIlk", params.decision_date_start))
if params.decision_date_end: query_params.append(("KararTarihiSon", params.decision_date_end))
if params.application_type and params.application_type.value: query_params.append(("BasvuruTurler_id", params.application_type.value))
if params.applicant_general_name: query_params.append(("BasvuranGeneller_id", params.applicant_general_name))
if params.applicant_specific_name: query_params.append(("BasvuranOzeller_id", params.applicant_specific_name))
if params.attending_members_names:
for name in params.attending_members_names: query_params.append(("Uyeler_id[]", name))
if params.rapporteur_name: query_params.append(("Raportorler_id", params.rapporteur_name))
if params.norm_type and params.norm_type.value: query_params.append(("NormunTurler_id", params.norm_type.value))
if params.norm_id_or_name: query_params.append(("NormunNumarasiAdlar_id", params.norm_id_or_name))
if params.norm_article: query_params.append(("NormunMaddeNumarasi", params.norm_article))
if params.review_outcomes:
for outcome_enum_val in params.review_outcomes:
if outcome_enum_val.value: query_params.append(("IncelemeTuruKararSonuclar_id[]", outcome_enum_val.value))
if params.reason_for_final_outcome and params.reason_for_final_outcome.value:
query_params.append(("KararSonucununGerekcesi", params.reason_for_final_outcome.value))
if params.basis_constitution_article_numbers:
for article_no in params.basis_constitution_article_numbers: query_params.append(("DayanakHukmu[]", article_no))
if params.official_gazette_date_start: query_params.append(("ResmiGazeteTarihiIlk", params.official_gazette_date_start))
if params.official_gazette_date_end: query_params.append(("ResmiGazeteTarihiSon", params.official_gazette_date_end))
if params.official_gazette_number_start: query_params.append(("ResmiGazeteSayisiIlk", params.official_gazette_number_start))
if params.official_gazette_number_end: query_params.append(("ResmiGazeteSayisiSon", params.official_gazette_number_end))
if params.has_press_release and params.has_press_release.value: query_params.append(("BasinDuyurusu", params.has_press_release.value))
if params.has_dissenting_opinion and params.has_dissenting_opinion.value: query_params.append(("KarsiOy", params.has_dissenting_opinion.value))
if params.has_different_reasoning and params.has_different_reasoning.value: query_params.append(("FarkliGerekce", params.has_different_reasoning.value))
if params.page_to_fetch and params.page_to_fetch > 1:
query_params.append(("page", str(params.page_to_fetch)))
return query_params
async def search_norm_denetimi_decisions(
self,
params: AnayasaNormDenetimiSearchRequest
) -> AnayasaSearchResult:
path_segments = []
if params.results_per_page and params.results_per_page != 10: # Default is 10
path_segments.append(f"SatirSayisi/{params.results_per_page}")
if params.sort_by_criteria and params.sort_by_criteria != "KararTarihi": # Default is KararTarihi
# Ensure correct quoting for criteria that might have Turkish chars or spaces
path_segments.append(f"Siralama/{quote(params.sort_by_criteria)}")
path_segments.append(self.SEARCH_PATH_SEGMENT)
request_path = "/" + "/".join(path_segments)
final_query_params = self._build_search_query_params_for_aym(params)
logger.info(f"AnayasaMahkemesiApiClient: Performing Norm Denetimi search. Path: {request_path}, Params: {final_query_params}")
try:
response = await self.http_client.get(request_path, params=final_query_params)
response.raise_for_status()
html_content = response.text
except httpx.RequestError as e:
logger.error(f"AnayasaMahkemesiApiClient: HTTP request error during Norm Denetimi search: {e}")
raise
except Exception as e:
logger.error(f"AnayasaMahkemesiApiClient: Error processing Norm Denetimi search request: {e}")
raise
soup = BeautifulSoup(html_content, 'html.parser')
total_records = None
bulunan_karar_div = soup.find("div", class_="bulunankararsayisi")
if not bulunan_karar_div: # Fallback for mobile view
bulunan_karar_div = soup.find("div", class_="bulunankararsayisiMobil")
if bulunan_karar_div:
match_records = re.search(r'(\d+)\s*Karar Bulundu', bulunan_karar_div.get_text(strip=True))
if match_records:
total_records = int(match_records.group(1))
processed_decisions: List[AnayasaDecisionSummary] = []
decision_divs = soup.find_all("div", class_="birkarar")
for decision_div in decision_divs:
link_tag = decision_div.find("a", href=True)
doc_url_path = link_tag['href'] if link_tag else None
decision_page_url_str = urljoin(self.BASE_URL, doc_url_path) if doc_url_path else None
title_div = decision_div.find("div", class_="bkararbaslik")
ek_no_text_raw = title_div.get_text(strip=True, separator=" ").replace('\xa0', ' ') if title_div else ""
ek_no_match = re.search(r"(E\.\s*\d+/\d+\s*,\s*K\.\s*\d+/\d+)", ek_no_text_raw)
ek_no_text = ek_no_match.group(1) if ek_no_match else ek_no_text_raw.split("Sayılı Karar")[0].strip()
keyword_count_div = title_div.find("div", class_="BulunanKelimeSayisi") if title_div else None
keyword_count_text = keyword_count_div.get_text(strip=True).replace("Bulunan Kelime Sayısı", "").strip() if keyword_count_div else None
keyword_count = int(keyword_count_text) if keyword_count_text and keyword_count_text.isdigit() else None
info_div = decision_div.find("div", class_="kararbilgileri")
info_parts = [part.strip() for part in info_div.get_text(separator="|").split("|")] if info_div else []
app_type_summary = info_parts[0] if len(info_parts) > 0 else None
applicant_summary = info_parts[1] if len(info_parts) > 1 else None
outcome_summary = info_parts[2] if len(info_parts) > 2 else None
dec_date_raw = info_parts[3] if len(info_parts) > 3 else None
decision_date_summary = dec_date_raw.replace("Karar Tarihi:", "").strip() if dec_date_raw else None
reviewed_norms_list: List[AnayasaReviewedNormInfo] = []
details_table_container = decision_div.find_next_sibling("div", class_=re.compile(r"col-sm-12")) # The details table is in a sibling div
if details_table_container:
details_table = details_table_container.find("table", class_="table")
if details_table and details_table.find("tbody"):
for row in details_table.find("tbody").find_all("tr"):
cells = row.find_all("td")
if len(cells) == 6:
reviewed_norms_list.append(AnayasaReviewedNormInfo(
norm_name_or_number=cells[0].get_text(strip=True) or None,
article_number=cells[1].get_text(strip=True) or None,
review_type_and_outcome=cells[2].get_text(strip=True) or None,
outcome_reason=cells[3].get_text(strip=True) or None,
basis_constitution_articles_cited=[a.strip() for a in cells[4].get_text(strip=True).split(',') if a.strip()] if cells[4].get_text(strip=True) else [],
postponement_period=cells[5].get_text(strip=True) or None
))
processed_decisions.append(AnayasaDecisionSummary(
decision_reference_no=ek_no_text,
decision_page_url=decision_page_url_str,
keywords_found_count=keyword_count,
application_type_summary=app_type_summary,
applicant_summary=applicant_summary,
decision_outcome_summary=outcome_summary,
decision_date_summary=decision_date_summary,
reviewed_norms=reviewed_norms_list
))
return AnayasaSearchResult(
decisions=processed_decisions,
total_records_found=total_records,
retrieved_page_number=params.page_to_fetch
)
def _convert_html_to_markdown_norm_denetimi(self, full_decision_html_content: str) -> Optional[str]:
"""Converts direct HTML content from an Anayasa Mahkemesi Norm Denetimi decision page to Markdown."""
if not full_decision_html_content:
return None
processed_html = html.unescape(full_decision_html_content)
soup = BeautifulSoup(processed_html, "html.parser")
html_input_for_markdown = ""
karar_tab_content = soup.find("div", id="Karar") # "KARAR" tab content
if karar_tab_content:
karar_metni_div = karar_tab_content.find("div", class_="KararMetni")
if karar_metni_div:
# Remove scripts and styles
for script_tag in karar_metni_div.find_all("script"): script_tag.decompose()
for style_tag in karar_metni_div.find_all("style"): style_tag.decompose()
# Remove "Künye Kopyala" button and other non-content divs
for item_div in karar_metni_div.find_all("div", class_="item col-sm-12"): item_div.decompose()
for modal_div in karar_metni_div.find_all("div", class_="modal fade"): modal_div.decompose() # If any modals
word_section = karar_metni_div.find("div", class_="WordSection1")
html_input_for_markdown = str(word_section) if word_section else str(karar_metni_div)
else:
html_input_for_markdown = str(karar_tab_content)
else:
# Fallback if specific structure is not found
word_section_fallback = soup.find("div", class_="WordSection1")
if word_section_fallback:
html_input_for_markdown = str(word_section_fallback)
else:
# Last resort: use the whole body or the raw HTML
body_tag = soup.find("body")
html_input_for_markdown = str(body_tag) if body_tag else processed_html
markdown_text = None
temp_file_path = None
try:
md_converter = MarkItDown(enable_plugins=False)
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as tmp_file:
# Ensure the content is wrapped in basic HTML structure if it's not already
if not html_input_for_markdown.strip().lower().startswith(("<html", "<!doctype")):
tmp_file.write(f"<html><head><meta charset=\"UTF-8\"></head><body>{html_input_for_markdown}</body></html>")
else:
tmp_file.write(html_input_for_markdown)
temp_file_path = tmp_file.name
conversion_result = md_converter.convert(temp_file_path)
markdown_text = conversion_result.text_content
except Exception as e:
logger.error(f"AnayasaMahkemesiApiClient: MarkItDown conversion error: {e}")
finally:
if temp_file_path and os.path.exists(temp_file_path):
os.remove(temp_file_path)
return markdown_text
async def get_decision_document_as_markdown(
self,
document_url: str,
page_number: int = 1
) -> AnayasaDocumentMarkdown:
"""
Retrieves a specific Anayasa Mahkemesi (Norm Denetimi) decision,
converts its content to Markdown, and returns the requested page/chunk.
"""
full_url = urljoin(self.BASE_URL, document_url) if not document_url.startswith("http") else document_url
logger.info(f"AnayasaMahkemesiApiClient: Fetching Norm Denetimi document for Markdown (page {page_number}) from URL: {full_url}")
decision_ek_no_from_page = None
decision_date_from_page = None
official_gazette_from_page = None
try:
# Use a new client instance for document fetching if headers/timeout needs to be different,
# or reuse self.http_client if settings are compatible. For now, self.http_client.
get_response = await self.http_client.get(full_url, headers={"Accept": "text/html"})
get_response.raise_for_status()
html_content_from_api = get_response.text
if not isinstance(html_content_from_api, str) or not html_content_from_api.strip():
logger.warning(f"AnayasaMahkemesiApiClient: Received empty or non-string HTML from URL {full_url}.")
return AnayasaDocumentMarkdown(
source_url=full_url, markdown_chunk=None, current_page=page_number, total_pages=0, is_paginated=False
)
# Extract metadata from the page content (E.K. No, Date, RG)
soup = BeautifulSoup(html_content_from_api, "html.parser")
karar_metni_div = soup.find("div", class_="KararMetni") # Usually within div#Karar
if not karar_metni_div: # Fallback if not in KararMetni
karar_metni_div = soup.find("div", class_="WordSection1")
if karar_metni_div:
# Attempt to find E.K. No (Esas No, Karar No)
# Norm Denetimi pages often have this in bold <p> tags directly or in the WordSection1
# Look for patterns like "Esas No.: YYYY/NN" and "Karar No.: YYYY/NN"
esas_no_tag = karar_metni_div.find(lambda tag: tag.name == "p" and tag.find("b") and "Esas No.:" in tag.find("b").get_text())
karar_no_tag = karar_metni_div.find(lambda tag: tag.name == "p" and tag.find("b") and "Karar No.:" in tag.find("b").get_text())
karar_tarihi_tag = karar_metni_div.find(lambda tag: tag.name == "p" and tag.find("b") and "Karar tarihi:" in tag.find("b").get_text()) # Less common on Norm pages
resmi_gazete_tag = karar_metni_div.find(lambda tag: tag.name == "p" and ("Resmî Gazete tarih ve sayısı:" in tag.get_text() or "Resmi Gazete tarih/sayı:" in tag.get_text()))
if esas_no_tag and esas_no_tag.find("b") and karar_no_tag and karar_no_tag.find("b"):
esas_str = esas_no_tag.find("b").get_text(strip=True).replace('Esas No.:', '').strip()
karar_str = karar_no_tag.find("b").get_text(strip=True).replace('Karar No.:', '').strip()
decision_ek_no_from_page = f"E.{esas_str}, K.{karar_str}"
if karar_tarihi_tag and karar_tarihi_tag.find("b"):
decision_date_from_page = karar_tarihi_tag.find("b").get_text(strip=True).replace("Karar tarihi:", "").strip()
elif karar_metni_div: # Fallback for Karar Tarihi if not in specific tag
date_match = re.search(r"Karar Tarihi\s*:\s*([\d\.]+)", karar_metni_div.get_text()) # Norm pages often use DD.MM.YYYY
if date_match: decision_date_from_page = date_match.group(1).strip()
if resmi_gazete_tag:
# Try to get the bold part first if it exists
bold_rg_tag = resmi_gazete_tag.find("b")
rg_text_content = bold_rg_tag.get_text(strip=True) if bold_rg_tag else resmi_gazete_tag.get_text(strip=True)
official_gazette_from_page = rg_text_content.replace("Resmî Gazete tarih ve sayısı:", "").replace("Resmi Gazete tarih/sayı:", "").strip()
full_markdown_content = self._convert_html_to_markdown_norm_denetimi(html_content_from_api)
if not full_markdown_content:
return AnayasaDocumentMarkdown(
source_url=full_url,
decision_reference_no_from_page=decision_ek_no_from_page,
decision_date_from_page=decision_date_from_page,
official_gazette_info_from_page=official_gazette_from_page,
markdown_chunk=None,
current_page=page_number,
total_pages=0,
is_paginated=False
)
content_length = len(full_markdown_content)
total_pages = math.ceil(content_length / self.DOCUMENT_MARKDOWN_CHUNK_SIZE)
if total_pages == 0: total_pages = 1
current_page_clamped = max(1, min(page_number, total_pages))
start_index = (current_page_clamped - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE
end_index = start_index + self.DOCUMENT_MARKDOWN_CHUNK_SIZE
markdown_chunk = full_markdown_content[start_index:end_index]
return AnayasaDocumentMarkdown(
source_url=full_url,
decision_reference_no_from_page=decision_ek_no_from_page,
decision_date_from_page=decision_date_from_page,
official_gazette_info_from_page=official_gazette_from_page,
markdown_chunk=markdown_chunk,
current_page=current_page_clamped,
total_pages=total_pages,
is_paginated=(total_pages > 1)
)
except httpx.RequestError as e:
logger.error(f"AnayasaMahkemesiApiClient: HTTP error fetching Norm Denetimi document from {full_url}: {e}")
raise
except Exception as e:
logger.error(f"AnayasaMahkemesiApiClient: General error processing Norm Denetimi document from {full_url}: {e}")
raise
async def close_client_session(self):
if hasattr(self, 'http_client') and self.http_client and not self.http_client.is_closed:
await self.http_client.aclose()
logger.info("AnayasaMahkemesiApiClient (Norm Denetimi): HTTP client session closed.")
+212
View File
@@ -0,0 +1,212 @@
# anayasa_mcp_module/models.py
from pydantic import BaseModel, Field, HttpUrl
from typing import List, Optional, Dict, Any
from enum import Enum
# --- Enums (AnayasaDonemEnum, AnayasaBasvuruTuruEnum, etc. - same as before) ---
class AnayasaDonemEnum(str, Enum):
TUMU = ""
DONEM_1961 = "1"
DONEM_1982 = "2"
class AnayasaBasvuruTuruEnum(str, Enum):
TUMU = ""
IPTAL = "1"
ITIRAZ = "2"
DIGER = "3"
class AnayasaVarYokEnum(str, Enum):
TUMU = ""
YOK = "0"
VAR = "1"
class AnayasaNormTuruEnum(str, Enum):
TUMU = ""
ANAYASA = "1"
ANAYASA_DEGISTIREN_KANUN = "2"
CUMHURBASKANLIGI_KARARNAMESI = "14"
ICTUZUK = "3"
KANUN = "4"
KANUN_HUKMUNDE_KARARNAME = "5"
KARAR = "6"
NIZAMNAME = "7"
TALIMATNAME = "8"
TARIFE = "9"
TBMM_KARARI = "10"
TEZKERE = "11"
TUZUK = "12"
YOK_SECENEGI = "0"
YONETMELIK = "13"
class AnayasaIncelemeSonucuEnum(str, Enum):
TUMU = ""
ESAS_ACILMAMIS_SAYILMA = "1"
ESAS_IPTAL = "2"
ESAS_KARAR_YER_OLMADIGI = "3"
ESAS_RET = "4"
ILK_ACILMAMIS_SAYILMA = "5"
ILK_ISIN_GERI_CEVRILMESI = "6"
ILK_KARAR_YER_OLMADIGI = "7"
ILK_RET = "8"
KANUN_6216_M43_4_IPTAL = "12"
class AnayasaSonucGerekcesiEnum(str, Enum):
TUMU = ""
ANAYASAYA_AYKIRI_DEGIL = "29"
ANAYASAYA_ESAS_YONUNDEN_AYKIRILIK = "1"
ANAYASAYA_ESAS_YONUNDEN_UYGUNLUK = "2"
ANAYASAYA_SEKIL_ESAS_UYGUNLUK = "30"
ANAYASAYA_SEKIL_YONUNDEN_AYKIRILIK = "3"
ANAYASAYA_SEKIL_YONUNDEN_UYGUNLUK = "4"
AYKIRILIK_ANAYASAYA_ESAS_YONUNDEN_DUPLICATE = "27"
BASVURU_KARARI = "5"
DENETIM_DISI = "6"
DIGER_GEREKCE_1 = "7"
DIGER_GEREKCE_2 = "8"
EKSIKLIGIN_GIDERILMEMESI = "9"
GEREKCE = "10"
GOREV = "11"
GOREV_YETKI = "12"
GOREVLI_MAHKEME = "13"
GORULMEKTE_OLAN_DAVA = "14"
MAHKEME = "15"
NORMDA_DEGISIKLIK_YAPILMASI = "16"
NORMUN_YURURLUKTEN_KALDIRILMASI = "17"
ON_YIL_YASAGI = "18"
SURE = "19"
USULE_UYMAMA = "20"
UYGULANACAK_NORM = "21"
UYGULANAMAZ_HALE_GELME = "22"
YETKI = "23"
YETKI_SURE = "24"
YOK_HUKMUNDE_OLMAMA = "25"
YOKLUK = "26"
# --- End Enums ---
class AnayasaNormDenetimiSearchRequest(BaseModel):
"""Model for Anayasa Mahkemesi (Norm Denetimi) search request for the MCP tool."""
keywords_all: Optional[List[str]] = Field(default_factory=list, description="Keywords for AND logic (KelimeAra[]).")
keywords_any: Optional[List[str]] = Field(default_factory=list, description="Keywords for OR logic (HerhangiBirKelimeAra[]).")
keywords_exclude: Optional[List[str]] = Field(default_factory=list, description="Keywords to exclude (BulunmayanKelimeAra[]).")
period: Optional[AnayasaDonemEnum] = Field(default=AnayasaDonemEnum.TUMU, description="Constitutional period (Donemler_id).")
case_number_esas: Optional[str] = Field(None, description="Case registry number (EsasNo), e.g., '2023/123'.")
decision_number_karar: Optional[str] = Field(None, description="Decision number (KararNo), e.g., '2023/456'.")
first_review_date_start: Optional[str] = Field(None, description="First review start date (IlkIncelemeTarihiIlk), format DD/MM/YYYY.")
first_review_date_end: Optional[str] = Field(None, description="First review end date (IlkIncelemeTarihiSon), format DD/MM/YYYY.")
decision_date_start: Optional[str] = Field(None, description="Decision start date (KararTarihiIlk), format DD/MM/YYYY.")
decision_date_end: Optional[str] = Field(None, description="Decision end date (KararTarihiSon), format DD/MM/YYYY.")
application_type: Optional[AnayasaBasvuruTuruEnum] = Field(default=AnayasaBasvuruTuruEnum.TUMU, description="Type of application (BasvuruTurler_id).")
applicant_general_name: Optional[str] = Field(None, description="General applicant name (BasvuranGeneller_id).")
applicant_specific_name: Optional[str] = Field(None, description="Specific applicant name (BasvuranOzeller_id).")
official_gazette_date_start: Optional[str] = Field(None, description="Official Gazette start date (ResmiGazeteTarihiIlk), format DD/MM/YYYY.")
official_gazette_date_end: Optional[str] = Field(None, description="Official Gazette end date (ResmiGazeteTarihiSon), format DD/MM/YYYY.")
official_gazette_number_start: Optional[str] = Field(None, description="Official Gazette starting number (ResmiGazeteSayisiIlk).")
official_gazette_number_end: Optional[str] = Field(None, description="Official Gazette ending number (ResmiGazeteSayisiSon).")
has_press_release: Optional[AnayasaVarYokEnum] = Field(default=AnayasaVarYokEnum.TUMU, description="Press release available (BasinDuyurusu).")
has_dissenting_opinion: Optional[AnayasaVarYokEnum] = Field(default=AnayasaVarYokEnum.TUMU, description="Dissenting opinion exists (KarsiOy).")
has_different_reasoning: Optional[AnayasaVarYokEnum] = Field(default=AnayasaVarYokEnum.TUMU, description="Different reasoning exists (FarkliGerekce).")
attending_members_names: Optional[List[str]] = Field(default_factory=list, description="List of attending members' exact names (Uyeler_id[]).")
rapporteur_name: Optional[str] = Field(None, description="Rapporteur's exact name (Raportorler_id).")
norm_type: Optional[AnayasaNormTuruEnum] = Field(default=AnayasaNormTuruEnum.TUMU, description="Type of the reviewed norm (NormunTurler_id).")
norm_id_or_name: Optional[str] = Field(None, description="Number or name of the norm (NormunNumarasiAdlar_id).")
norm_article: Optional[str] = Field(None, description="Article number of the norm (NormunMaddeNumarasi).")
review_outcomes: Optional[List[AnayasaIncelemeSonucuEnum]] = Field(default_factory=list, description="List of review types and outcomes (IncelemeTuruKararSonuclar_id[]).")
reason_for_final_outcome: Optional[AnayasaSonucGerekcesiEnum] = Field(default=AnayasaSonucGerekcesiEnum.TUMU, description="Main reason for the decision outcome (KararSonucununGerekcesi).")
basis_constitution_article_numbers: Optional[List[str]] = Field(default_factory=list, description="List of supporting Constitution article numbers (DayanakHukmu[]).")
results_per_page: Optional[int] = Field(10, description="Number of results per page. Options: 10, 20, 30, 40, 50.")
page_to_fetch: Optional[int] = Field(1, ge=1, description="Page number to fetch for results list.")
sort_by_criteria: Optional[str] = Field("KararTarihi", description="Sort criteria. Options: 'KararTarihi', 'YayinTarihi', 'Toplam' (keyword count).")
class AnayasaReviewedNormInfo(BaseModel):
"""Details of a norm reviewed within an AYM decision summary."""
norm_name_or_number: Optional[str] = None
article_number: Optional[str] = None
review_type_and_outcome: Optional[str] = None
outcome_reason: Optional[str] = None
basis_constitution_articles_cited: List[str] = Field(default_factory=list)
postponement_period: Optional[str] = None
class AnayasaDecisionSummary(BaseModel):
"""Model for a single Anayasa Mahkemesi (Norm Denetimi) decision summary from search results."""
decision_reference_no: Optional[str] = None
decision_page_url: Optional[HttpUrl] = None
keywords_found_count: Optional[int] = None
application_type_summary: Optional[str] = None
applicant_summary: Optional[str] = None
decision_outcome_summary: Optional[str] = None
decision_date_summary: Optional[str] = None
reviewed_norms: List[AnayasaReviewedNormInfo] = Field(default_factory=list)
class AnayasaSearchResult(BaseModel):
"""Model for the overall search result for Anayasa Mahkemesi Norm Denetimi decisions."""
decisions: List[AnayasaDecisionSummary]
total_records_found: Optional[int] = None
retrieved_page_number: Optional[int] = None
class AnayasaDocumentMarkdown(BaseModel):
"""
Model for an Anayasa Mahkemesi (Norm Denetimi) decision document, containing a chunk of Markdown content
and pagination information.
"""
source_url: HttpUrl
decision_reference_no_from_page: Optional[str] = Field(None, description="E.K. No parsed from the document page.")
decision_date_from_page: Optional[str] = Field(None, description="Decision date parsed from the document page.")
official_gazette_info_from_page: Optional[str] = Field(None, description="Official Gazette info parsed from the document page.")
markdown_chunk: Optional[str] = Field(None, description="A 5,000 character chunk of the Markdown content.") # Corrected chunk size
current_page: int = Field(description="The current page number of the markdown chunk (1-indexed).")
total_pages: int = Field(description="Total number of pages for the full markdown content.")
is_paginated: bool = Field(description="True if the full markdown content is split into multiple pages.")
# --- Models for Anayasa Mahkemesi - Bireysel Başvuru Karar Raporu ---
class AnayasaBireyselReportSearchRequest(BaseModel):
"""Model for Anayasa Mahkemesi (Bireysel Başvuru) 'Karar Arama Raporu' search request."""
keywords: Optional[List[str]] = Field(default_factory=list, description="Keywords for AND logic (KelimeAra[]).")
page_to_fetch: int = Field(1, ge=1, description="Page number to fetch for the report (page). Default is 1.")
class AnayasaBireyselReportDecisionDetail(BaseModel):
"""Details of a specific right/claim within a Bireysel Başvuru decision summary in a report."""
hak: Optional[str] = Field(None, description="İhlal edildiği iddia edilen hak (örneğin, Mülkiyet hakkı).")
mudahale_iddiasi: Optional[str] = Field(None, description="İhlale neden olan müdahale iddiası.")
sonuc: Optional[str] = Field(None, description="İnceleme sonucu (örneğin, İhlal, Düşme).")
giderim: Optional[str] = Field(None, description="Kararlaştırılan giderim (örneğin, Yeniden yargılama).")
class AnayasaBireyselReportDecisionSummary(BaseModel):
"""Model for a single Anayasa Mahkemesi (Bireysel Başvuru) decision summary from a 'Karar Arama Raporu'."""
title: Optional[str] = Field(None, description="Başvurunun başlığı (e.g., 'HASAN DURMUŞ Başvurusuna İlişkin Karar').")
decision_reference_no: Optional[str] = Field(None, description="Başvuru Numarası (e.g., '2019/19126').")
decision_page_url: Optional[HttpUrl] = Field(None, description="URL to the full decision page.")
decision_type_summary: Optional[str] = Field(None, description="Karar Türü (Başvuru Sonucu) (e.g., 'Esas (İhlal)').")
decision_making_body: Optional[str] = Field(None, description="Kararı Veren Birim (e.g., 'Genel Kurul', 'Birinci Bölüm').")
application_date_summary: Optional[str] = Field(None, description="Başvuru Tarihi (DD/MM/YYYY).")
decision_date_summary: Optional[str] = Field(None, description="Karar Tarihi (DD/MM/YYYY).")
application_subject_summary: Optional[str] = Field(None, description="Başvuru konusunun özeti.")
details: List[AnayasaBireyselReportDecisionDetail] = Field(default_factory=list, description="İncelenen haklar ve sonuçlarına ilişkin detaylar.")
class AnayasaBireyselReportSearchResult(BaseModel):
"""Model for the overall search result for Anayasa Mahkemesi 'Karar Arama Raporu'."""
decisions: List[AnayasaBireyselReportDecisionSummary]
total_records_found: Optional[int] = Field(None, description="Raporda bulunan toplam karar sayısı.")
retrieved_page_number: int = Field(description="Alınan rapor sayfa numarası.")
class AnayasaBireyselBasvuruDocumentMarkdown(BaseModel):
"""
Model for an Anayasa Mahkemesi (Bireysel Başvuru) decision document, containing a chunk of Markdown content
and pagination information. Fetched from /BB/YYYY/NNNN paths.
"""
source_url: HttpUrl
basvuru_no_from_page: Optional[str] = Field(None, description="Başvuru Numarası (B.No) parsed from the document page.")
karar_tarihi_from_page: Optional[str] = Field(None, description="Decision date parsed from the document page.")
basvuru_tarihi_from_page: Optional[str] = Field(None, description="Application date parsed from the document page.")
karari_veren_birim_from_page: Optional[str] = Field(None, description="Deciding body (Bölüm/Genel Kurul) parsed from the document page.")
karar_turu_from_page: Optional[str] = Field(None, description="Decision type (Başvuru Sonucu) parsed from the document page.")
resmi_gazete_info_from_page: Optional[str] = Field(None, description="Official Gazette info parsed from the document page, if available.")
markdown_chunk: Optional[str] = Field(None, description="A 5,000 character chunk of the Markdown content.")
current_page: int = Field(description="The current page number of the markdown chunk (1-indexed).")
total_pages: int = Field(description="Total number of pages for the full markdown content.")
is_paginated: bool = Field(description="True if the full markdown content is split into multiple pages.")
# --- End Models for Bireysel Başvuru ---
View File
+194
View File
@@ -0,0 +1,194 @@
# danistay_mcp_module/client.py
import httpx
from bs4 import BeautifulSoup
from typing import Dict, Any, List, Optional
import logging
import html
import re
import tempfile
import os
from markitdown import MarkItDown
from .models import (
DanistayKeywordSearchRequest,
DanistayDetailedSearchRequest,
DanistayApiResponse,
DanistayDocumentMarkdown,
DanistayKeywordSearchRequestData,
DanistayDetailedSearchRequestData
)
logger = logging.getLogger(__name__)
if not logger.hasHandlers():
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
class DanistayApiClient:
BASE_URL = "https://karararama.danistay.gov.tr"
KEYWORD_SEARCH_ENDPOINT = "/aramalist"
DETAILED_SEARCH_ENDPOINT = "/aramadetaylist"
DOCUMENT_ENDPOINT = "/getDokuman"
def __init__(self, request_timeout: float = 30.0):
self.http_client = httpx.AsyncClient(
base_url=self.BASE_URL,
headers={
"Content-Type": "application/json; charset=UTF-8", # Arama endpoint'leri için
"Accept": "application/json, text/plain, */*", # Arama endpoint'leri için
# /getDokuman HTML döndürdüğü için Accept header'ı GET isteğinde farklı olabilir
# ama httpx genellikle bunu yönetir. Gerekirse özel header eklenebilir.
"X-Requested-With": "XMLHttpRequest",
},
timeout=request_timeout,
verify=False
)
def _prepare_keywords_for_api(self, keywords: List[str]) -> List[str]:
return [f'"{k.strip("\"")}"' for k in keywords if k and k.strip()]
async def search_keyword_decisions(
self,
params: DanistayKeywordSearchRequest
) -> DanistayApiResponse:
data_for_payload = DanistayKeywordSearchRequestData(
andKelimeler=self._prepare_keywords_for_api(params.andKelimeler),
orKelimeler=self._prepare_keywords_for_api(params.orKelimeler),
notAndKelimeler=self._prepare_keywords_for_api(params.notAndKelimeler),
notOrKelimeler=self._prepare_keywords_for_api(params.notOrKelimeler),
pageSize=params.pageSize,
pageNumber=params.pageNumber
)
final_payload = {"data": data_for_payload.model_dump(exclude_none=True)}
logger.info(f"DanistayApiClient: Performing KEYWORD search via {self.KEYWORD_SEARCH_ENDPOINT} with payload: {final_payload}")
return await self._execute_api_search(self.KEYWORD_SEARCH_ENDPOINT, final_payload)
async def search_detailed_decisions(
self,
params: DanistayDetailedSearchRequest
) -> DanistayApiResponse:
data_for_payload = DanistayDetailedSearchRequestData(
daire=params.daire or "",
esasYil=params.esasYil or "",
esasIlkSiraNo=params.esasIlkSiraNo or "",
esasSonSiraNo=params.esasSonSiraNo or "",
kararYil=params.kararYil or "",
kararIlkSiraNo=params.kararIlkSiraNo or "",
kararSonSiraNo=params.kararSonSiraNo or "",
baslangicTarihi=params.baslangicTarihi or "",
bitisTarihi=params.bitisTarihi or "",
mevzuatNumarasi=params.mevzuatNumarasi or "",
mevzuatAdi=params.mevzuatAdi or "",
madde=params.madde or "",
siralama=params.siralama,
siralamaDirection=params.siralamaDirection,
pageSize=params.pageSize,
pageNumber=params.pageNumber
)
final_payload = {"data": data_for_payload.model_dump(exclude_defaults=False, exclude_none=False)}
logger.info(f"DanistayApiClient: Performing DETAILED search via {self.DETAILED_SEARCH_ENDPOINT} with payload: {final_payload}")
return await self._execute_api_search(self.DETAILED_SEARCH_ENDPOINT, final_payload)
async def _execute_api_search(self, endpoint: str, payload: Dict) -> DanistayApiResponse:
try:
response = await self.http_client.post(endpoint, json=payload)
response.raise_for_status()
response_json_data = response.json()
logger.debug(f"DanistayApiClient: Raw API response from {endpoint}: {response_json_data}")
api_response_parsed = DanistayApiResponse(**response_json_data)
if api_response_parsed.data and api_response_parsed.data.data:
for decision_item in api_response_parsed.data.data:
if decision_item.id:
decision_item.document_url = f"{self.BASE_URL}{self.DOCUMENT_ENDPOINT}?id={decision_item.id}"
return api_response_parsed
except httpx.RequestError as e:
logger.error(f"DanistayApiClient: HTTP request error during search to {endpoint}: {e}")
raise
except Exception as e:
logger.error(f"DanistayApiClient: Error processing or validating search response from {endpoint}: {e}")
raise
def _convert_html_to_markdown_danistay(self, direct_html_content: str) -> Optional[str]:
"""
Converts direct HTML content (assumed from Danıştay /getDokuman) to Markdown.
"""
if not direct_html_content:
return None
# Basic HTML unescaping and fixing common escaped characters
# This step might be less critical if MarkItDown handles them, but good for pre-cleaning.
processed_html = html.unescape(direct_html_content)
processed_html = processed_html.replace('\\"', '"') # If any such JS-escaped strings exist
# Danistay HTML doesn't seem to have \\r\\n etc. from the example, but keeping for robustness
processed_html = processed_html.replace('\\r\\n', '\n').replace('\\n', '\n').replace('\\t', '\t')
# For simplicity and to leverage MarkItDown's capability to handle full docs,
# we pass the pre-processed full HTML.
html_input_for_markdown = processed_html
markdown_text = None
temp_file_path = None
try:
md_converter = MarkItDown(enable_plugins=False) # Basic conversion
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as tmp_file:
tmp_file.write(html_input_for_markdown) # Write the full HTML string
temp_file_path = tmp_file.name
conversion_result = md_converter.convert(temp_file_path)
markdown_text = conversion_result.text_content
logger.info("DanistayApiClient: HTML to Markdown conversion successful.")
except Exception as e:
logger.error(f"DanistayApiClient: Error during MarkItDown HTML to Markdown conversion: {e}")
finally:
if temp_file_path and os.path.exists(temp_file_path):
os.remove(temp_file_path)
return markdown_text
async def get_decision_document_as_markdown(self, document_id: str) -> DanistayDocumentMarkdown:
"""
Retrieves a specific Danıştay decision by ID and returns its content as Markdown.
The /getDokuman endpoint for Danıştay returns direct HTML.
"""
document_api_url = f"{self.DOCUMENT_ENDPOINT}?id={document_id}"
source_url = f"{self.BASE_URL}{document_api_url}"
logger.info(f"DanistayApiClient: Fetching Danistay document for Markdown (ID: {document_id}) from {source_url}")
try:
# For direct HTML response, we might want different headers if the API is sensitive,
# but httpx usually handles basic GET requests well.
response = await self.http_client.get(document_api_url)
response.raise_for_status()
# Danıştay /getDokuman directly returns HTML text
html_content_from_api = response.text
if not isinstance(html_content_from_api, str) or not html_content_from_api.strip():
logger.warning(f"DanistayApiClient: Received empty or non-string HTML content for ID {document_id}.")
# Return with None markdown_content if HTML is effectively empty
return DanistayDocumentMarkdown(
document_id=document_id,
markdown_content=None,
source_url=source_url
)
markdown_content = self._convert_html_to_markdown_danistay(html_content_from_api)
return DanistayDocumentMarkdown(
document_id=document_id,
markdown_content=markdown_content,
source_url=source_url
)
except httpx.RequestError as e:
logger.error(f"DanistayApiClient: HTTP error fetching Danistay document (ID: {document_id}): {e}")
raise
# Removed ValueError for JSON as Danistay /getDokuman returns direct HTML
except Exception as e: # Catches other errors like MarkItDown issues if they propagate
logger.error(f"DanistayApiClient: General error processing Danistay document (ID: {document_id}): {e}")
raise
async def close_client_session(self):
"""Closes the HTTPX client session."""
if self.http_client and not self.http_client.is_closed:
await self.http_client.aclose()
logger.info("DanistayApiClient: HTTP client session closed.")
+116
View File
@@ -0,0 +1,116 @@
# danistay_mcp_module/models.py
from pydantic import BaseModel, Field, HttpUrl
from typing import List, Optional, Dict, Any
class DanistayBaseSearchRequest(BaseModel):
"""Base model for common search parameters for Danistay."""
pageSize: int = Field(default=10, ge=1, le=100)
pageNumber: int = Field(default=1, ge=1)
# siralama and siralamaDirection are part of detailed search, not necessarily keyword search
# as per user's provided payloads.
class DanistayKeywordSearchRequestData(BaseModel):
"""Internal data model for the keyword search payload's 'data' field."""
andKelimeler: List[str] = Field(default_factory=list)
orKelimeler: List[str] = Field(default_factory=list)
notAndKelimeler: List[str] = Field(default_factory=list)
notOrKelimeler: List[str] = Field(default_factory=list)
pageSize: int
pageNumber: int
class DanistayKeywordSearchRequest(BaseModel): # This is the model the MCP tool will accept
"""Model for keyword-based search request for Danistay."""
andKelimeler: List[str] = Field(default_factory=list, description="Keywords for AND logic, e.g., ['word1', 'word2']")
orKelimeler: List[str] = Field(default_factory=list, description="Keywords for OR logic.")
notAndKelimeler: List[str] = Field(default_factory=list, description="Keywords for NOT AND logic.")
notOrKelimeler: List[str] = Field(default_factory=list, description="Keywords for NOT OR logic.")
pageSize: int = Field(default=10, ge=1, le=100)
pageNumber: int = Field(default=1, ge=1)
class DanistayDetailedSearchRequestData(BaseModel): # Internal data model for detailed search payload
"""Internal data model for the detailed search payload's 'data' field."""
daire: Optional[str] = "" # API expects empty string for None
esasYil: Optional[str] = ""
esasIlkSiraNo: Optional[str] = ""
esasSonSiraNo: Optional[str] = ""
kararYil: Optional[str] = ""
kararIlkSiraNo: Optional[str] = ""
kararSonSiraNo: Optional[str] = ""
baslangicTarihi: Optional[str] = ""
bitisTarihi: Optional[str] = ""
mevzuatNumarasi: Optional[str] = ""
mevzuatAdi: Optional[str] = ""
madde: Optional[str] = ""
siralama: str # Seems mandatory in detailed search payload
siralamaDirection: str # Seems mandatory
pageSize: int
pageNumber: int
# Note: 'arananKelime' is not in the detailed search payload example provided by user.
# If it can be included, it should be added here.
class DanistayDetailedSearchRequest(DanistayBaseSearchRequest): # MCP tool will accept this
"""Model for detailed search request for Danistay."""
daire: Optional[str] = Field(None, description="Chamber/Department name (e.g., '1. Daire').")
esasYil: Optional[str] = Field(None, description="Case year for 'Esas No'.")
esasIlkSiraNo: Optional[str] = Field(None, description="Starting sequence for 'Esas No'.")
esasSonSiraNo: Optional[str] = Field(None, description="Ending sequence for 'Esas No'.")
kararYil: Optional[str] = Field(None, description="Decision year for 'Karar No'.")
kararIlkSiraNo: Optional[str] = Field(None, description="Starting sequence for 'Karar No'.")
kararSonSiraNo: Optional[str] = Field(None, description="Ending sequence for 'Karar No'.")
baslangicTarihi: Optional[str] = Field(None, description="Start date for decision (DD.MM.YYYY).")
bitisTarihi: Optional[str] = Field(None, description="End date for decision (DD.MM.YYYY).")
mevzuatNumarasi: Optional[str] = Field(None, description="Legislation number.")
mevzuatAdi: Optional[str] = Field(None, description="Legislation name.")
madde: Optional[str] = Field(None, description="Article number.")
siralama: str = Field("1", description="Sorting criteria (e.g., 1: Esas No, 3: Karar Tarihi).")
siralamaDirection: str = Field("desc", description="Sorting direction ('asc' or 'desc').")
# Add a general keyword field if detailed search also supports it
# arananKelime: Optional[str] = Field(None, description="General keyword for detailed search.")
class DanistayApiDecisionEntry(BaseModel):
"""Model for an individual decision entry from the Danistay API search response.
Based on user-provided response samples for both keyword and detailed search.
"""
id: str
# The API response for keyword search uses "daireKurul", detailed search example uses "daire".
# We use an alias to handle both and map to a consistent field name "chamber".
chamber: Optional[str] = Field(None, alias="daire", alt_alias="daireKurul", description="The chamber or board.")
esasNo: Optional[str] = Field(None)
kararNo: Optional[str] = Field(None)
kararTarihi: Optional[str] = Field(None)
arananKelime: Optional[str] = Field(None, description="Matched keyword if provided in response.")
# index: Optional[int] = None # Present in response, can be added if needed by MCP tool
# siraNo: Optional[int] = None # Present in detailed response, can be added
document_url: Optional[HttpUrl] = Field(None, description="URL to the full document, constructed by the client.")
class Config:
populate_by_name = True # Important for alias to work
extra = 'ignore' # Ignore any extra fields from API not defined in model
class DanistayApiResponseInnerData(BaseModel):
"""Model for the inner 'data' object in the Danistay API search response."""
data: List[DanistayApiDecisionEntry]
recordsTotal: int
recordsFiltered: int
draw: Optional[int] = Field(None, description="Draw counter from API, usually for DataTables.")
class DanistayApiResponse(BaseModel):
"""Model for the complete search response from the Danistay API."""
data: DanistayApiResponseInnerData
metadata: Optional[Dict[str, Any]] = Field(None, description="Optional metadata from API.")
class DanistayDocumentMarkdown(BaseModel):
"""Model for a Danistay decision document, containing only Markdown content."""
document_id: str
markdown_content: Optional[str] = Field(None, description="The decision content converted to Markdown.")
source_url: HttpUrl
class CompactDanistaySearchResult(BaseModel):
"""A compact search result model for the MCP tool to return."""
decisions: List[DanistayApiDecisionEntry]
total_records: int
requested_page: int
page_size: int
View File
+178
View File
@@ -0,0 +1,178 @@
# emsal_mcp_module/client.py
import httpx
# from bs4 import BeautifulSoup # Uncomment if needed for advanced HTML pre-processing
from typing import Dict, Any, List, Optional
import logging
import html
import re
import tempfile
import os
from markitdown import MarkItDown
from .models import (
EmsalSearchRequest,
EmsalDetailedSearchRequestData,
EmsalApiResponse,
EmsalDocumentMarkdown
)
logger = logging.getLogger(__name__)
if not logger.hasHandlers():
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
class EmsalApiClient:
"""API Client for Emsal (UYAP Precedent Decision) search system."""
BASE_URL = "https://emsal.uyap.gov.tr"
DETAILED_SEARCH_ENDPOINT = "/aramadetaylist"
DOCUMENT_ENDPOINT = "/getDokuman"
def __init__(self, request_timeout: float = 30.0):
self.http_client = httpx.AsyncClient(
base_url=self.BASE_URL,
headers={
"Content-Type": "application/json; charset=UTF-8",
"Accept": "application/json, text/plain, */*",
"X-Requested-With": "XMLHttpRequest",
},
timeout=request_timeout,
verify=False # As per user's original FastAPI code
)
async def search_detailed_decisions(
self,
params: EmsalSearchRequest
) -> EmsalApiResponse:
"""Performs a detailed search for Emsal decisions."""
data_for_api_payload = EmsalDetailedSearchRequestData(
arananKelime=params.keyword or "",
Bam_Hukuk_Mahkemeleri=params.selected_bam_civil_court, # Uses alias "Bam Hukuk Mahkemeleri"
Hukuk_Mahkemeleri=params.selected_civil_court, # Uses alias "Hukuk Mahkemeleri"
birimHukukMah="+".join(params.selected_regional_civil_chambers) if params.selected_regional_civil_chambers else "",
esasYil=params.case_year_esas or "",
esasIlkSiraNo=params.case_start_seq_esas or "",
esasSonSiraNo=params.case_end_seq_esas or "",
kararYil=params.decision_year_karar or "",
kararIlkSiraNo=params.decision_start_seq_karar or "",
kararSonSiraNo=params.decision_end_seq_karar or "",
baslangicTarihi=params.start_date or "",
bitisTarihi=params.end_date or "",
siralama=params.sort_criteria,
siralamaDirection=params.sort_direction,
pageSize=params.page_size,
pageNumber=params.page_number
)
final_payload = {"data": data_for_api_payload.model_dump(by_alias=True, exclude_none=True)}
logger.info(f"EmsalApiClient: Performing DETAILED search with payload: {final_payload}")
return await self._execute_api_search(self.DETAILED_SEARCH_ENDPOINT, final_payload)
async def _execute_api_search(self, endpoint: str, payload: Dict) -> EmsalApiResponse:
"""Helper method to execute search POST request and process response for Emsal."""
try:
response = await self.http_client.post(endpoint, json=payload)
response.raise_for_status()
response_json_data = response.json()
logger.debug(f"EmsalApiClient: Raw API response from {endpoint}: {response_json_data}")
api_response_parsed = EmsalApiResponse(**response_json_data)
if api_response_parsed.data and api_response_parsed.data.data:
for decision_item in api_response_parsed.data.data:
if decision_item.id:
decision_item.document_url = f"{self.BASE_URL}{self.DOCUMENT_ENDPOINT}?id={decision_item.id}"
return api_response_parsed
except httpx.RequestError as e:
logger.error(f"EmsalApiClient: HTTP request error during Emsal search to {endpoint}: {e}")
raise
except Exception as e:
logger.error(f"EmsalApiClient: Error processing or validating Emsal search response from {endpoint}: {e}")
raise
def _clean_html_and_convert_to_markdown_emsal(self, html_content_from_api_data_field: str) -> Optional[str]:
"""
Cleans HTML (from Emsal API 'data' field containing HTML string)
and converts it to Markdown using MarkItDown.
This assumes Emsal /getDokuman response is JSON with HTML in "data" field,
similar to Yargitay and the last Emsal /getDokuman example.
"""
if not html_content_from_api_data_field:
return None
# Basic HTML unescaping and fixing common escaped characters
# Based on user's original fix_html_content in app/routers/emsal.py
content = html.unescape(html_content_from_api_data_field)
content = content.replace('\\"', '"')
content = content.replace('\\r\\n', '\n')
content = content.replace('\\n', '\n')
content = content.replace('\\t', '\t')
# The HTML string from "data" field starts with "<html><head>..."
html_input_for_markdown = content
markdown_text = None
temp_file_path = None
try:
md_converter = MarkItDown(enable_plugins=False)
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as tmp_file:
tmp_file.write(html_input_for_markdown)
temp_file_path = tmp_file.name
conversion_result = md_converter.convert(temp_file_path)
markdown_text = conversion_result.text_content
logger.info("EmsalApiClient: HTML to Markdown conversion successful.")
except Exception as e:
logger.error(f"EmsalApiClient: Error during MarkItDown HTML to Markdown conversion for Emsal: {e}")
finally:
if temp_file_path and os.path.exists(temp_file_path):
os.remove(temp_file_path)
return markdown_text
async def get_decision_document_as_markdown(self, document_id: str) -> EmsalDocumentMarkdown:
"""
Retrieves a specific Emsal decision by ID and returns its content as Markdown.
Assumes Emsal /getDokuman endpoint returns JSON with HTML content in the 'data' field.
"""
document_api_url = f"{self.DOCUMENT_ENDPOINT}?id={document_id}"
source_url = f"{self.BASE_URL}{document_api_url}"
logger.info(f"EmsalApiClient: Fetching Emsal document for Markdown (ID: {document_id}) from {source_url}")
try:
response = await self.http_client.get(document_api_url)
response.raise_for_status()
# Emsal /getDokuman returns JSON with HTML in 'data' field (confirmed by user example)
response_json = response.json()
html_content_from_api = response_json.get("data")
if not isinstance(html_content_from_api, str) or not html_content_from_api.strip():
logger.warning(f"EmsalApiClient: Received empty or non-string HTML in 'data' field for Emsal ID {document_id}.")
return EmsalDocumentMarkdown(document_id=document_id, markdown_content=None, source_url=source_url)
markdown_content = self._clean_html_and_convert_to_markdown_emsal(html_content_from_api)
return EmsalDocumentMarkdown(
document_id=document_id,
markdown_content=markdown_content,
source_url=source_url
)
except httpx.RequestError as e:
logger.error(f"EmsalApiClient: HTTP error fetching Emsal document (ID: {document_id}): {e}")
raise
except ValueError as e:
logger.error(f"EmsalApiClient: ValueError processing Emsal document response (ID: {document_id}): {e}")
raise
except Exception as e:
logger.error(f"EmsalApiClient: General error processing Emsal document (ID: {document_id}): {e}")
raise
async def close_client_session(self):
"""Closes the HTTPX client session."""
if self.http_client and not self.http_client.is_closed:
await self.http_client.aclose()
logger.info("EmsalApiClient: HTTP client session closed.")
+104
View File
@@ -0,0 +1,104 @@
# emsal_mcp_module/models.py
from pydantic import BaseModel, Field, HttpUrl
from typing import List, Optional, Dict, Any
class EmsalDetailedSearchRequestData(BaseModel):
"""
Internal model for the 'data' object in the Emsal detailed search payload.
Field names use aliases to match the exact keys in the API payload
(e.g., "Bam Hukuk Mahkemeleri" with spaces).
The API expects empty strings for None/omitted optional fields.
"""
arananKelime: Optional[str] = ""
Bam_Hukuk_Mahkemeleri: Optional[str] = Field(None, alias="Bam Hukuk Mahkemeleri")
Hukuk_Mahkemeleri: Optional[str] = Field(None, alias="Hukuk Mahkemeleri")
# Add other specific court type fields from the form if they are separate keys in payload
# E.g., "Ceza Mahkemeleri", "İdari Mahkemeler" etc.
birimHukukMah: Optional[str] = Field("", description="List of selected Regional Civil Chambers, '+' separated.")
esasYil: Optional[str] = ""
esasIlkSiraNo: Optional[str] = ""
esasSonSiraNo: Optional[str] = ""
kararYil: Optional[str] = ""
kararIlkSiraNo: Optional[str] = ""
kararSonSiraNo: Optional[str] = ""
baslangicTarihi: Optional[str] = ""
bitisTarihi: Optional[str] = ""
siralama: str # Mandatory in payload example
siralamaDirection: str # Mandatory in payload example
pageSize: int
pageNumber: int
class Config:
populate_by_name = True # Enables use of alias in serialization (when dumping to dict for payload)
# anystr_strip_whitespace = True # Optional: strip whitespace from strings
class EmsalSearchRequest(BaseModel): # This is the model the MCP tool will accept
"""Model for Emsal detailed search request, with user-friendly field names."""
keyword: Optional[str] = Field(None, description="Keyword to search.")
selected_bam_civil_court: Optional[str] = Field(None, description="Selected BAM Civil Court (maps to 'Bam Hukuk Mahkemeleri' payload key).")
selected_civil_court: Optional[str] = Field(None, description="Selected Civil Court (maps to 'Hukuk Mahkemeleri' payload key).")
selected_regional_civil_chambers: Optional[List[str]] = Field(default_factory=list, description="Selected Regional Civil Chambers (for 'birimHukukMah', joined by '+').")
case_year_esas: Optional[str] = Field(None, description="Case year for 'Esas No'.")
case_start_seq_esas: Optional[str] = Field(None, description="Starting sequence for 'Esas No'.")
case_end_seq_esas: Optional[str] = Field(None, description="Ending sequence for 'Esas No'.")
decision_year_karar: Optional[str] = Field(None, description="Decision year for 'Karar No'.")
decision_start_seq_karar: Optional[str] = Field(None, description="Starting sequence for 'Karar No'.")
decision_end_seq_karar: Optional[str] = Field(None, description="Ending sequence for 'Karar No'.")
start_date: Optional[str] = Field(None, description="Start date for decision (DD.MM.YYYY).")
end_date: Optional[str] = Field(None, description="End date for decision (DD.MM.YYYY).")
sort_criteria: str = Field("1", description="Sorting criteria (e.g., 1: Esas No).")
sort_direction: str = Field("desc", description="Sorting direction ('asc' or 'desc').")
page_number: int = Field(default=1, ge=1)
page_size: int = Field(default=10, ge=1, le=100)
class EmsalApiDecisionEntry(BaseModel):
"""Model for an individual decision entry from the Emsal API search response."""
id: str
daire: Optional[str] = Field(None, description="The chamber/court that made the decision.")
esasNo: Optional[str] = Field(None)
kararNo: Optional[str] = Field(None)
kararTarihi: Optional[str] = Field(None)
arananKelime: Optional[str] = Field(None, description="Matched keyword from the search.")
durum: Optional[str] = Field(None, description="Status of the decision (e.g., 'KESİNLEŞMEDİ').")
# index: Optional[int] = None # Present in Emsal response, can be added if tool needs it
document_url: Optional[HttpUrl] = Field(None, description="URL to the full document, constructed by the client.")
class Config:
extra = 'ignore'
class EmsalApiResponseInnerData(BaseModel):
"""Model for the inner 'data' object in the Emsal API search response."""
data: List[EmsalApiDecisionEntry]
recordsTotal: int
recordsFiltered: int
draw: Optional[int] = Field(None, description="Draw counter from API, usually for DataTables.")
class EmsalApiResponse(BaseModel):
"""Model for the complete search response from the Emsal API."""
data: EmsalApiResponseInnerData
metadata: Optional[Dict[str, Any]] = Field(None, description="Optional metadata from API, if any.")
class EmsalDocumentMarkdown(BaseModel):
"""Model for an Emsal decision document, containing only Markdown content."""
document_id: str
markdown_content: Optional[str] = Field(None, description="The decision content converted to Markdown.")
source_url: HttpUrl
class CompactEmsalSearchResult(BaseModel):
"""A compact search result model for the MCP tool to return."""
decisions: List[EmsalApiDecisionEntry]
total_records: int
requested_page: int
page_size: int
+337
View File
@@ -0,0 +1,337 @@
# mcp_server_main.py
import asyncio
import atexit
import logging
import os
from pydantic import HttpUrl
from typing import Optional
# --- Logging Configuration Start ---
LOG_DIRECTORY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
if not os.path.exists(LOG_DIRECTORY):
os.makedirs(LOG_DIRECTORY)
LOG_FILE_PATH = os.path.join(LOG_DIRECTORY, "mcp_server.log")
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
log_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(threadName)s - %(message)s')
file_handler = logging.FileHandler(LOG_FILE_PATH, mode='a', encoding='utf-8')
file_handler.setFormatter(log_formatter)
file_handler.setLevel(logging.DEBUG)
root_logger.addHandler(file_handler)
console_handler = logging.StreamHandler()
console_handler.setFormatter(log_formatter)
console_handler.setLevel(logging.INFO)
root_logger.addHandler(console_handler)
logger = logging.getLogger(__name__)
# --- Logging Configuration End ---
from fastmcp import FastMCP
from pydantic import Field
# --- Module Imports ---
from yargitay_mcp_module.client import YargitayOfficialApiClient
from yargitay_mcp_module.models import (
YargitayDetailedSearchRequest, YargitayDocumentMarkdown, CompactYargitaySearchResult
)
from danistay_mcp_module.client import DanistayApiClient
from danistay_mcp_module.models import (
DanistayKeywordSearchRequest, DanistayDetailedSearchRequest,
DanistayDocumentMarkdown, CompactDanistaySearchResult
)
from emsal_mcp_module.client import EmsalApiClient
from emsal_mcp_module.models import (
EmsalSearchRequest, EmsalDocumentMarkdown, CompactEmsalSearchResult
)
from uyusmazlik_mcp_module.client import UyusmazlikApiClient
from uyusmazlik_mcp_module.models import (
UyusmazlikSearchRequest, UyusmazlikSearchResponse, UyusmazlikDocumentMarkdown
)
from anayasa_mcp_module.client import AnayasaMahkemesiApiClient # Norm Denetimi Client
from anayasa_mcp_module.bireysel_client import AnayasaBireyselBasvuruApiClient # Bireysel Başvuru Client
from anayasa_mcp_module.models import (
AnayasaNormDenetimiSearchRequest,
AnayasaSearchResult,
AnayasaDocumentMarkdown, # For Norm Denetimi documents
AnayasaBireyselReportSearchRequest, # For Bireysel Başvuru reports
AnayasaBireyselReportSearchResult, # For Bireysel Başvuru reports
AnayasaBireyselBasvuruDocumentMarkdown, # For Bireysel Başvuru documents
)
app = FastMCP(
name="TurkishLawResearchAssistantMCP",
instructions="MCP server for TR legal databases (Yargitay, Danistay, Emsal, Uyusmazlik, Anayasa-Norm, Anayasa-Bireysel).",
dependencies=["httpx", "beautifulsoup4", "markitdown", "pydantic", "aiohttp"]
)
# --- API Client Instances ---
yargitay_client_instance = YargitayOfficialApiClient()
danistay_client_instance = DanistayApiClient()
emsal_client_instance = EmsalApiClient()
uyusmazlik_client_instance = UyusmazlikApiClient()
anayasa_norm_client_instance = AnayasaMahkemesiApiClient()
anayasa_bireysel_client_instance = AnayasaBireyselBasvuruApiClient()
# --- MCP Tools for Yargitay ---
@app.tool()
async def search_yargitay_detailed(search_query: YargitayDetailedSearchRequest) -> CompactYargitaySearchResult:
"""Searches Yargitay (Court of Cassation) decisions using detailed criteria."""
logger.info(f"Tool 'search_yargitay_detailed' called: {search_query.model_dump_json(exclude_none=True, indent=2)}")
try:
api_response = await yargitay_client_instance.search_detailed_decisions(search_query)
if api_response.data:
return CompactYargitaySearchResult(
decisions=api_response.data.data,
total_records=api_response.data.recordsTotal,
requested_page=search_query.pageNumber,
page_size=search_query.pageSize)
logger.warning("API response for Yargitay search did not contain expected data structure.")
return CompactYargitaySearchResult(decisions=[], total_records=0, requested_page=search_query.pageNumber, page_size=search_query.pageSize)
except Exception as e:
logger.exception(f"Error in tool 'search_yargitay_detailed' with query: {search_query.model_dump_json(exclude_none=True, indent=2)}")
raise
@app.tool()
async def get_yargitay_document_markdown(document_id: str) -> YargitayDocumentMarkdown:
"""Retrieves a specific Yargitay decision by its ID and returns its content as Markdown."""
logger.info(f"Tool 'get_yargitay_document_markdown' called for ID: {document_id}")
if not document_id or not document_id.strip(): raise ValueError("Document ID must be a non-empty string.")
try:
return await yargitay_client_instance.get_decision_document_as_markdown(document_id)
except Exception as e:
logger.exception(f"Error in tool 'get_yargitay_document_markdown' for ID: {document_id}")
raise
# --- MCP Tools for Danistay ---
@app.tool()
async def search_danistay_by_keyword(search_query: DanistayKeywordSearchRequest) -> CompactDanistaySearchResult:
"""Searches Danıştay (Council of State) decisions using keywords."""
logger.info(f"Tool 'search_danistay_by_keyword' called: {search_query.model_dump_json(exclude_none=True, indent=2)}")
try:
api_response = await danistay_client_instance.search_keyword_decisions(search_query)
if api_response.data:
return CompactDanistaySearchResult(
decisions=api_response.data.data,
total_records=api_response.data.recordsTotal,
requested_page=search_query.pageNumber,
page_size=search_query.pageSize)
logger.warning("API response for Danistay keyword search did not contain expected data structure.")
return CompactDanistaySearchResult(decisions=[], total_records=0, requested_page=search_query.pageNumber, page_size=search_query.pageSize)
except Exception as e:
logger.exception(f"Error in tool 'search_danistay_by_keyword': {search_query.model_dump_json(exclude_none=True, indent=2)}")
raise
@app.tool()
async def search_danistay_detailed(search_query: DanistayDetailedSearchRequest) -> CompactDanistaySearchResult:
"""Performs a detailed search for Danıştay (Council of State) decisions."""
logger.info(f"Tool 'search_danistay_detailed' called: {search_query.model_dump_json(exclude_none=True, indent=2)}")
try:
api_response = await danistay_client_instance.search_detailed_decisions(search_query)
if api_response.data:
return CompactDanistaySearchResult(
decisions=api_response.data.data,
total_records=api_response.data.recordsTotal,
requested_page=search_query.pageNumber,
page_size=search_query.pageSize)
logger.warning("API response for Danistay detailed search did not contain expected data structure.")
return CompactDanistaySearchResult(decisions=[], total_records=0, requested_page=search_query.pageNumber, page_size=search_query.pageSize)
except Exception as e:
logger.exception(f"Error in tool 'search_danistay_detailed': {search_query.model_dump_json(exclude_none=True, indent=2)}")
raise
@app.tool()
async def get_danistay_document_markdown(document_id: str) -> DanistayDocumentMarkdown:
"""Retrieves a specific Danıştay decision by ID and returns its content as Markdown."""
logger.info(f"Tool 'get_danistay_document_markdown' called for ID: {document_id}")
if not document_id or not document_id.strip(): raise ValueError("Document ID must be a non-empty string for Danıştay.")
try:
return await danistay_client_instance.get_decision_document_as_markdown(document_id)
except Exception as e:
logger.exception(f"Error in tool 'get_danistay_document_markdown' for ID: {document_id}")
raise
# --- MCP Tools for Emsal ---
@app.tool()
async def search_emsal_detailed_decisions(search_query: EmsalSearchRequest) -> CompactEmsalSearchResult:
"""Searches for Emsal (UYAP Precedent) decisions using detailed criteria."""
logger.info(f"Tool 'search_emsal_detailed_decisions' called: {search_query.model_dump_json(exclude_none=True, indent=2)}")
try:
api_response = await emsal_client_instance.search_detailed_decisions(search_query)
if api_response.data:
return CompactEmsalSearchResult(
decisions=api_response.data.data,
total_records=api_response.data.totalRecords if api_response.data.totalRecords is not None else 0,
requested_page=search_query.page_number,
page_size=search_query.page_size
)
logger.warning("API response for Emsal search did not contain expected data structure.")
return CompactEmsalSearchResult(decisions=[], total_records=0, requested_page=search_query.page_number, page_size=search_query.page_size)
except Exception as e:
logger.exception(f"Error in tool 'search_emsal_detailed_decisions': {search_query.model_dump_json(exclude_none=True, indent=2)}")
raise
@app.tool()
async def get_emsal_document_markdown(document_id: str) -> EmsalDocumentMarkdown:
"""Retrieves a specific Emsal decision by ID and returns its content as Markdown."""
logger.info(f"Tool 'get_emsal_document_markdown' called for ID: {document_id}")
if not document_id or not document_id.strip(): raise ValueError("Document ID required for Emsal.")
try:
return await emsal_client_instance.get_decision_document_as_markdown(document_id)
except Exception as e:
logger.exception(f"Error in tool 'get_emsal_document_markdown' for ID: {document_id}")
raise
# --- MCP Tools for Uyusmazlik ---
@app.tool()
async def search_uyusmazlik_decisions(
search_params: UyusmazlikSearchRequest
) -> UyusmazlikSearchResponse:
"""Searches for Uyuşmazlık Mahkemesi decisions using various criteria from the form."""
logger.info(f"Tool 'search_uyusmazlik_decisions' called with params: {search_params.model_dump_json(exclude_none=True, indent=2)}")
try:
return await uyusmazlik_client_instance.search_decisions(search_params)
except Exception as e:
logger.exception(f"Error in tool 'search_uyusmazlik_decisions': {search_params.model_dump_json(exclude_none=True, indent=2)}")
raise
@app.tool()
async def get_uyusmazlik_document_markdown_from_url(document_url: HttpUrl) -> UyusmazlikDocumentMarkdown:
"""
Retrieves a specific Uyuşmazlık Mahkemesi decision from its full URL
and returns its content as Markdown.
The URL is typically obtained from the 'search_uyusmazlik_decisions' tool results.
"""
logger.info(f"Tool 'get_uyusmazlik_document_markdown_from_url' called for URL: {str(document_url)}")
if not document_url:
raise ValueError("Document URL (document_url) is required for Uyuşmazlık document retrieval.")
try:
return await uyusmazlik_client_instance.get_decision_document_as_markdown(str(document_url))
except Exception as e:
logger.exception(f"Error in tool 'get_uyusmazlik_document_markdown_from_url' for URL: {str(document_url)}")
raise
# --- MCP Tools for Anayasa Mahkemesi (Norm Denetimi) ---
@app.tool()
async def search_anayasa_norm_denetimi_decisions(
search_query: AnayasaNormDenetimiSearchRequest
) -> AnayasaSearchResult:
"""
Searches Anayasa Mahkemesi (Constitutional Court) Norm Denetimi decisions
using various criteria from the official search form. This is for https://normkararlarbilgibankasi.anayasa.gov.tr.
"""
logger.info(f"Tool 'search_anayasa_norm_denetimi_decisions' called: {search_query.model_dump_json(exclude_none=True, indent=2)}")
try:
return await anayasa_norm_client_instance.search_norm_denetimi_decisions(search_query)
except Exception as e:
logger.exception(f"Error in tool 'search_anayasa_norm_denetimi_decisions': {search_query.model_dump_json(exclude_none=True, indent=2)}")
raise
@app.tool()
async def get_anayasa_norm_denetimi_document_markdown(
document_url: str = Field(..., description="The URL path of the AYM Norm Denetimi decision (e.g., /ND/YYYY/NN) or full https URL from normkararlarbilgibankasi.anayasa.gov.tr."),
page_number: Optional[int] = Field(1, ge=1, description="Page number for paginated Markdown content, 1-indexed. Default is 1 for the first 5,000 characters.") # Corrected chunk size in description
) -> AnayasaDocumentMarkdown:
"""
Retrieves a specific Anayasa Mahkemesi (Norm Denetimi) decision
from its URL and returns its content as paginated Markdown. This is for https://normkararlarbilgibankasi.anayasa.gov.tr.
Content is paginated if it exceeds 5,000 characters. Use 'page_number' to get subsequent pages.
"""
logger.info(f"Tool 'get_anayasa_norm_denetimi_document_markdown' called for URL: {document_url}, Page: {page_number}")
if not document_url or not document_url.strip():
raise ValueError("Document URL is required for Anayasa Norm Denetimi document retrieval.")
current_page_to_fetch = page_number if page_number is not None and page_number >= 1 else 1
try:
return await anayasa_norm_client_instance.get_decision_document_as_markdown(document_url, page_number=current_page_to_fetch)
except Exception as e:
logger.exception(f"Error in tool 'get_anayasa_norm_denetimi_document_markdown' for URL: {document_url}, Page: {current_page_to_fetch}")
raise
# --- MCP Tools for Anayasa Mahkemesi (Bireysel Başvuru Karar Raporu & Belgeler) ---
@app.tool()
async def search_anayasa_bireysel_basvuru_report(
search_query: AnayasaBireyselReportSearchRequest
) -> AnayasaBireyselReportSearchResult:
"""
Searches Anayasa Mahkemesi (Constitutional Court) Bireysel Başvuru (Individual Application)
decisions and generates a 'Karar Arama Raporu' (Decision Search Report).
This is for https://kararlarbilgibankasi.anayasa.gov.tr and uses the KararBulteni=1 parameter.
The report typically displays 10 decisions per page by default.
"""
logger.info(f"Tool 'search_anayasa_bireysel_basvuru_report' called: {search_query.model_dump_json(exclude_none=True, indent=2)}")
try:
return await anayasa_bireysel_client_instance.search_bireysel_basvuru_report(search_query)
except Exception as e:
logger.exception(f"Error in tool 'search_anayasa_bireysel_basvuru_report': {search_query.model_dump_json(exclude_none=True, indent=2)}")
raise
@app.tool()
async def get_anayasa_bireysel_basvuru_document_markdown(
document_url_path: str = Field(..., description="The URL path of the AYM Bireysel Başvuru decision (e.g., /BB/YYYY/NNNN) from kararlarbilgibankasi.anayasa.gov.tr."),
page_number: Optional[int] = Field(1, ge=1, description="Page number for paginated Markdown content, 1-indexed. Default is 1 for the first 5,000 characters.")
) -> AnayasaBireyselBasvuruDocumentMarkdown:
"""
Retrieves a specific Anayasa Mahkemesi Bireysel Başvuru (Individual Application) decision
from its URL path (e.g., /BB/YYYY/NNNN found in report results) and returns its content as paginated Markdown.
This is for https://kararlarbilgibankasi.anayasa.gov.tr.
Content is paginated if it exceeds 5,000 characters. Use 'page_number' to get subsequent pages.
"""
logger.info(f"Tool 'get_anayasa_bireysel_basvuru_document_markdown' called for URL path: {document_url_path}, Page: {page_number}")
if not document_url_path or not document_url_path.strip() or not document_url_path.startswith("/BB/"):
raise ValueError("Document URL path (e.g., /BB/YYYY/NNNN) is required for Anayasa Bireysel Başvuru document retrieval.")
current_page_to_fetch = page_number if page_number is not None and page_number >= 1 else 1
try:
return await anayasa_bireysel_client_instance.get_decision_document_as_markdown(document_url_path, page_number=current_page_to_fetch)
except Exception as e:
logger.exception(f"Error in tool 'get_anayasa_bireysel_basvuru_document_markdown' for URL path: {document_url_path}, Page: {current_page_to_fetch}")
raise
# --- Application Shutdown Handling ---
def perform_cleanup():
logger.info("MCP Server performing cleanup...")
try:
loop = asyncio.get_event_loop()
if loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
except RuntimeError: # pragma: no cover
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
clients_to_close = [
globals().get('yargitay_client_instance'),
globals().get('danistay_client_instance'),
globals().get('emsal_client_instance'),
globals().get('uyusmazlik_client_instance'),
globals().get('anayasa_norm_client_instance'),
globals().get('anayasa_bireysel_client_instance')
]
for client_instance in clients_to_close:
if client_instance and hasattr(client_instance, 'close_client_session') and callable(client_instance.close_client_session):
logger.info(f"Closing client session for {client_instance.__class__.__name__} via atexit.")
loop.run_until_complete(client_instance.close_client_session())
except Exception as e:
logger.error(f"Error during atexit cleanup: {e}")
logger.info("MCP Server atexit cleanup attempt finished.")
atexit.register(perform_cleanup)
# --- Main Entry Point ---
if __name__ == "__main__":
logger.info(f"Starting {app.name} server (main execution block)...")
logger.info(f"Logs will be written to: {LOG_FILE_PATH}")
try:
asyncio.run(app.run_sse_async(host="127.0.0.1", port=8890, log_level="debug"))
except KeyboardInterrupt:
logger.info("Server shut down by user (KeyboardInterrupt).")
except Exception as e:
logger.exception("Server failed to start or crashed.")
finally:
logger.info(f"{app.name} server has shut down.")
+6
View File
@@ -0,0 +1,6 @@
fastmcp
httpx
beautifulsoup4
markitdown
pydantic
aiohttp
View File
+242
View File
@@ -0,0 +1,242 @@
# uyusmazlik_mcp_module/client.py
import httpx
import aiohttp
from bs4 import BeautifulSoup
from typing import Dict, Any, List, Optional, Union, Tuple
import logging
import html
import re
import tempfile
import os
from markitdown import MarkItDown
from urllib.parse import urljoin, urlencode # urlencode for aiohttp form data
from .models import (
UyusmazlikSearchRequest,
UyusmazlikApiDecisionEntry,
UyusmazlikSearchResponse,
UyusmazlikDocumentMarkdown,
UyusmazlikBolumEnum,
UyusmazlikTuruEnum,
UyusmazlikKararSonucuEnum
)
logger = logging.getLogger(__name__)
if not logger.hasHandlers():
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
# --- Mappings from user-friendly Enum values to API IDs ---
BOLUM_ENUM_TO_ID_MAP = {
UyusmazlikBolumEnum.CEZA_BOLUMU: "f6b74320-f2d7-4209-ad6e-c6df180d4e7c",
UyusmazlikBolumEnum.GENEL_KURUL_KARARLARI: "e4ca658d-a75a-4719-b866-b2d2f1c3b1d9",
UyusmazlikBolumEnum.HUKUK_BOLUMU: "96b26fc4-ef8e-4a4f-a9cc-a3de89952aa1",
UyusmazlikBolumEnum.TUMU: "" # Represents "...Seçiniz..." or all
}
UYUSMAZLIK_TURU_ENUM_TO_ID_MAP = {
UyusmazlikTuruEnum.GOREV_UYUSMAZLIGI: "7b1e2cd3-8f09-418a-921c-bbe501e1740c",
UyusmazlikTuruEnum.HUKUM_UYUSMAZLIGI: "19b88402-172b-4c1d-8339-595c942a89f5",
UyusmazlikTuruEnum.TUMU: "" # Represents "...Seçiniz..." or all
}
KARAR_SONUCU_ENUM_TO_ID_MAP = {
# These IDs are from the form HTML provided by the user
UyusmazlikKararSonucuEnum.HUKUM_UYUSMAZLIGI_OLMADIGINA_DAIR: "6f47d87f-dcb5-412e-9878-000385dba1d9",
UyusmazlikKararSonucuEnum.HUKUM_UYUSMAZLIGI_OLDUGUNA_DAIR: "5a01742a-c440-4c4a-ba1f-da20837cffed",
# Add all other 'Karar Sonucu' enum members and their corresponding GUIDs
# by inspecting the 'KararSonucuList' checkboxes in the provided form HTML.
}
# --- End Mappings ---
class UyusmazlikApiClient:
BASE_URL = "https://kararlar.uyusmazlik.gov.tr"
SEARCH_ENDPOINT = "/Arama/Search"
# Individual documents are fetched by their full URLs obtained from search results.
def __init__(self, request_timeout: float = 30.0):
self.request_timeout = request_timeout # Store timeout for aiohttp and httpx
# Headers for aiohttp search. httpx for docs will create its own.
self.default_aiohttp_search_headers = {
"Accept": "*/*", # Mimicking browser headers provided by user
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
"X-Requested-With": "XMLHttpRequest",
"Origin": self.BASE_URL,
"Referer": self.BASE_URL + "/",
}
async def search_decisions(
self,
params: UyusmazlikSearchRequest
) -> UyusmazlikSearchResponse:
bolum_id_for_api = BOLUM_ENUM_TO_ID_MAP.get(params.bolum, "")
uyusmazlik_id_for_api = UYUSMAZLIK_TURU_ENUM_TO_ID_MAP.get(params.uyusmazlik_turu, "")
form_data_list: List[Tuple[str, str]] = []
def add_to_form_data(key: str, value: Optional[str]):
# API expects empty strings for omitted optional fields based on user payload example
form_data_list.append((key, value or ""))
add_to_form_data("BolumId", bolum_id_for_api)
add_to_form_data("UyusmazlikId", uyusmazlik_id_for_api)
if params.karar_sonuclari:
for enum_member in params.karar_sonuclari:
api_id = KARAR_SONUCU_ENUM_TO_ID_MAP.get(enum_member)
if api_id: # Only add if a valid ID is found
form_data_list.append(('KararSonucuList', api_id))
add_to_form_data("EsasYil", params.esas_yil)
add_to_form_data("EsasSayisi", params.esas_sayisi)
add_to_form_data("KararYil", params.karar_yil)
add_to_form_data("KararSayisi", params.karar_sayisi)
add_to_form_data("KanunNo", params.kanun_no)
add_to_form_data("KararDateBegin", params.karar_date_begin)
add_to_form_data("KararDateEnd", params.karar_date_end)
add_to_form_data("ResmiGazeteSayi", params.resmi_gazete_sayi)
add_to_form_data("ResmiGazeteDate", params.resmi_gazete_date)
add_to_form_data("Icerik", params.icerik)
add_to_form_data("Tumce", params.tumce)
add_to_form_data("WildCard", params.wild_card)
add_to_form_data("Hepsi", params.hepsi)
add_to_form_data("Herhangibirisi", params.herhangi_birisi)
add_to_form_data("NotHepsi", params.not_hepsi)
# X-Requested-With is handled by default_aiohttp_search_headers
search_url = urljoin(self.BASE_URL, self.SEARCH_ENDPOINT)
# For aiohttp, data for application/x-www-form-urlencoded should be a dict or str.
# Using urlencode for list of tuples.
encoded_form_payload = urlencode(form_data_list, encoding='UTF-8')
logger.info(f"UyusmazlikApiClient (aiohttp): Performing search to {search_url} with form_data: {encoded_form_payload}")
html_content = ""
aiohttp_headers = self.default_aiohttp_search_headers.copy()
aiohttp_headers["Content-Type"] = "application/x-www-form-urlencoded; charset=UTF-8"
try:
# Create a new session for each call for simplicity with aiohttp here
async with aiohttp.ClientSession(headers=aiohttp_headers) as session:
async with session.post(search_url, data=encoded_form_payload, timeout=self.request_timeout) as response:
response.raise_for_status() # Raises ClientResponseError for 400-599
html_content = await response.text(encoding='utf-8') # Ensure correct encoding
logger.debug("UyusmazlikApiClient (aiohttp): Received HTML response for search.")
except aiohttp.ClientError as e:
logger.error(f"UyusmazlikApiClient (aiohttp): HTTP client error during search: {e}")
raise # Re-raise to be handled by the MCP tool
except Exception as e:
logger.error(f"UyusmazlikApiClient (aiohttp): Error processing search request: {e}")
raise
# --- HTML Parsing (remains the same as previous version) ---
soup = BeautifulSoup(html_content, 'html.parser')
total_records_text_div = soup.find("div", class_="pull-right label label-important")
total_records = None
if total_records_text_div:
match_records = re.search(r'(\d+)\s*adet kayıt bulundu', total_records_text_div.get_text(strip=True))
if match_records:
total_records = int(match_records.group(1))
result_table = soup.find("table", class_="table-hover")
processed_decisions: List[UyusmazlikApiDecisionEntry] = []
if result_table:
rows = result_table.find_all("tr")
if len(rows) > 1: # Skip header row
for row in rows[1:]:
cols = row.find_all('td')
if len(cols) >= 5:
try:
popover_div = cols[0].find("div", attrs={"data-rel": "popover"})
popover_content_raw = popover_div["data-content"] if popover_div and popover_div.has_attr("data-content") else None
link_tag = cols[0].find('a')
doc_relative_url = link_tag['href'] if link_tag and link_tag.has_attr('href') else None
if not doc_relative_url: continue
document_url_str = urljoin(self.BASE_URL, doc_relative_url)
pdf_link_tag = cols[5].find('a', href=re.compile(r'\.pdf$', re.IGNORECASE)) if len(cols) > 5 else None
pdf_url_str = urljoin(self.BASE_URL, pdf_link_tag['href']) if pdf_link_tag and pdf_link_tag.has_attr('href') else None
decision_data_parsed = {
"karar_sayisi": cols[0].get_text(strip=True),
"esas_sayisi": cols[1].get_text(strip=True),
"bolum": cols[2].get_text(strip=True),
"uyusmazlik_konusu": cols[3].get_text(strip=True),
"karar_sonucu": cols[4].get_text(strip=True),
"popover_content": html.unescape(popover_content_raw) if popover_content_raw else None,
"document_url": document_url_str,
"pdf_url": pdf_url_str
}
decision_model = UyusmazlikApiDecisionEntry(**decision_data_parsed)
processed_decisions.append(decision_model)
except Exception as e:
logger.warning(f"UyusmazlikApiClient: Could not parse decision row. Row content: {row.get_text(strip=True, separator=' | ')}, Error: {e}")
return UyusmazlikSearchResponse(
decisions=processed_decisions,
total_records_found=total_records
)
def _convert_html_to_markdown_uyusmazlik(self, full_decision_html_content: str) -> Optional[str]:
"""Converts direct HTML content (from an Uyuşmazlık decision page) to Markdown."""
if not full_decision_html_content:
return None
processed_html = html.unescape(full_decision_html_content)
# As per user request, pass the full (unescaped) HTML to MarkItDown
html_input_for_markdown = processed_html
markdown_text = None
temp_file_path = None
try:
md_converter = MarkItDown(enable_plugins=False)
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as tmp_file:
tmp_file.write(html_input_for_markdown)
temp_file_path = tmp_file.name
conversion_result = md_converter.convert(temp_file_path)
markdown_text = conversion_result.text_content
logger.info("UyusmazlikApiClient: HTML to Markdown conversion successful.")
except Exception as e:
logger.error(f"UyusmazlikApiClient: Error during MarkItDown HTML to Markdown conversion: {e}")
finally:
if temp_file_path and os.path.exists(temp_file_path):
os.remove(temp_file_path)
return markdown_text
async def get_decision_document_as_markdown(self, document_url: str) -> UyusmazlikDocumentMarkdown:
"""
Retrieves a specific Uyuşmazlık decision from its full URL and returns content as Markdown.
"""
logger.info(f"UyusmazlikApiClient (httpx for docs): Fetching Uyuşmazlık document for Markdown from URL: {document_url}")
try:
# Using a new httpx.AsyncClient instance for this GET request for simplicity
async with httpx.AsyncClient(verify=False, timeout=self.request_timeout) as doc_fetch_client:
get_response = await doc_fetch_client.get(document_url, headers={"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"})
get_response.raise_for_status()
html_content_from_api = get_response.text
if not isinstance(html_content_from_api, str) or not html_content_from_api.strip():
logger.warning(f"UyusmazlikApiClient: Received empty or non-string HTML from URL {document_url}.")
return UyusmazlikDocumentMarkdown(source_url=document_url, markdown_content=None)
markdown_content = self._convert_html_to_markdown_uyusmazlik(html_content_from_api)
return UyusmazlikDocumentMarkdown(source_url=document_url, markdown_content=markdown_content)
except httpx.RequestError as e:
logger.error(f"UyusmazlikApiClient (httpx for docs): HTTP error fetching Uyuşmazlık document from {document_url}: {e}")
raise
except Exception as e:
logger.error(f"UyusmazlikApiClient (httpx for docs): General error processing Uyuşmazlık document from {document_url}: {e}")
raise
async def close_client_session(self):
logger.info("UyusmazlikApiClient: No persistent client session from __init__ to close.")
+86
View File
@@ -0,0 +1,86 @@
# uyusmazlik_mcp_module/models.py
from pydantic import BaseModel, Field, HttpUrl
from typing import List, Optional
from enum import Enum
# Enum definitions for user-friendly input based on the provided HTML form
class UyusmazlikBolumEnum(str, Enum):
"""User-friendly names for 'BolumId'."""
TUMU = "" # Represents "...Seçiniz..." or all
CEZA_BOLUMU = "Ceza Bölümü"
GENEL_KURUL_KARARLARI = "Genel Kurul Kararları"
HUKUK_BOLUMU = "Hukuk Bölümü"
class UyusmazlikTuruEnum(str, Enum):
"""User-friendly names for 'UyusmazlikId'."""
TUMU = "" # Represents "...Seçiniz..." or all
GOREV_UYUSMAZLIGI = "Görev Uyuşmazlığı"
HUKUM_UYUSMAZLIGI = "Hüküm Uyuşmazlığı"
class UyusmazlikKararSonucuEnum(str, Enum): # Based on checkbox text in the form
"""User-friendly names for 'KararSonucuList' items."""
HUKUM_UYUSMAZLIGI_OLMADIGINA_DAIR = "Hüküm Uyuşmazlığı Olmadığına Dair"
HUKUM_UYUSMAZLIGI_OLDUGUNA_DAIR = "Hüküm Uyuşmazlığı Olduğuna Dair"
# Add other "Karar Sonucu" options from the form's checkboxes as Enum members
# Example: GOREVLI_YARGI_YERI_ADLI = "Görevli Yargı Yeri Belirlenmesine Dair (Adli Yargı)"
# The client will map these enum values (which are strings) to their respective IDs.
class UyusmazlikSearchRequest(BaseModel): # This is the model the MCP tool will accept
"""Model for Uyuşmazlık Mahkemesi search request using user-friendly terms."""
icerik: Optional[str] = Field("", description="Keyword or content for main text search (Icerik).")
bolum: Optional[UyusmazlikBolumEnum] = Field(
UyusmazlikBolumEnum.TUMU,
description="Select the department (Bölüm)."
)
uyusmazlik_turu: Optional[UyusmazlikTuruEnum] = Field(
UyusmazlikTuruEnum.TUMU,
description="Select the type of dispute (Uyuşmazlık)."
)
# User provides a list of user-friendly names for Karar Sonucu
karar_sonuclari: Optional[List[UyusmazlikKararSonucuEnum]] = Field( # Changed to list of Enums
default_factory=list,
description="List of desired 'Karar Sonucu' types."
)
esas_yil: Optional[str] = Field("", description="Case year ('Esas Yılı').")
esas_sayisi: Optional[str] = Field("", description="Case number ('Esas Sayısı').")
karar_yil: Optional[str] = Field("", description="Decision year ('Karar Yılı').")
karar_sayisi: Optional[str] = Field("", description="Decision number ('Karar Sayısı').")
kanun_no: Optional[str] = Field("", description="Relevant Law Number ('KanunNo').")
karar_date_begin: Optional[str] = Field("", description="Decision start date (DD.MM.YYYY) ('KararDateBegin').")
karar_date_end: Optional[str] = Field("", description="Decision end date (DD.MM.YYYY) ('KararDateEnd').")
resmi_gazete_sayi: Optional[str] = Field("", description="Official Gazette number ('ResmiGazeteSayi').")
resmi_gazete_date: Optional[str] = Field("", description="Official Gazette date (DD.MM.YYYY) ('ResmiGazeteDate').")
# Detailed text search fields from the "icerikDetail" section of the form
tumce: Optional[str] = Field("", description="Exact phrase search ('Tumce').")
wild_card: Optional[str] = Field("", description="Search for phrase and its inflections ('WildCard').") # Changed from WildCard for Pythonic name
hepsi: Optional[str] = Field("", description="Search for texts containing all specified words ('Hepsi').")
herhangi_birisi: Optional[str] = Field("", description="Search for texts containing any of the specified words ('Herhangibirisi').")
not_hepsi: Optional[str] = Field("", description="Exclude texts containing these specified words ('NotHepsi').")
class UyusmazlikApiDecisionEntry(BaseModel):
"""Model for an individual decision entry parsed from Uyuşmazlık API's HTML search response."""
karar_sayisi: Optional[str] = Field(None)
esas_sayisi: Optional[str] = Field(None)
bolum: Optional[str] = Field(None)
uyusmazlik_konusu: Optional[str] = Field(None)
karar_sonucu: Optional[str] = Field(None)
popover_content: Optional[str] = Field(None, description="Summary/description from popover.")
document_url: HttpUrl # Full URL to the decision document HTML page
pdf_url: Optional[HttpUrl] = Field(None, description="Direct URL to PDF if available.")
class UyusmazlikSearchResponse(BaseModel): # This is what the MCP tool will return
"""Response model for Uyuşmazlık Mahkemesi search results for the MCP tool."""
decisions: List[UyusmazlikApiDecisionEntry]
total_records_found: Optional[int] = Field(None, description="Total number of records found for the query, if available.")
class UyusmazlikDocumentMarkdown(BaseModel):
"""Model for an Uyuşmazlık decision document, containing only Markdown content."""
source_url: HttpUrl # The URL from which the content was fetched
markdown_content: Optional[str] = Field(None, description="The decision content converted to Markdown.")
View File
+175
View File
@@ -0,0 +1,175 @@
# yargitay_mcp_module/client.py
import httpx
from bs4 import BeautifulSoup # Still needed for pre-processing HTML before markitdown
from typing import Dict, Any, List, Optional
import logging
import html
import re
import tempfile
import os
from markitdown import MarkItDown
from .models import (
YargitayDetailedSearchRequest,
YargitayApiSearchResponse,
YargitayApiDecisionEntry,
YargitayDocumentMarkdown,
CompactYargitaySearchResult
)
logger = logging.getLogger(__name__)
# Basic logging configuration if no handlers are configured
if not logger.hasHandlers():
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
class YargitayOfficialApiClient:
"""
API Client for Yargitay's official decision search system.
Targets the detailed search endpoint (e.g., /aramadetaylist) based on user-provided payload.
"""
BASE_URL = "https://karararama.yargitay.gov.tr"
# The form action was "/detayliArama". This often maps to an API endpoint like "/aramadetaylist".
# This should be confirmed with the actual API.
DETAILED_SEARCH_ENDPOINT = "/aramadetaylist"
DOCUMENT_ENDPOINT = "/getDokuman"
def __init__(self, request_timeout: float = 60.0):
self.http_client = httpx.AsyncClient(
base_url=self.BASE_URL,
headers={
"Content-Type": "application/json; charset=UTF-8",
"Accept": "application/json, text/plain, */*",
"X-Requested-With": "XMLHttpRequest",
"X-KL-KIS-Ajax-Request": "Ajax_Request", # Seen in a Yargitay client example
"Referer": f"{self.BASE_URL}/" # Some APIs might check referer
},
timeout=request_timeout,
verify=False # SSL verification disabled as per original user code - use with caution
)
async def search_detailed_decisions(
self,
search_params: YargitayDetailedSearchRequest
) -> YargitayApiSearchResponse:
"""
Performs a detailed search for decisions in Yargitay
using the structured search_params.
"""
# Create the main payload structure with the 'data' key
request_payload = {"data": search_params.model_dump(exclude_none=True, by_alias=True)}
logger.info(f"YargitayOfficialApiClient: Performing detailed search with payload: {request_payload}")
try:
response = await self.http_client.post(self.DETAILED_SEARCH_ENDPOINT, json=request_payload)
response.raise_for_status() # Raise an exception for HTTP 4xx or 5xx status codes
response_json_data = response.json()
# Validate and parse the response using Pydantic models
api_response = YargitayApiSearchResponse(**response_json_data)
# Populate the document_url for each decision entry
if api_response.data and api_response.data.data:
for decision_item in api_response.data.data:
decision_item.document_url = f"{self.BASE_URL}{self.DOCUMENT_ENDPOINT}?id={decision_item.id}"
return api_response
except httpx.RequestError as e:
logger.error(f"YargitayOfficialApiClient: HTTP request error during detailed search: {e}")
raise # Re-raise to be handled by the calling MCP tool
except Exception as e: # Catches Pydantic ValidationErrors as well
logger.error(f"YargitayOfficialApiClient: Error processing or validating detailed search response: {e}")
raise
def _convert_html_to_markdown(self, html_from_api_data_field: str) -> Optional[str]:
"""
Takes raw HTML string (from Yargitay API 'data' field for a document),
pre-processes it, and converts it to Markdown using MarkItDown.
Returns only the Markdown string or None if conversion fails.
"""
if not html_from_api_data_field:
return None
# Pre-process HTML: unescape entities and fix common escaped sequences
# Based on user's original fix_html_content
processed_html = html.unescape(html_from_api_data_field)
processed_html = processed_html.replace('\\"', '"')
processed_html = processed_html.replace('\\r\\n', '\n')
processed_html = processed_html.replace('\\n', '\n')
processed_html = processed_html.replace('\\t', '\t')
# MarkItDown often works best with a full HTML document structure.
# The Yargitay /getDokuman response already provides a full <html>...</html> string.
# If it were just a fragment, we might wrap it like:
# html_to_convert = f"<html><head><meta charset=\"UTF-8\"></head><body>{processed_html}</body></html>"
# But since it's already a full HTML string in "data":
html_to_convert = processed_html
markdown_output = None
temp_file_path = None
try:
md_converter = MarkItDown(enable_plugins=False) # Plugins disabled as per basic usage
# Write the HTML to a temporary file for MarkItDown to process
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as tmp_html_file:
tmp_html_file.write(html_to_convert)
temp_file_path = tmp_html_file.name
conversion_result = md_converter.convert(temp_file_path)
markdown_output = conversion_result.text_content
logger.info("Successfully converted HTML to Markdown.")
except Exception as e:
logger.error(f"Error during MarkItDown HTML to Markdown conversion: {e}")
finally:
if temp_file_path and os.path.exists(temp_file_path):
os.remove(temp_file_path) # Clean up the temporary file
return markdown_output
async def get_decision_document_as_markdown(self, document_id: str) -> YargitayDocumentMarkdown:
"""
Retrieves a specific Yargitay decision by its ID and returns its content
as Markdown.
Based on user-provided /getDokuman response structure.
"""
document_api_url = f"{self.DOCUMENT_ENDPOINT}?id={document_id}"
source_url = f"{self.BASE_URL}{document_api_url}" # The original URL of the document
logger.info(f"YargitayOfficialApiClient: Fetching document for Markdown conversion (ID: {document_id})")
try:
response = await self.http_client.get(document_api_url)
response.raise_for_status()
# Expecting JSON response with HTML content in the 'data' field
response_json = response.json()
html_content_from_api = response_json.get("data")
if not isinstance(html_content_from_api, str):
logger.error(f"YargitayOfficialApiClient: 'data' field in API response is not a string or not found (ID: {document_id}).")
raise ValueError("Expected HTML content not found in API response's 'data' field.")
markdown_content = self._convert_html_to_markdown(html_content_from_api)
return YargitayDocumentMarkdown(
document_id=document_id,
markdown_content=markdown_content,
source_url=source_url
)
except httpx.RequestError as e:
logger.error(f"YargitayOfficialApiClient: HTTP error fetching document for Markdown (ID: {document_id}): {e}")
raise
except ValueError as e: # For JSON parsing errors or missing 'data' field
logger.error(f"YargitayOfficialApiClient: Error processing document response for Markdown (ID: {document_id}): {e}")
raise
except Exception as e: # For other unexpected errors
logger.error(f"YargitayOfficialApiClient: General error fetching/processing document for Markdown (ID: {document_id}): {e}")
raise
async def close_client_session(self):
"""Closes the HTTPX client session."""
await self.http_client.aclose()
logger.info("YargitayOfficialApiClient: HTTP client session closed.")
+76
View File
@@ -0,0 +1,76 @@
# yargitay_mcp_module/models.py
from pydantic import BaseModel, Field, HttpUrl
from typing import List, Optional, Dict, Any
class YargitayDetailedSearchRequest(BaseModel):
"""
Model for the 'data' object sent in the request payload
to Yargitay's detailed search endpoint (e.g., /aramadetaylist).
Based on the payload provided by the user.
"""
arananKelime: Optional[str] = Field("", description="Keyword to search for.")
# Department/Board selection. Based on user provided payload.
# birimYrg* fields seem to be the ones used for filtering.
birimYrgKurulDaire: Optional[str] = Field("", description="Yargitay Board Unit (e.g., 'Hukuk Genel Kurulu').")
birimYrgHukukDaire: Optional[str] = Field("", description="Yargitay Civil Chamber (e.g., '1. Hukuk Dairesi').")
birimYrgCezaDaire: Optional[str] = Field("", description="Yargitay Criminal Chamber.")
esasYil: Optional[str] = Field("", description="Case year for 'Esas No'.")
esasIlkSiraNo: Optional[str] = Field("", description="Starting sequence number for 'Esas No'.")
esasSonSiraNo: Optional[str] = Field("", description="Ending sequence number for 'Esas No'.")
kararYil: Optional[str] = Field("", description="Decision year for 'Karar No'.")
kararIlkSiraNo: Optional[str] = Field("", description="Starting sequence number for 'Karar No'.")
kararSonSiraNo: Optional[str] = Field("", description="Ending sequence number for 'Karar No'.")
baslangicTarihi: Optional[str] = Field("", description="Start date for decision search (DD.MM.YYYY).")
bitisTarihi: Optional[str] = Field("", description="End date for decision search (DD.MM.YYYY).")
siralama: Optional[str] = Field("3", description="Sorting criteria (1: Esas No, 2: Karar No, 3: Karar Tarihi).") # Default to 'Karar Tarihine Göre'
siralamaDirection: Optional[str] = Field("desc", description="Sorting direction ('asc' or 'desc').") # Default to 'Büyükten Küçüğe'
pageSize: int = Field(10, ge=1, le=100, description="Number of results per page.")
pageNumber: int = Field(1, ge=1, description="Page number to retrieve.")
class YargitayApiDecisionEntry(BaseModel):
"""Model for an individual decision entry from the Yargitay API search response."""
id: str # Unique system ID of the decision
daire: Optional[str] = Field(None, description="The chamber that made the decision.")
esasNo: Optional[str] = Field(None, alias="esasNo", description="Case registry number ('Esas No').")
kararNo: Optional[str] = Field(None, alias="kararNo", description="Decision number ('Karar No').")
kararTarihi: Optional[str] = Field(None, alias="kararTarihi", description="Date of the decision.")
arananKelime: Optional[str] = Field(None, alias="arananKelime", description="Matched keyword in the search result item.")
# 'index' and 'siraNo' from API response are not critical for MCP tool, so omitted for brevity
# This field will be populated by the client after fetching the search list
document_url: Optional[HttpUrl] = Field(None, description="Direct URL to the decision document.")
class Config:
populate_by_name = True # To allow populating by alias from API response
class YargitayApiResponseInnerData(BaseModel):
"""Model for the inner 'data' object in the Yargitay API search response."""
data: List[YargitayApiDecisionEntry]
# draw: Optional[int] = None # Typically used by DataTables, not essential for MCP
recordsTotal: int # Total number of records matching the query
recordsFiltered: int # Total number of records after filtering (usually same as recordsTotal)
class YargitayApiSearchResponse(BaseModel):
"""Model for the complete search response from the Yargitay API."""
data: YargitayApiResponseInnerData
# metadata: Optional[Dict[str, Any]] = None # Optional metadata from API
class YargitayDocumentMarkdown(BaseModel):
"""Model for a Yargitay decision document, containing only Markdown content."""
document_id: str = Field(..., description="The unique ID of the document.")
markdown_content: Optional[str] = Field(None, description="The decision content converted to Markdown.")
source_url: HttpUrl = Field(..., description="The source URL of the original document.")
class CompactYargitaySearchResult(BaseModel):
"""A more compact search result model for the MCP tool to return."""
decisions: List[YargitayApiDecisionEntry]
total_records: int
requested_page: int
page_size: int