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>
346 lines
13 KiB
Python
346 lines
13 KiB
Python
# 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)}")
|