diff --git a/.gitignore b/.gitignore index 68bc17f..42f39a1 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/anayasa_mcp_module/__init__.py b/anayasa_mcp_module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/anayasa_mcp_module/bireysel_client.py b/anayasa_mcp_module/bireysel_client.py new file mode 100644 index 0000000..5c048f7 --- /dev/null +++ b/anayasa_mcp_module/bireysel_client.py @@ -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_input_for_markdown}") + 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.") \ No newline at end of file diff --git a/anayasa_mcp_module/client.py b/anayasa_mcp_module/client.py new file mode 100644 index 0000000..6a8fe19 --- /dev/null +++ b/anayasa_mcp_module/client.py @@ -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_input_for_markdown}") + 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

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.") \ No newline at end of file diff --git a/anayasa_mcp_module/models.py b/anayasa_mcp_module/models.py new file mode 100644 index 0000000..72a8d5a --- /dev/null +++ b/anayasa_mcp_module/models.py @@ -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 --- \ No newline at end of file diff --git a/danistay_mcp_module/__init__.py b/danistay_mcp_module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/danistay_mcp_module/client.py b/danistay_mcp_module/client.py new file mode 100644 index 0000000..96fb090 --- /dev/null +++ b/danistay_mcp_module/client.py @@ -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.") \ No newline at end of file diff --git a/danistay_mcp_module/models.py b/danistay_mcp_module/models.py new file mode 100644 index 0000000..c139f42 --- /dev/null +++ b/danistay_mcp_module/models.py @@ -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 \ No newline at end of file diff --git a/emsal_mcp_module/__init__.py b/emsal_mcp_module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emsal_mcp_module/client.py b/emsal_mcp_module/client.py new file mode 100644 index 0000000..29a3a7c --- /dev/null +++ b/emsal_mcp_module/client.py @@ -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_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.") \ No newline at end of file diff --git a/emsal_mcp_module/models.py b/emsal_mcp_module/models.py new file mode 100644 index 0000000..7cd937a --- /dev/null +++ b/emsal_mcp_module/models.py @@ -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 \ No newline at end of file diff --git a/mcp_server_main.py b/mcp_server_main.py new file mode 100644 index 0000000..9c2850e --- /dev/null +++ b/mcp_server_main.py @@ -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.") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0c6bc7f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastmcp +httpx +beautifulsoup4 +markitdown +pydantic +aiohttp \ No newline at end of file diff --git a/uyusmazlik_mcp_module/__init__.py b/uyusmazlik_mcp_module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uyusmazlik_mcp_module/client.py b/uyusmazlik_mcp_module/client.py new file mode 100644 index 0000000..a69b91a --- /dev/null +++ b/uyusmazlik_mcp_module/client.py @@ -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.") \ No newline at end of file diff --git a/uyusmazlik_mcp_module/models.py b/uyusmazlik_mcp_module/models.py new file mode 100644 index 0000000..c187615 --- /dev/null +++ b/uyusmazlik_mcp_module/models.py @@ -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.") \ No newline at end of file diff --git a/yargitay_mcp_module/__init__.py b/yargitay_mcp_module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yargitay_mcp_module/client.py b/yargitay_mcp_module/client.py new file mode 100644 index 0000000..408ab69 --- /dev/null +++ b/yargitay_mcp_module/client.py @@ -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 ... string. + # If it were just a fragment, we might wrap it like: + # html_to_convert = f"{processed_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.") \ No newline at end of file diff --git a/yargitay_mcp_module/models.py b/yargitay_mcp_module/models.py new file mode 100644 index 0000000..6a073f3 --- /dev/null +++ b/yargitay_mcp_module/models.py @@ -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 \ No newline at end of file