Files
yargi-mcp/sigorta_tahkim_mcp_module/client.py
T
saidsurucuandClaude Opus 4.7 96a5a538b2 perf(server): unblock event loop on rate-limit waits and markitdown
Two complementary changes to mitigate intermittent TLS handshake
timeouts and "notifications/cancelled: Bad Request" seen against the
single-worker uvicorn deployment.

1. bedesten rate-limiter back-pressure
   - Add optional ``max_wait`` to ``_TokenBucket.acquire``: if the next
     wait would exceed it, raise ``BedestenRateLimited`` immediately
     instead of sleeping. After a server-side 429 the bucket pauses for
     up to 30s; previously a queued request sat in ``asyncio.sleep``
     for that whole window, holding the worker slot and pushing the
     MCP client past its cancellation timeout.
   - ``search_bedesten_unified`` / ``get_bedesten_document_markdown``
     catch ``BedestenRateLimited`` and reuse the existing structured
     429-style response, so callers get a fast, clean retry signal.
   - Tunable via ``BEDESTEN_RATE_MAX_WAIT_S`` (default 8.0s).

2. Offload sync markitdown conversions to a thread
   - Every ``markitdown.convert*`` call site is now wrapped in
     ``asyncio.to_thread(...)`` across 14 modules (bedesten, yargitay,
     danistay, anayasa norm + bireysel, uyusmazlik, emsal, rekabet,
     gib, kvkk, sayistay, bddk, sigorta_tahkim, kik_v2). PDF / large
     HTML parsing was stalling the event loop for seconds, which on a
     single-worker deployment delayed every other in-flight request
     and queued new TLS handshakes until they timed out.

Verified locally:
- ``ast.parse`` + ``importlib.import_module`` on all 15 modified files
- ``mcp_server_main.create_app()`` constructs successfully
- New ``_TokenBucket.acquire(max_wait=...)`` smoke-tested across 6
  paths: capacity-available, no-arg backward compat, max_wait raise,
  max_wait wait+succeed, ``penalize_until`` + max_wait fast-raise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:31:23 +03:00

346 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# sigorta_tahkim_mcp_module/client.py
import asyncio
import httpx
from typing import Optional
import logging
import os
import re
import io
import math
from markitdown import MarkItDown
from .models import (
SigortaTahkimSearchRequest,
SigortaTahkimDecisionSummary,
SigortaTahkimSearchResult,
SigortaTahkimDocumentMarkdown,
SigortaTahkimSearchWithinMatch,
SigortaTahkimSearchWithinResult
)
logger = logging.getLogger(__name__)
if not logger.hasHandlers():
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
# Turkish-specific lowercase: İ→i, I→ı (Python's str.lower() doesn't handle these)
_TR_UPPER = str.maketrans("İIÇĞÖŞÜ", "iıçğöşü")
def _turkish_lower(text: str) -> str:
"""Lowercase with Turkish İ/I handling."""
return text.translate(_TR_UPPER).lower()
class SigortaTahkimApiClient:
"""
API client for searching and retrieving Sigorta Tahkim Komisyonu
(Insurance Arbitration Commission) decisions using Tavily Search API
for discovery and direct PDF download for content retrieval.
The commission publishes quarterly PDF journals ("Hakem Karar Dergisi")
containing arbitration decisions. There are 64 issues spanning 2010-2025.
"""
TAVILY_API_URL = "https://api.tavily.com/search"
BASE_URL = "https://www.sigortatahkim.org"
PDF_BASE_URL = "https://www.sigortatahkim.org/content/CmsFiles/"
DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000
def __init__(self, request_timeout: float = 60.0):
"""Initialize the Sigorta Tahkim API client."""
self.tavily_api_key = os.getenv("TAVILY_API_KEY")
if not self.tavily_api_key:
self.tavily_api_key = "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"
logger.info("Using fallback Tavily API token (development token)")
else:
logger.info("Using Tavily API key from environment variable")
self.http_client = httpx.AsyncClient(
headers={
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
},
timeout=httpx.Timeout(request_timeout)
)
self.markitdown = MarkItDown()
async def close_client_session(self):
"""Close the HTTP client session."""
await self.http_client.aclose()
logger.info("SigortaTahkimApiClient: HTTP client session closed.")
def _get_pdf_filename(self, issue_number: int) -> str:
"""Get the PDF filename for a given journal issue number."""
if issue_number == 4:
return "karardergisisayi4.pdf"
elif 57 <= issue_number <= 61:
return f"revizekd{issue_number}.pdf"
else:
return f"karardrgs{issue_number}.pdf"
def _extract_issue_number(self, url: str) -> Optional[str]:
"""Extract journal issue number from a sigortatahkim.org URL."""
# Pattern: karardrgs{N}.pdf
match = re.search(r'karardrgs(\d+)\.pdf', url, re.IGNORECASE)
if match:
return match.group(1)
# Pattern: revizekd{N}.pdf
match = re.search(r'revizekd(\d+)\.pdf', url, re.IGNORECASE)
if match:
return match.group(1)
# Pattern: karardergisisayi{N}.pdf
match = re.search(r'karardergisisayi(\d+)\.pdf', url, re.IGNORECASE)
if match:
return match.group(1)
# Pattern: sayı or sayi in URL path with number
match = re.search(r'say[ıi]\s*[-:]?\s*(\d+)', url, re.IGNORECASE)
if match:
return match.group(1)
return None
async def search_decisions(
self,
request: SigortaTahkimSearchRequest
) -> SigortaTahkimSearchResult:
"""
Search for Sigorta Tahkim Komisyonu decisions using Tavily API.
Args:
request: Search request parameters
Returns:
SigortaTahkimSearchResult with matching decisions
"""
try:
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.tavily_api_key}"
}
payload = {
"query": request.keywords,
"country": "turkey",
"include_domains": ["sigortatahkim.org"],
"max_results": request.pageSize,
"search_depth": "advanced"
}
if request.page > 1:
logger.warning(f"Tavily API doesn't support pagination. Page {request.page} requested.")
response = await self.http_client.post(
self.TAVILY_API_URL,
json=payload,
headers=headers
)
response.raise_for_status()
data = response.json()
logger.info(f"Tavily returned {len(data.get('results', []))} results for Sigorta Tahkim")
decisions = []
for result in data.get("results", []):
url = result.get("url", "")
title = result.get("title", "").strip()
content = result.get("content", "")[:500]
issue_num = self._extract_issue_number(url)
doc_id = issue_num if issue_num else url
decision = SigortaTahkimDecisionSummary(
title=title,
document_id=doc_id,
content=content,
url=url
)
decisions.append(decision)
return SigortaTahkimSearchResult(
decisions=decisions,
total_results=len(data.get("results", [])),
page=request.page,
pageSize=request.pageSize
)
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error searching Sigorta Tahkim decisions: {e}")
if e.response.status_code == 401:
raise Exception("Tavily API authentication failed. Check API key.")
raise Exception(f"Failed to search Sigorta Tahkim decisions: {str(e)}")
except Exception as e:
logger.error(f"Error searching Sigorta Tahkim decisions: {e}")
raise Exception(f"Failed to search Sigorta Tahkim decisions: {str(e)}")
# Regex pattern to split decisions within a journal issue
DECISION_HEADER_PATTERN = re.compile(
r'(\d{2}\.\d{2}\.\d{4}\s+Tarih\s+ve\s+K-\d{4}/\d+\s+Sayılı\s+Hakem\s+Kararı)'
)
# Minimum body length to distinguish real decisions from TOC entries
MIN_DECISION_BODY_LENGTH = 1000
async def _download_and_convert_pdf(self, issue_number: str) -> tuple[str, str]:
"""
Download a journal issue PDF and convert to markdown.
Returns:
Tuple of (markdown_content, pdf_url)
"""
issue_num = int(issue_number)
filename = self._get_pdf_filename(issue_num)
pdf_url = f"{self.PDF_BASE_URL}{filename}"
logger.info(f"Downloading Sigorta Tahkim PDF: {pdf_url}")
response = await self.http_client.get(pdf_url, follow_redirects=True)
response.raise_for_status()
pdf_stream = io.BytesIO(response.content)
# markitdown is sync; offload to thread so PDF parsing doesn't block
# the event-loop / other in-flight MCP requests.
result = await asyncio.to_thread(
self.markitdown.convert_stream, pdf_stream, file_extension=".pdf"
)
return result.text_content.strip(), pdf_url
def _split_into_decisions(self, markdown_content: str) -> list[tuple[str, str]]:
"""
Split markdown content into individual decisions.
Returns:
List of (header, body) tuples for decisions with substantial content.
"""
parts = self.DECISION_HEADER_PATTERN.split(markdown_content)
decisions = []
for i in range(1, len(parts) - 1, 2):
header = parts[i].strip()
body = parts[i + 1].strip() if i + 1 < len(parts) else ""
if len(body) >= self.MIN_DECISION_BODY_LENGTH:
decisions.append((header, body))
return decisions
async def get_document_markdown(
self,
issue_number: str,
page_number: int = 1
) -> SigortaTahkimDocumentMarkdown:
"""
Retrieve a Sigorta Tahkim journal issue PDF and convert to Markdown.
Args:
issue_number: Journal issue number (e.g., '64')
page_number: Page number for paginated content (1-indexed)
Returns:
SigortaTahkimDocumentMarkdown with paginated content
"""
try:
markdown_content, pdf_url = await self._download_and_convert_pdf(issue_number)
total_length = len(markdown_content)
total_pages = max(1, math.ceil(total_length / self.DOCUMENT_MARKDOWN_CHUNK_SIZE))
start_idx = (page_number - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE
end_idx = start_idx + self.DOCUMENT_MARKDOWN_CHUNK_SIZE
page_content = markdown_content[start_idx:end_idx]
return SigortaTahkimDocumentMarkdown(
document_id=issue_number,
markdown_content=page_content,
page_number=page_number,
total_pages=total_pages,
source_url=pdf_url
)
except ValueError:
raise Exception(f"Invalid issue number: {issue_number}. Must be a number (e.g., '64').")
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error fetching Sigorta Tahkim issue {issue_number}: {e}")
raise Exception(f"Failed to fetch journal issue {issue_number}: {str(e)}")
except Exception as e:
logger.error(f"Error processing Sigorta Tahkim issue {issue_number}: {e}")
raise Exception(f"Failed to process journal issue {issue_number}: {str(e)}")
async def search_within_issue(
self,
issue_number: str,
keyword: str,
max_results: int = 10
) -> SigortaTahkimSearchWithinResult:
"""
Search for a keyword within a specific journal issue's decisions.
Downloads the PDF, splits into individual decisions, and returns
matching decisions sorted by relevance (match count).
Args:
issue_number: Journal issue number (e.g., '64')
keyword: Search keyword or phrase in Turkish
max_results: Maximum matching decisions to return
Returns:
SigortaTahkimSearchWithinResult with matching decisions
"""
try:
markdown_content, _ = await self._download_and_convert_pdf(issue_number)
decisions = self._split_into_decisions(markdown_content)
logger.info(
f"Searching '{keyword}' within issue {issue_number}: "
f"{len(decisions)} decisions found"
)
keyword_lower = _turkish_lower(keyword)
matches = []
for header, body in decisions:
body_lower = _turkish_lower(body)
count = body_lower.count(keyword_lower)
if count == 0:
continue
# Extract excerpt around the first match
first_pos = body_lower.find(keyword_lower)
excerpt_start = max(0, first_pos - 200)
excerpt_end = min(len(body), first_pos + len(keyword) + 200)
excerpt = body[excerpt_start:excerpt_end].strip()
if excerpt_start > 0:
excerpt = "..." + excerpt
if excerpt_end < len(body):
excerpt = excerpt + "..."
matches.append(SigortaTahkimSearchWithinMatch(
decision_header=header,
relevance_score=count,
excerpt=excerpt,
body_length=len(body)
))
# Sort by relevance (highest match count first)
matches.sort(key=lambda m: m.relevance_score, reverse=True)
matches = matches[:max_results]
return SigortaTahkimSearchWithinResult(
issue_number=issue_number,
keyword=keyword,
total_decisions=len(decisions),
matching_decisions=len(matches),
matches=matches
)
except ValueError:
raise Exception(f"Invalid issue number: {issue_number}. Must be a number (e.g., '64').")
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error in search_within issue {issue_number}: {e}")
raise Exception(f"Failed to fetch journal issue {issue_number}: {str(e)}")
except Exception as e:
logger.error(f"Error in search_within issue {issue_number}: {e}")
raise Exception(f"Failed to search within issue {issue_number}: {str(e)}")