diff --git a/.gitignore b/.gitignore index 1baa68e..30ae4ef 100644 --- a/.gitignore +++ b/.gitignore @@ -161,3 +161,5 @@ cython_debug/ .DS_Store hello.py +*.html +test_kik_client.py diff --git a/kik_mcp_module/__init__.py b/kik_mcp_module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kik_mcp_module/client.py b/kik_mcp_module/client.py new file mode 100644 index 0000000..1aa06ee --- /dev/null +++ b/kik_mcp_module/client.py @@ -0,0 +1,441 @@ +# kik_mcp_module/client.py +import asyncio +from playwright.async_api import ( + async_playwright, + Page, + BrowserContext, + Browser, + Error as PlaywrightError, + TimeoutError as PlaywrightTimeoutError +) +from bs4 import BeautifulSoup +import logging +from typing import Dict, Any, List, Optional +import urllib.parse +import base64 # Base64 için +import re +import html as html_parser +from markitdown import MarkItDown +import os +import math +import tempfile + +from .models import ( + KikSearchRequest, + KikDecisionEntry, + KikSearchResult, + KikDocumentMarkdown, + KikKararTipi +) + +logger = logging.getLogger(__name__) + +class KikApiClient: + BASE_URL = "https://ekap.kik.gov.tr" + SEARCH_PAGE_PATH = "/EKAP/Vatandas/kurulkararsorgu.aspx" + FIELD_LOCATORS = { + "karar_tipi_radio_group": "input[name='ctl00$ContentPlaceHolder1$kurulKararTip']", + "karar_no": "input[name='ctl00$ContentPlaceHolder1$txtKararNo']", + "karar_tarihi_baslangic": "input[name='ctl00$ContentPlaceHolder1$etKararTarihBaslangic$EkapTakvimTextBox_etKararTarihBaslangic']", + "karar_tarihi_bitis": "input[name='ctl00$ContentPlaceHolder1$etKararTarihBitis$EkapTakvimTextBox_etKararTarihBitis']", + "resmi_gazete_sayisi": "input[name='ctl00$ContentPlaceHolder1$txtResmiGazeteSayisi']", + "resmi_gazete_tarihi": "input[name='ctl00$ContentPlaceHolder1$etResmiGazeteTarihi$EkapTakvimTextBox_etResmiGazeteTarihi']", + "basvuru_konusu_ihale": "input[name='ctl00$ContentPlaceHolder1$txtBasvuruKonusuIhale']", + "basvuru_sahibi": "input[name='ctl00$ContentPlaceHolder1$txtSikayetci']", + "ihaleyi_yapan_idare": "input[name='ctl00$ContentPlaceHolder1$txtIhaleyiYapanIdare']", + "yil": "select[name='ctl00$ContentPlaceHolder1$ddlYil']", + "karar_metni": "input[name='ctl00$ContentPlaceHolder1$txtKararMetni']", + "search_button_id": "ctl00_ContentPlaceHolder1_btnAra" + } + RESULTS_TABLE_ID = "grdKurulKararSorguSonuc" + NO_RESULTS_MESSAGE_SELECTOR = "div#ctl00_MessageContent1" + VALIDATION_SUMMARY_SELECTOR = "div#ctl00_ValidationSummary1" + MODAL_CLOSE_BUTTON_SELECTOR = "div#detayPopUp.in a#btnKapatPencere_0.close" + DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000 + + def __init__(self, request_timeout: float = 60000): + self.playwright_instance: Optional[async_playwright] = None + self.browser: Optional[Browser] = None + self.context: Optional[BrowserContext] = None + self.page: Optional[Page] = None + self.request_timeout = request_timeout + self._lock = asyncio.Lock() + + async def _ensure_playwright_ready(self, force_new_page: bool = False): + async with self._lock: + browser_recreated = False + context_recreated = False + if not self.playwright_instance: + self.playwright_instance = await async_playwright().start() + if not self.browser or not self.browser.is_connected(): + if self.browser: await self.browser.close() + self.browser = await self.playwright_instance.chromium.launch(headless=True) + browser_recreated = True + if not self.context or browser_recreated: + if self.context: await self.context.close() + if not self.browser: raise PlaywrightError("Browser not initialized.") + self.context = await self.browser.new_context( + user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36", + java_script_enabled=True, + ) + context_recreated = True + if not self.page or self.page.is_closed() or force_new_page or context_recreated or browser_recreated: + if self.page and not self.page.is_closed(): await self.page.close() + if not self.context: raise PlaywrightError("Context is None.") + self.page = await self.context.new_page() + if not self.page: raise PlaywrightError("Failed to create new page.") + self.page.set_default_navigation_timeout(self.request_timeout) + self.page.set_default_timeout(self.request_timeout) + if not self.page or self.page.is_closed(): + raise PlaywrightError("Playwright page initialization failed.") + logger.debug("_ensure_playwright_ready completed.") + + async def close_client_session(self): + async with self._lock: + # ... (öncekiyle aynı) + if self.page and not self.page.is_closed(): await self.page.close(); self.page = None + if self.context: await self.context.close(); self.context = None + if self.browser: await self.browser.close(); self.browser = None + if self.playwright_instance: await self.playwright_instance.stop(); self.playwright_instance = None + logger.info("KikApiClient (Playwright): Resources closed.") + + def _parse_decision_entries_from_soup(self, soup: BeautifulSoup, search_karar_tipi: KikKararTipi) -> List[KikDecisionEntry]: + entries: List[KikDecisionEntry] = [] + table = soup.find("table", {"id": self.RESULTS_TABLE_ID}) + if not table: return entries + rows = table.find_all("tr") + for row_idx, row in enumerate(rows): + if row_idx < 2: continue + cells = row.find_all("td") + if len(cells) == 6: + try: + preview_button_tag = cells[0].find("a", id=re.compile(r"btnOnizle$")) + event_target = "" + if preview_button_tag and preview_button_tag.has_attr('href'): + match = re.search(r"__doPostBack\('([^']*)','([^']*)'\)", preview_button_tag['href']) + if match: event_target = match.group(1) + karar_no_span = cells[1].find("span", id=re.compile(r"lblKno$")) + karar_tarihi_span = cells[2].find("span", id=re.compile(r"lblKtar$")) + idare_span = cells[3].find("span", id=re.compile(r"lblIdare$")) + basvuru_sahibi_span = cells[4].find("span", id=re.compile(r"lblSikayetci$")) + ihale_span = cells[5].find("span", id=re.compile(r"lblIhale$")) + if not (event_target and karar_no_span and karar_tarihi_span): continue + + # Karar tipini arama parametresinden alıyoruz, çünkü HTML'de direkt olarak bulunmuyor. + entry = KikDecisionEntry( + preview_event_target=event_target, + kararNo=karar_no_span.get_text(strip=True), + karar_tipi=search_karar_tipi, # Arama yapılan karar tipini ekle + kararTarihi=karar_tarihi_span.get_text(strip=True), + idare=idare_span.get_text(strip=True) if idare_span else None, + basvuruSahibi=basvuru_sahibi_span.get_text(strip=True) if basvuru_sahibi_span else None, + ihaleKonusu=ihale_span.get_text(strip=True) if ihale_span else None, + ) + entries.append(entry) + except Exception as e: + logger.error(f"Error parsing a KIK decision entry row: {e}", exc_info=True) + return entries + + def _parse_total_records_from_soup(self, soup: BeautifulSoup) -> int: + # ... (öncekiyle aynı) ... + try: + pager_div = soup.find("div", class_="gridToplamSayi") + if pager_div: + match = re.search(r"Toplam Kayıt Sayısı:(\d+)", pager_div.get_text(strip=True)) + if match: return int(match.group(1)) + except: pass + return 0 + + def _parse_current_page_from_soup(self, soup: BeautifulSoup) -> int: + # ... (öncekiyle aynı) ... + try: + pager_div = soup.find("div", class_="sayfalama") + if pager_div: + active_page_span = pager_div.find("span", class_="active") + if active_page_span: return int(active_page_span.get_text(strip=True)) + except: pass + return 1 + + async def search_decisions(self, search_params: KikSearchRequest) -> KikSearchResult: + await self._ensure_playwright_ready() + page = self.page + search_url = f"{self.BASE_URL}{self.SEARCH_PAGE_PATH}" + try: + if page.url != search_url: + await page.goto(search_url, wait_until="networkidle", timeout=self.request_timeout) + search_button_selector = f"a[id='{self.FIELD_LOCATORS['search_button_id']}']" + await page.wait_for_selector(search_button_selector, state="visible", timeout=self.request_timeout) + + current_karar_tipi_value = search_params.karar_tipi.value + radio_locator_selector = f"{self.FIELD_LOCATORS['karar_tipi_radio_group']}[value='{current_karar_tipi_value}']" + if not await page.locator(radio_locator_selector).is_checked(): + js_target_radio = f"ctl00$ContentPlaceHolder1${current_karar_tipi_value}" + async with page.expect_navigation(wait_until="networkidle", timeout=self.request_timeout): + await page.evaluate(f"javascript:__doPostBack('{js_target_radio}','')") + await page.wait_for_timeout(1000) + + async def fill_if_value(selector_key: str, value: Optional[str]): + if value is not None: await page.fill(self.FIELD_LOCATORS[selector_key], value) + + # Karar No'yu KİK sitesine göndermeden önce '_' -> '/' dönüşümü yap + karar_no_for_kik_form = None + if search_params.karar_no: # search_params.karar_no Claude'dan '_' ile gelmiş olabilir + karar_no_for_kik_form = search_params.karar_no.replace('_', '/') + logger.info(f"Using karar_no '{karar_no_for_kik_form}' (transformed from '{search_params.karar_no}') for KIK form.") + + await fill_if_value('karar_metni', search_params.karar_metni) + await fill_if_value('karar_no', karar_no_for_kik_form) # Dönüştürülmüş halini kullan + # ... (diğer fill_if_value çağrıları aynı) ... + await fill_if_value('karar_tarihi_baslangic', search_params.karar_tarihi_baslangic) + await fill_if_value('karar_tarihi_bitis', search_params.karar_tarihi_bitis) + await fill_if_value('resmi_gazete_sayisi', search_params.resmi_gazete_sayisi) + await fill_if_value('resmi_gazete_tarihi', search_params.resmi_gazete_tarihi) + await fill_if_value('basvuru_konusu_ihale', search_params.basvuru_konusu_ihale) + await fill_if_value('basvuru_sahibi', search_params.basvuru_sahibi) + await fill_if_value('ihaleyi_yapan_idare', search_params.ihaleyi_yapan_idare) + + if search_params.yil: + await page.select_option(self.FIELD_LOCATORS['yil'], value=search_params.yil) + + action_is_search_button_click = (search_params.page == 1) + event_target_for_submit: str + if action_is_search_button_click: + event_target_for_submit = self.FIELD_LOCATORS['search_button_id'] + else: # Pagination + page_link_ctl_number = search_params.page + 2 + event_target_for_submit = f"ctl00$ContentPlaceHolder1$grdKurulKararSorguSonuc$ctl14$ctl{page_link_ctl_number:02d}" + + try: + async with page.expect_navigation(wait_until="networkidle", timeout=self.request_timeout): + if action_is_search_button_click: + await page.locator(search_button_selector).click() + else: + await page.evaluate(f"javascript:__doPostBack('{event_target_for_submit}','')") + except PlaywrightTimeoutError: + await page.wait_for_timeout(2000) + + results_table_dom_selector = f"table#{self.RESULTS_TABLE_ID}" + try: + await page.wait_for_selector(results_table_dom_selector, timeout=30000, state="attached") + await page.wait_for_timeout(2000) + except PlaywrightTimeoutError: + logger.warning(f"Timeout waiting for results table '{results_table_dom_selector}'.") + + html_content = await page.content() + soup = BeautifulSoup(html_content, "html.parser") + # ... (hata ve sonuç yok mesajı kontrolü aynı) ... + validation_summary_tag = soup.find("div", id=self.VALIDATION_SUMMARY_SELECTOR.split('[')[0].split(':')[0]) + if validation_summary_tag and validation_summary_tag.get_text(strip=True) and \ + ("display: none" not in validation_summary_tag.get("style", "").lower() if validation_summary_tag.has_attr("style") else True) and \ + validation_summary_tag.get_text(strip=True) != "": + return KikSearchResult(decisions=[], total_records=0, current_page=search_params.page) + message_content_div = soup.find("div", id=self.NO_RESULTS_MESSAGE_SELECTOR.split(':')[0]) + if message_content_div and "kayıt bulunamamıştır" in message_content_div.get_text(strip=True).lower(): + return KikSearchResult(decisions=[], total_records=0, current_page=1) + + # _parse_decision_entries_from_soup'a arama yapılan karar_tipi'ni gönder + decisions = self._parse_decision_entries_from_soup(soup, search_params.karar_tipi) + total_records = self._parse_total_records_from_soup(soup) + current_page_from_html = self._parse_current_page_from_soup(soup) + return KikSearchResult(decisions=decisions, total_records=total_records, current_page=current_page_from_html) + except Exception as e: + logger.error(f"Error during KIK decision search: {e}", exc_info=True) + return KikSearchResult(decisions=[], current_page=search_params.page) + + def _clean_html_for_markdown(self, html_content: str) -> str: + # ... (öncekiyle aynı) ... + if not html_content: return "" + return html_parser.unescape(html_content) + + def _convert_html_to_markdown_internal(self, html_fragment: str) -> Optional[str]: + # ... (öncekiyle aynı) ... + if not html_fragment: return None + cleaned_html = self._clean_html_for_markdown(html_fragment) + markdown_output = None; temp_file_path = None + try: + md_converter = MarkItDown(enable_plugins=True, remove_alt_whitespace=True, keep_underline=True) + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as tmp_html_file: + tmp_html_file.write(cleaned_html); temp_file_path = tmp_html_file.name + markdown_output = md_converter.convert(temp_file_path).text_content + if markdown_output: markdown_output = re.sub(r'\n{3,}', '\n\n', markdown_output).strip() + except Exception as e: logger.error(f"MarkItDown conversion error: {e}", exc_info=True) + finally: + if temp_file_path and os.path.exists(temp_file_path): os.remove(temp_file_path) + return markdown_output + + + async def get_decision_document_as_markdown( + self, + karar_id_b64: str, + page_number: int = 1 + ) -> KikDocumentMarkdown: + await self._ensure_playwright_ready() + # Bu metodun kendi içinde yeni bir 'page' nesnesi ('doc_page_for_content') kullanacağını unutmayın, + # ana 'self.page' arama sonuçları sayfasında kalır. + current_main_page = self.page # Ana arama sonuçları sayfasını referans alalım + + try: + decoded_key = base64.b64decode(karar_id_b64.encode('utf-8')).decode('utf-8') + karar_tipi_value, karar_no_for_search = decoded_key.split('|', 1) + original_karar_tipi = KikKararTipi(karar_tipi_value) + logger.info(f"KIK Get Detail: Decoded karar_id '{karar_id_b64}' to Karar Tipi: {original_karar_tipi.value}, Karar No: {karar_no_for_search}. Requested Markdown Page: {page_number}") + except Exception as e_decode: + logger.error(f"Invalid karar_id format. Could not decode Base64 or split: {karar_id_b64}. Error: {e_decode}") + return KikDocumentMarkdown(retrieved_with_karar_id=karar_id_b64, error_message="Invalid karar_id format.", current_page=page_number) + + default_error_response_data = { + "retrieved_with_karar_id": karar_id_b64, + "retrieved_karar_no": karar_no_for_search, + "retrieved_karar_tipi": original_karar_tipi, + "error_message": "An unspecified error occurred.", + "current_page": page_number, "total_pages": 1, "is_paginated": False + } + + # Ana arama sayfasında olduğumuzdan emin olalım + if self.SEARCH_PAGE_PATH not in current_main_page.url: + logger.info(f"Not on search page ({current_main_page.url}). Navigating to {self.SEARCH_PAGE_PATH} before targeted search for document.") + await current_main_page.goto(f"{self.BASE_URL}{self.SEARCH_PAGE_PATH}", wait_until="networkidle", timeout=self.request_timeout) + await current_main_page.wait_for_selector(f"a[id='{self.FIELD_LOCATORS['search_button_id']}']", state="visible", timeout=self.request_timeout) + + targeted_search_params = KikSearchRequest( + karar_no=karar_no_for_search, + karar_tipi=original_karar_tipi, + page=1 + ) + logger.info(f"Performing targeted search for Karar No: {karar_no_for_search}") + # search_decisions kendi içinde _ensure_playwright_ready çağırır ve self.page'i kullanır. + # Bu, current_main_page ile aynı olmalı. + search_results = await self.search_decisions(targeted_search_params) + + if not search_results.decisions: + default_error_response_data["error_message"] = f"Decision with Karar No '{karar_no_for_search}' (Tipi: {original_karar_tipi.value}) not found by internal search." + return KikDocumentMarkdown(**default_error_response_data) + + decision_to_fetch = None + for dec_entry in search_results.decisions: + if dec_entry.karar_no_str == karar_no_for_search and dec_entry.karar_tipi == original_karar_tipi: + decision_to_fetch = dec_entry + break + + if not decision_to_fetch: + default_error_response_data["error_message"] = f"Karar No '{karar_no_for_search}' (Tipi: {original_karar_tipi.value}) not present with an exact match in first page of targeted search results." + return KikDocumentMarkdown(**default_error_response_data) + + decision_preview_event_target = decision_to_fetch.preview_event_target + logger.info(f"Found target decision. Using preview_event_target: {decision_preview_event_target} for Karar No: {decision_to_fetch.karar_no_str}") + + iframe_document_url_str = None + karar_id_param_from_url_on_doc_page = None + document_html_content = "" + + try: + logger.info(f"Evaluating __doPostBack on main page to show modal for: {decision_preview_event_target}") + # Bu evaluate, self.page (yani current_main_page) üzerinde çalışır + await current_main_page.evaluate(f"javascript:__doPostBack('{decision_preview_event_target}','')") + await current_main_page.wait_for_timeout(1000) + logger.info(f"Executed __doPostBack for {decision_preview_event_target} on main page.") + + iframe_selector = "iframe#iframe_detayPopUp" + modal_visible_selector = "div#detayPopUp.in" + + try: + logger.info(f"Waiting for modal '{modal_visible_selector}' to be visible and iframe '{iframe_selector}' src to be populated on main page...") + await current_main_page.wait_for_function( + f""" + () => {{ + const modal = document.querySelector('{modal_visible_selector}'); + const iframe = document.querySelector('{iframe_selector}'); + const modalIsTrulyVisible = modal && (window.getComputedStyle(modal).display !== 'none'); + return modalIsTrulyVisible && + iframe && iframe.getAttribute('src') && + iframe.getAttribute('src').includes('KurulKararGoster.aspx'); + }} + """, + timeout=self.request_timeout / 2 + ) + iframe_src_value = await current_main_page.locator(iframe_selector).get_attribute("src") + logger.info(f"Iframe src populated: {iframe_src_value}") + + except PlaywrightTimeoutError: + logger.warning(f"Timeout waiting for KIK iframe src for {decision_preview_event_target}. Trying to parse from static content after presumed update.") + html_after_postback = await current_main_page.content() + # ... (fallback parsing öncekiyle aynı, default_error_response_data set edilir ve return edilir) ... + soup_after_postback = BeautifulSoup(html_after_postback, "html.parser") + detay_popup_div = soup_after_postback.find("div", {"id": "detayPopUp", "class": re.compile(r"\bin\b")}) + if not detay_popup_div: detay_popup_div = soup_after_postback.find("div", {"id": "detayPopUp", "style": re.compile(r"display:\s*block", re.I)}) + iframe_tag = detay_popup_div.find("iframe", {"id": "iframe_detayPopUp"}) if detay_popup_div else None + if iframe_tag and iframe_tag.has_attr("src") and iframe_tag["src"]: iframe_src_value = iframe_tag["src"] + else: + default_error_response_data["error_message"]="Timeout or failure finding decision content iframe URL after postback." + return KikDocumentMarkdown(**default_error_response_data) + + if not iframe_src_value or not iframe_src_value.strip(): + default_error_response_data["error_message"]="Extracted iframe URL for decision content is empty." + return KikDocumentMarkdown(**default_error_response_data) + + # iframe_src_value göreceli bir URL ise, ana sayfanın URL'si ile birleştir + iframe_document_url_str = urllib.parse.urljoin(current_main_page.url, iframe_src_value) + logger.info(f"Constructed absolute iframe_document_url_str for goto: {iframe_document_url_str}") # Log this absolute URL + default_error_response_data["source_url"] = iframe_document_url_str + + parsed_url = urllib.parse.urlparse(iframe_document_url_str) + query_params = urllib.parse.parse_qs(parsed_url.query) + karar_id_param_from_url_on_doc_page = query_params.get("KararId", [None])[0] + default_error_response_data["karar_id_param_from_url"] = karar_id_param_from_url_on_doc_page + if not karar_id_param_from_url_on_doc_page: + default_error_response_data["error_message"]="KararId (KIK internal ID) not found in extracted iframe URL." + return KikDocumentMarkdown(**default_error_response_data) + + logger.info(f"Fetching KIK decision content from iframe URL using a new Playwright page: {iframe_document_url_str}") + + doc_page_for_content = await self.context.new_page() + try: + # `goto` metoduna MUTLAK URL verilmeli. Loglanan URL'nin mutlak olduğundan emin olalım. + await doc_page_for_content.goto(iframe_document_url_str, wait_until="domcontentloaded", timeout=self.request_timeout) + document_html_content = await doc_page_for_content.content() + except Exception as e_doc_page: + logger.error(f"Error navigating or getting content from doc_page ({iframe_document_url_str}): {e_doc_page}") + if doc_page_for_content and not doc_page_for_content.is_closed(): await doc_page_for_content.close() + default_error_response_data["error_message"]=f"Failed to load decision detail page: {e_doc_page}" + return KikDocumentMarkdown(**default_error_response_data) + finally: + if doc_page_for_content and not doc_page_for_content.is_closed(): + await doc_page_for_content.close() + + soup_decision_detail = BeautifulSoup(document_html_content, "html.parser") + karar_content_span = soup_decision_detail.find("span", {"id": "ctl00_ContentPlaceHolder1_lblKarar"}) + actual_decision_html = karar_content_span.decode_contents() if karar_content_span else document_html_content + full_markdown_content = self._convert_html_to_markdown_internal(actual_decision_html) + + if not full_markdown_content: + default_error_response_data["error_message"]="Markdown conversion failed or returned empty content." + try: + if await current_main_page.locator(self.MODAL_CLOSE_BUTTON_SELECTOR).is_visible(timeout=1000): + await current_main_page.locator(self.MODAL_CLOSE_BUTTON_SELECTOR).click() + except: pass + return KikDocumentMarkdown(**default_error_response_data) + + content_length = len(full_markdown_content); total_pages = math.ceil(content_length / self.DOCUMENT_MARKDOWN_CHUNK_SIZE) or 1 + current_page_clamped = max(1, min(page_number, total_pages)) + start_index = (current_page_clamped - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE + markdown_chunk = full_markdown_content[start_index : start_index + self.DOCUMENT_MARKDOWN_CHUNK_SIZE] + + try: + if await current_main_page.locator(self.MODAL_CLOSE_BUTTON_SELECTOR).is_visible(timeout=2000): + await current_main_page.locator(self.MODAL_CLOSE_BUTTON_SELECTOR).click() + await current_main_page.wait_for_selector(f"div#detayPopUp:not(.in)", timeout=5000) + except: pass + + return KikDocumentMarkdown( + retrieved_with_karar_id=karar_id_b64, + retrieved_karar_no=karar_no_for_search, + retrieved_karar_tipi=original_karar_tipi, + kararIdParam=karar_id_param_from_url_on_doc_page, + markdown_chunk=markdown_chunk, source_url=iframe_document_url_str, + current_page=current_page_clamped, total_pages=total_pages, + is_paginated=(total_pages > 1), full_content_char_count=content_length + ) + except Exception as e: + logger.error(f"Error in get_decision_document_as_markdown for Karar ID {karar_id_b64}: {e}", exc_info=True) + default_error_response_data["error_message"] = f"General error: {str(e)}" + return KikDocumentMarkdown(**default_error_response_data) diff --git a/kik_mcp_module/models.py b/kik_mcp_module/models.py new file mode 100644 index 0000000..0b67a89 --- /dev/null +++ b/kik_mcp_module/models.py @@ -0,0 +1,77 @@ +# kik_mcp_module/models.py +from pydantic import BaseModel, Field, HttpUrl, computed_field +from typing import List, Optional +from enum import Enum +import base64 # Base64 encoding/decoding için + +class KikKararTipi(str, Enum): + """Enum for KIK (Public Procurement Authority) Decision Types.""" + UYUSMAZLIK = "rbUyusmazlik" + DUZENLEYICI = "rbDuzenleyici" + MAHKEME = "rbMahkeme" + +class KikSearchRequest(BaseModel): + """Model for KIK Decision search criteria.""" + karar_tipi: KikKararTipi = Field(KikKararTipi.UYUSMAZLIK, description="Type of KIK Decision.") + karar_no: Optional[str] = Field(None, description="Decision Number (e.g., '2024/UH.II-1766').") + karar_tarihi_baslangic: Optional[str] = Field(None, description="Decision Date Start (DD.MM.YYYY).", pattern=r"^\d{2}\.\d{2}\.\d{4}$") + karar_tarihi_bitis: Optional[str] = Field(None, description="Decision Date End (DD.MM.YYYY).", pattern=r"^\d{2}\.\d{2}\.\d{4}$") + resmi_gazete_sayisi: Optional[str] = Field(None, description="Official Gazette Number.") + resmi_gazete_tarihi: Optional[str] = Field(None, description="Official Gazette Date (DD.MM.YYYY).", pattern=r"^\d{2}\.\d{2}\.\d{4}$") + basvuru_konusu_ihale: Optional[str] = Field(None, description="Tender subject of the application.") + basvuru_sahibi: Optional[str] = Field(None, description="Applicant.") + ihaleyi_yapan_idare: Optional[str] = Field(None, description="Procuring Entity.") + yil: Optional[str] = Field(None, description="Year of the decision.") + karar_metni: Optional[str] = Field(None, description="Keyword/phrase in decision text.") + page: int = Field(1, ge=1, description="Results page number.") + +class KikDecisionEntry(BaseModel): + """Represents a single decision entry from KIK search results.""" + preview_event_target: str = Field(..., description="Internal event target for fetching details.") + karar_no_str: str = Field(..., alias="kararNo", description="Raw decision number as extracted from KIK (e.g., '2024/UH.II-1766').") + karar_tipi: KikKararTipi = Field(..., description="The type of decision this entry belongs to.") + + karar_tarihi_str: str = Field(..., alias="kararTarihi", description="Decision date.") + idare_str: Optional[str] = Field(None, alias="idare", description="Procuring entity.") + basvuru_sahibi_str: Optional[str] = Field(None, alias="basvuruSahibi", description="Applicant.") + ihale_konusu_str: Optional[str] = Field(None, alias="ihaleKonusu", description="Tender subject.") + + @computed_field + @property + def karar_id(self) -> str: + """ + A Base64 encoded unique ID for the decision, combining decision type and number. + Format before encoding: "{karar_tipi.value}|{karar_no_str}" + """ + combined_key = f"{self.karar_tipi.value}|{self.karar_no_str}" + return base64.b64encode(combined_key.encode('utf-8')).decode('utf-8') + + class Config: + populate_by_name = True + +class KikSearchResult(BaseModel): + """Model for KIK search results.""" + decisions: List[KikDecisionEntry] + total_records: int = 0 + current_page: int = 1 + +class KikDocumentMarkdown(BaseModel): + """ + KIK decision document, with Markdown content potentially paginated. + """ + retrieved_with_karar_id: Optional[str] = Field(None, description="The Base64 encoded karar_id that was used to request this document.") + # Decode edilmiş karar no ve tipini de yanıt olarak ekleyelim, Claude için faydalı olabilir. + retrieved_karar_no: Optional[str] = Field(None, description="The raw KIK Decision Number (e.g., '2024/UH.II-1766') this document pertains to.") + retrieved_karar_tipi: Optional[KikKararTipi] = Field(None, description="The KIK Decision Type this document pertains to.") + + karar_id_param_from_url: Optional[str] = Field(None, alias="kararIdParam", description="The KIK system's internal KararId parameter from the document's display URL (KurulKararGoster.aspx).") + markdown_chunk: Optional[str] = Field(None, description="The requested chunk of the decision content converted to Markdown.") + source_url: Optional[str] = Field(None, description="The source URL of the original document (KurulKararGoster.aspx).") + error_message: Optional[str] = Field(None, description="Error message if document retrieval or processing failed.") + current_page: int = Field(1, description="The current page number of the markdown chunk being returned.") + total_pages: int = Field(1, description="The total number of pages the full markdown content is divided into.") + is_paginated: bool = Field(False, description="True if the full markdown content is split into multiple pages.") + full_content_char_count: Optional[int] = Field(None, description="Total character count of the full markdown content before chunking.") + + class Config: + populate_by_name = True \ No newline at end of file diff --git a/mcp_server_main.py b/mcp_server_main.py index bb5900c..c8cf546 100644 --- a/mcp_server_main.py +++ b/mcp_server_main.py @@ -1,11 +1,11 @@ # mcp_server_main.py - import asyncio import atexit import logging import os -from pydantic import HttpUrl -from typing import Optional +from pydantic import HttpUrl, Field +from typing import Optional, Dict +import urllib.parse # urllib.parse client tarafında kullanılıyor, server'da gerekmeyebilir. # --- Logging Configuration Start --- LOG_DIRECTORY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs") @@ -20,7 +20,7 @@ log_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(th file_handler = logging.FileHandler(LOG_FILE_PATH, mode='a', encoding='utf-8') file_handler.setFormatter(log_formatter) -file_handler.setLevel(logging.DEBUG) +file_handler.setLevel(logging.DEBUG) root_logger.addHandler(file_handler) console_handler = logging.StreamHandler() @@ -32,7 +32,6 @@ logger = logging.getLogger(__name__) # --- Logging Configuration End --- from fastmcp import FastMCP -from pydantic import Field # --- Module Imports --- from yargitay_mcp_module.client import YargitayOfficialApiClient @@ -52,21 +51,30 @@ 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.client import AnayasaMahkemesiApiClient +from anayasa_mcp_module.bireysel_client import AnayasaBireyselBasvuruApiClient 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 + AnayasaDocumentMarkdown, + AnayasaBireyselReportSearchRequest, + AnayasaBireyselReportSearchResult, + AnayasaBireyselBasvuruDocumentMarkdown, ) +# KIK Module Imports +from kik_mcp_module.client import KikApiClient +from kik_mcp_module.models import ( + KikKararTipi, + KikSearchRequest, + KikSearchResult, + KikDocumentMarkdown +) + 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"] + instructions="MCP server for TR legal databases (Yargitay, Danistay, Emsal, Uyusmazlik, Anayasa-Norm, Anayasa-Bireysel, KIK).", + dependencies=["httpx", "beautifulsoup4", "markitdown", "pydantic", "aiohttp", "playwright"] ) # --- API Client Instances --- @@ -76,8 +84,10 @@ emsal_client_instance = EmsalApiClient() uyusmazlik_client_instance = UyusmazlikApiClient() anayasa_norm_client_instance = AnayasaMahkemesiApiClient() anayasa_bireysel_client_instance = AnayasaBireyselBasvuruApiClient() +kik_client_instance = KikApiClient() # --- MCP Tools for Yargitay --- +# ... (Yargıtay araçları öncekiyle aynı, docstringler İngilizce) ... @app.tool() async def search_yargitay_detailed(search_query: YargitayDetailedSearchRequest) -> CompactYargitaySearchResult: """Searches Yargitay (Court of Cassation) decisions using detailed criteria.""" @@ -93,7 +103,7 @@ async def search_yargitay_detailed(search_query: YargitayDetailedSearchRequest) 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)}") + logger.exception(f"Error in tool 'search_yargitay_detailed'.") # Query loglaması kaldırıldı, PII içerebilir raise @app.tool() @@ -104,44 +114,45 @@ async def get_yargitay_document_markdown(document_id: str) -> YargitayDocumentMa 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}") + logger.exception(f"Error in tool 'get_yargitay_document_markdown'.") raise # --- MCP Tools for Danistay --- +# ... (Danıştay araçları öncekiyle aynı, docstringler İngilizce) ... @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)}") + logger.info(f"Tool 'search_danistay_by_keyword' called.") # Query loglaması kaldırıldı try: api_response = await danistay_client_instance.search_keyword_decisions(search_query) if api_response.data: - return CompactDanistaySearchResult( + return CompactDanistaySearchResult( decisions=api_response.data.data, - total_records=api_response.data.recordsTotal, + 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)}") + logger.exception(f"Error in tool 'search_danistay_by_keyword'.") 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)}") + logger.info(f"Tool 'search_danistay_detailed' called.") # Query loglaması kaldırıldı try: api_response = await danistay_client_instance.search_detailed_decisions(search_query) if api_response.data: - return CompactDanistaySearchResult( + return CompactDanistaySearchResult( decisions=api_response.data.data, - total_records=api_response.data.recordsTotal, + 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)}") + logger.exception(f"Error in tool 'search_danistay_detailed'.") raise @app.tool() @@ -152,14 +163,15 @@ async def get_danistay_document_markdown(document_id: str) -> DanistayDocumentMa 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}") + logger.exception(f"Error in tool 'get_danistay_document_markdown'.") raise # --- MCP Tools for Emsal --- +# ... (Emsal araçları öncekiyle aynı, docstringler İngilizce) ... @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)}") + logger.info(f"Tool 'search_emsal_detailed_decisions' called.") # Query loglaması kaldırıldı try: api_response = await emsal_client_instance.search_detailed_decisions(search_query) if api_response.data: @@ -172,7 +184,7 @@ async def search_emsal_detailed_decisions(search_query: EmsalSearchRequest) -> C 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)}") + logger.exception(f"Error in tool 'search_emsal_detailed_decisions'.") raise @app.tool() @@ -183,20 +195,21 @@ async def get_emsal_document_markdown(document_id: str) -> EmsalDocumentMarkdown 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}") + logger.exception(f"Error in tool 'get_emsal_document_markdown'.") raise # --- MCP Tools for Uyusmazlik --- +# ... (Uyuşmazlık araçları öncekiyle aynı, docstringler İngilizce) ... @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)}") + """Searches for Uyuşmazlık Mahkemesi (Court of Jurisdictional Disputes) decisions using various criteria.""" + logger.info(f"Tool 'search_uyusmazlik_decisions' called.") # Query loglaması kaldırıldı 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)}") + logger.exception(f"Error in tool 'search_uyusmazlik_decisions'.") raise @app.tool() @@ -212,34 +225,35 @@ async def get_uyusmazlik_document_markdown_from_url(document_url: HttpUrl) -> Uy 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)}") + logger.exception(f"Error in tool 'get_uyusmazlik_document_markdown_from_url'.") raise # --- MCP Tools for Anayasa Mahkemesi (Norm Denetimi) --- +# ... (Anayasa Norm araçları öncekiyle aynı, docstringler İngilizce) ... @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. + Searches Anayasa Mahkemesi (Constitutional Court) Norm Denetimi (Norm Control) decisions + using criteria from 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)}") + logger.info(f"Tool 'search_anayasa_norm_denetimi_decisions' called.") # Query loglaması kaldırıldı 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)}") + logger.exception(f"Error in tool 'search_anayasa_norm_denetimi_decisions'.") 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 + document_url: str = Field(..., description="The URL path (e.g., /ND/YYYY/NN) or full https URL of the AYM Norm Denetimi decision 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 (first 5,000 characters).") ) -> 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. + from its URL and returns its content as paginated Markdown. + Content is paginated if it exceeds 5,000 characters. Use 'page_number' for 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(): @@ -248,10 +262,11 @@ async def get_anayasa_norm_denetimi_document_markdown( 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}") + logger.exception(f"Error in tool 'get_anayasa_norm_denetimi_document_markdown'.") raise # --- MCP Tools for Anayasa Mahkemesi (Bireysel Başvuru Karar Raporu & Belgeler) --- +# ... (Anayasa Bireysel araçları öncekiyle aynı, docstringler İngilizce) ... @app.tool() async def search_anayasa_bireysel_basvuru_report( search_query: AnayasaBireyselReportSearchRequest @@ -259,83 +274,147 @@ async def search_anayasa_bireysel_basvuru_report( """ 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. + This is for https://kararlarbilgibankasi.anayasa.gov.tr (uses KararBulteni=1). """ - logger.info(f"Tool 'search_anayasa_bireysel_basvuru_report' called: {search_query.model_dump_json(exclude_none=True, indent=2)}") + logger.info(f"Tool 'search_anayasa_bireysel_basvuru_report' called.") # Query loglaması kaldırıldı 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)}") + logger.exception(f"Error in tool 'search_anayasa_bireysel_basvuru_report'.") 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.") + document_url_path: str = Field(..., description="The URL path (e.g., /BB/YYYY/NNNN) of the AYM Bireysel Başvuru decision 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 (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. + from its URL path (e.g., /BB/YYYY/NNNN) and returns content as paginated Markdown. + Content is paginated if it exceeds 5,000 characters. Use 'page_number' for 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}") + logger.exception(f"Error in tool 'get_anayasa_bireysel_basvuru_document_markdown'.") raise +# --- MCP Tools for KIK (Kamu İhale Kurulu) --- +@app.tool() +async def search_kik_decisions(search_query: KikSearchRequest) -> KikSearchResult: + """ + Searches KIK (Public Procurement Authority) decisions. + """ + logger.info(f"Tool 'search_kik_decisions' called.") # Query loglaması kaldırıldı + try: + api_response = await kik_client_instance.search_decisions(search_query) + page_param_for_log = search_query.page if hasattr(search_query, 'page') else 1 + if not api_response.decisions and api_response.total_records == 0 and page_param_for_log == 1: + logger.warning(f"KIK search returned no decisions for query.") # Query detayı kaldırıldı + return api_response + except Exception as e: + logger.exception(f"Error in KIK search tool 'search_kik_decisions'.") + current_page_val = search_query.page if hasattr(search_query, 'page') else 1 + return KikSearchResult(decisions=[], total_records=0, current_page=current_page_val) + +@app.tool() +async def get_kik_document_markdown( + karar_id: str = Field(..., description="The Base64 encoded KIK decision identifier."), + page_number: Optional[int] = Field(1, ge=1, description="Page number for paginated Markdown content (1-indexed). Default is 1.") +) -> KikDocumentMarkdown: + """ + Retrieves a specific KIK (Public Procurement Authority) decision using its Base64 encoded 'karar_id'. + Content is returned as paginated Markdown. + """ + logger.info(f"Tool 'get_kik_document_markdown' called for KIK karar_id: {karar_id}, Markdown Page: {page_number}") + + if not karar_id or not karar_id.strip(): + logger.error("KIK Document retrieval: karar_id cannot be empty.") + return KikDocumentMarkdown( + retrieved_with_karar_id=karar_id, + error_message="karar_id is required and must be a non-empty string.", + current_page=page_number or 1, + total_pages=1, + is_paginated=False + ) + + current_page_to_fetch = page_number if page_number is not None and page_number >= 1 else 1 + + try: + return await kik_client_instance.get_decision_document_as_markdown( + karar_id_b64=karar_id, + page_number=current_page_to_fetch + ) + except Exception as e: + logger.exception(f"Error in KIK document retrieval tool 'get_kik_document_markdown' for karar_id: {karar_id}") + return KikDocumentMarkdown( + retrieved_with_karar_id=karar_id, + error_message=f"Tool-level error during KIK document retrieval: {str(e)}", + current_page=current_page_to_fetch, + total_pages=1, + is_paginated=False + ) + # --- Application Shutdown Handling --- +# ... (perform_cleanup ve main fonksiyonları öncekiyle aynı, değişiklik yok) ... def perform_cleanup(): logger.info("MCP Server performing cleanup...") try: - loop = asyncio.get_event_loop() - if loop.is_closed(): + loop = asyncio.get_event_loop_policy().get_event_loop() + if loop.is_closed(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - except RuntimeError: # pragma: no cover + except RuntimeError: 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') - ] + 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'), + globals().get('kik_client_instance') + ] + async def close_all_clients_async(): + tasks = [] 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.") - + logger.info(f"Scheduling close for client session: {client_instance.__class__.__name__}") + tasks.append(client_instance.close_client_session()) + if tasks: + results = await asyncio.gather(*tasks, return_exceptions=True) + for i, result in enumerate(results): + if isinstance(result, Exception): + client_name = "Unknown Client" + if i < len(clients_to_close) and clients_to_close[i] is not None: + client_name = clients_to_close[i].__class__.__name__ + logger.error(f"Error closing client {client_name}: {result}") + try: + if loop.is_running(): + asyncio.ensure_future(close_all_clients_async(), loop=loop) + logger.info("Client cleanup tasks scheduled on running event loop.") + else: + loop.run_until_complete(close_all_clients_async()) + logger.info("Client cleanup tasks completed via run_until_complete.") + except Exception as e: + logger.error(f"Error during atexit cleanup execution: {e}", exc_info=True) + logger.info("MCP Server atexit cleanup process finished.") atexit.register(perform_cleanup) - def main(): - """Main entry point for the MCP server.""" logger.info(f"Starting {app.name} server via main() function...") logger.info(f"Logs will be written to: {LOG_FILE_PATH}") try: app.run() - except KeyboardInterrupt: + except KeyboardInterrupt: logger.info("Server shut down by user (KeyboardInterrupt).") - except Exception as e: + except Exception as e: logger.exception("Server failed to start or crashed.") finally: logger.info(f"{app.name} server has shut down.") - -# --- Main Entry Point --- -if __name__ == "__main__": - main() \ No newline at end of file +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4d92923..00876cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "markitdown>=0.1.1", "pydantic>=2.11.4", "aiohttp>=3.11.18", + "playwright>=1.52.0", ] [project.scripts] @@ -20,4 +21,4 @@ yargi-mcp = "mcp_server_main:main" py-modules = ["mcp_server_main"] [tool.setuptools.packages.find] -include = ["*_mcp_module"] \ No newline at end of file +include = ["*_mcp_module"]