Compare commits
312
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eb86d0a9a | ||
|
|
0e51ca432a | ||
|
|
08a19fb83c | ||
|
|
15402b4423 | ||
|
|
1b483a6fcf | ||
|
|
cc055103fe | ||
|
|
2c3347643d | ||
|
|
fadc3b0bc0 | ||
|
|
6bbc656dc6 | ||
|
|
a062237474 | ||
|
|
b69eda77af | ||
|
|
3927dcee8f | ||
|
|
3768104679 | ||
|
|
aa580ffafc | ||
|
|
931eb3ca8f | ||
|
|
d258ad2375 | ||
|
|
061887f870 | ||
|
|
ac611f840c | ||
|
|
c938f10ba2 | ||
|
|
1356c4d020 | ||
|
|
5392435c7a | ||
|
|
96a5a538b2 | ||
|
|
26aa3dacc6 | ||
|
|
58457b076f | ||
|
|
4521e1de85 | ||
|
|
8f04010c57 | ||
|
|
7ed9c25687 | ||
|
|
4fdc7a3689 | ||
|
|
1538a4c145 | ||
|
|
a24def2e66 | ||
|
|
6b781b61d2 | ||
|
|
fb29146755 | ||
|
|
42731a2c03 | ||
|
|
ae5d590cca | ||
|
|
ee544dc603 | ||
|
|
355f505da9 | ||
|
|
4c06a5926b | ||
|
|
2cec4dccd6 | ||
|
|
5c2e9cc92b | ||
|
|
a66a3f2053 | ||
|
|
a4d9e2e53d | ||
|
|
036e49a928 | ||
|
|
7f78f87508 | ||
|
|
d8805cb93b | ||
|
|
28ff2e39a5 | ||
|
|
fd08637ca2 | ||
|
|
a5e6baeec8 | ||
|
|
8818a7809a | ||
|
|
12d51e3735 | ||
|
|
efe962abf1 | ||
|
|
1d73265f10 | ||
|
|
f1d3b60efb | ||
|
|
93e64bc1fc | ||
|
|
77e2748ade | ||
|
|
da146cf3ec | ||
|
|
e771c5b3c5 | ||
|
|
1223b37adb | ||
|
|
e26f09aced | ||
|
|
ae5bae2f4a | ||
|
|
a2b50951e9 | ||
|
|
91ad04cf09 | ||
|
|
5cec0df785 | ||
|
|
d51f11c7ba | ||
|
|
b207b16ef7 | ||
|
|
f47147ba44 | ||
|
|
4d57a3939f | ||
|
|
50c6963eee | ||
|
|
def7e7d65e | ||
|
|
82a0d13d25 | ||
|
|
18b552ca2f | ||
|
|
1e96b1888e | ||
|
|
815786a09d | ||
|
|
260adb3ac9 | ||
|
|
7164205425 | ||
|
|
3961a23d3a | ||
|
|
25723f070f | ||
|
|
6376037ccf | ||
|
|
d1728ce114 | ||
|
|
69b5da5cef | ||
|
|
91564bf0a1 | ||
|
|
6f94eca33c | ||
|
|
4122790821 | ||
|
|
0f5bae8bb1 | ||
|
|
4d7da0d3ba | ||
|
|
4f48681b09 | ||
|
|
b401bad890 | ||
|
|
0a80bc535b | ||
|
|
b1da034ea9 | ||
|
|
e900bc03dd | ||
|
|
54f81e18f0 | ||
|
|
4e18e792c5 | ||
|
|
4a3edef287 | ||
|
|
e2ca844ab9 | ||
|
|
a49d0859ea | ||
|
|
a7877f34f4 | ||
|
|
364f3761d7 | ||
|
|
673f996f5f | ||
|
|
90a7a23064 | ||
|
|
443657f9e2 | ||
|
|
f5fa0076f8 | ||
|
|
7a346ef3f6 | ||
|
|
217103f0b6 | ||
|
|
1fbcb65031 | ||
|
|
9e40671798 | ||
|
|
6c8a614872 | ||
|
|
861d9e86ef | ||
|
|
c4b5d3608a | ||
|
|
38e0cc032b | ||
|
|
2c1b8c6f9d | ||
|
|
92f04fbab6 | ||
|
|
515347e29c | ||
|
|
ebefe22a4c | ||
|
|
c93244ee10 | ||
|
|
d84f8a2c88 | ||
|
|
ec40b9d6a2 | ||
|
|
daa16cae99 | ||
|
|
c3bc9e17eb | ||
|
|
c9344fc538 | ||
|
|
d12ad7d900 | ||
|
|
891751043c | ||
|
|
ba447502fe | ||
|
|
c092a7af45 | ||
|
|
34216a9557 | ||
|
|
753283f0e8 | ||
|
|
611456fd49 | ||
|
|
7a1ff0b9ed | ||
|
|
95620285d9 | ||
|
|
8a148899e3 | ||
|
|
fa6c448afa | ||
|
|
cc363e7a6d | ||
|
|
f96c1a2e44 | ||
|
|
856ecdf13d | ||
|
|
f7dac9363a | ||
|
|
b32aabf541 | ||
|
|
945ffe6267 | ||
|
|
17f9b109a2 | ||
|
|
2a24ce03ad | ||
|
|
e34d81be26 | ||
|
|
54e8d61f83 | ||
|
|
35382136c5 | ||
|
|
59d81d4b03 | ||
|
|
06a319c0ee | ||
|
|
a48b003121 | ||
|
|
1620d7a9b0 | ||
|
|
c10069bcc7 | ||
|
|
c0fe7e1305 | ||
|
|
f492109eb7 | ||
|
|
35f3b739d4 | ||
|
|
8f1ca6b854 | ||
|
|
71218996fd | ||
|
|
6bac51dc19 | ||
|
|
b898cad4f4 | ||
|
|
3d172c508a | ||
|
|
48efe74dc0 | ||
|
|
8ae5772c2c | ||
|
|
87cf4fd46d | ||
|
|
d590702272 | ||
|
|
1ebe8847fb | ||
|
|
cb318faeba | ||
|
|
83f54a86a8 | ||
|
|
f5f0f99678 | ||
|
|
b0d7151ba1 | ||
|
|
de9e337163 | ||
|
|
a7ebb8b27e | ||
|
|
01e58ab5ed | ||
|
|
7081bbbd91 | ||
|
|
ca89c9480d | ||
|
|
b751e94847 | ||
|
|
3ccb52e719 | ||
|
|
e8b92e347c | ||
|
|
e65b42abc8 | ||
|
|
71096ae67d | ||
|
|
9fb23dadae | ||
|
|
d77781709a | ||
|
|
c059ec30a7 | ||
|
|
6842ad207d | ||
|
|
bbc56d9409 | ||
|
|
66b5e2631e | ||
|
|
a17853b918 | ||
|
|
0a302bb13b | ||
|
|
28e9a47464 | ||
|
|
6c1efece31 | ||
|
|
eb9441a6f3 | ||
|
|
d006dc8a55 | ||
|
|
55cee6933d | ||
|
|
2f375d74e5 | ||
|
|
82856b25e9 | ||
|
|
8464aedceb | ||
|
|
64c3a2c138 | ||
|
|
318bedd4c5 | ||
|
|
91895f6c1a | ||
|
|
b86c18c842 | ||
|
|
5509380cef | ||
|
|
1c818756e4 | ||
|
|
34ac65dc02 | ||
|
|
e6b7e645ce | ||
|
|
b4f8faf5eb | ||
|
|
a6a9201562 | ||
|
|
a5e1ad1778 | ||
|
|
698a9db0fb | ||
|
|
732edba35c | ||
|
|
73c72a6039 | ||
|
|
84b33f1f91 | ||
|
|
4bcd95cc41 | ||
|
|
2946a4863d | ||
|
|
13ed5b3c67 | ||
|
|
a29a624598 | ||
|
|
a094c4bf7f | ||
|
|
9d41bdd30a | ||
|
|
8d91d2c764 | ||
|
|
e70554dcc9 | ||
|
|
cb6d6ee8df | ||
|
|
7e6819affa | ||
|
|
9880316fd2 | ||
|
|
6919ac552b | ||
|
|
7802395ef0 | ||
|
|
f25661c7da | ||
|
|
dc57743939 | ||
|
|
5d910274a4 | ||
|
|
bad36dd664 | ||
|
|
0d8cd181b8 | ||
|
|
424960fafb | ||
|
|
c6daebe924 | ||
|
|
1aaf1e1bd4 | ||
|
|
0f9f5da5b3 | ||
|
|
c79aacbb73 | ||
|
|
caf4fdcfa0 | ||
|
|
2fe81b4298 | ||
|
|
e0bba7625f | ||
|
|
bbf99870eb | ||
|
|
f23f74df80 | ||
|
|
1d289c6e9b | ||
|
|
91f72bea47 | ||
|
|
f7500aa79d | ||
|
|
5ea1cf924a | ||
|
|
346f891b5b | ||
|
|
dfb703d7a5 | ||
|
|
81f5f49b8a | ||
|
|
3fcb0cf773 | ||
|
|
e34bbdc355 | ||
|
|
3746804b20 | ||
|
|
553bf61106 | ||
|
|
973a25980b | ||
|
|
352969deca | ||
|
|
2e369304d8 | ||
|
|
3cd6ba7d62 | ||
|
|
f3e81e0701 | ||
|
|
2179582614 | ||
|
|
a82be5979f | ||
|
|
4c7da4fe10 | ||
|
|
5a930d5cea | ||
|
|
a95cc14de6 | ||
|
|
425e2a4247 | ||
|
|
a8b45a7b1c | ||
|
|
fa78df94ce | ||
|
|
262ef5bef5 | ||
|
|
ad0c9834e7 | ||
|
|
5accd34823 | ||
|
|
849cea2ca8 | ||
|
|
3fceb95b91 | ||
|
|
f9c23e1680 | ||
|
|
1a190fbc3e | ||
|
|
4927f1adda | ||
|
|
b63de52910 | ||
|
|
ddbbeb6c4e | ||
|
|
f755cdf438 | ||
|
|
2a24f92702 | ||
|
|
98abb1d658 | ||
|
|
7c89f2cf84 | ||
|
|
9bc9688439 | ||
|
|
cf90036f99 | ||
|
|
b0847c789b | ||
|
|
0d0a8b8f5e | ||
|
|
a47c46ae83 | ||
|
|
bca7c8f99a | ||
|
|
48fe348468 | ||
|
|
acacef5bad | ||
|
|
c77cc3f8c1 | ||
|
|
48927a809a | ||
|
|
017f15c785 | ||
|
|
062298c005 | ||
|
|
c2c83b0f3f | ||
|
|
f13d99b36e | ||
|
|
ae47904ce1 | ||
|
|
cde3a60d82 | ||
|
|
41e4742346 | ||
|
|
9d8fd52e99 | ||
|
|
e703978e0e | ||
|
|
3bcf2bbf2a | ||
|
|
12bb62cd9c | ||
|
|
48adc4159e | ||
|
|
47e11dc3be | ||
|
|
180c29e82e | ||
|
|
e29178e25a | ||
|
|
957df46b7d | ||
|
|
9df6eba37c | ||
|
|
94553c3b78 | ||
|
|
539b0c7041 | ||
|
|
4a2af434ab | ||
|
|
c1352c868d | ||
|
|
2271423c91 | ||
|
|
d59b498be7 | ||
|
|
3c10913680 | ||
|
|
32c4d06eb6 | ||
|
|
f892d430cb | ||
|
|
b19ccc60d7 | ||
|
|
f6e95140cf | ||
|
|
4d86833fd6 | ||
|
|
81d82369da | ||
|
|
ae9bdebdad | ||
|
|
f82dc9f493 | ||
|
|
970dd65bce |
+183
@@ -0,0 +1,183 @@
|
||||
# flyctl launch added from .gitignore
|
||||
# Byte-compiled / optimized / DLL files
|
||||
**/__pycache__
|
||||
**/*.py[cod]
|
||||
**/*$py.class
|
||||
|
||||
# C extensions
|
||||
**/*.so
|
||||
|
||||
# Distribution / packaging
|
||||
**/.Python
|
||||
**/build
|
||||
**/develop-eggs
|
||||
**/dist
|
||||
**/downloads
|
||||
**/eggs
|
||||
**/.eggs
|
||||
**/lib
|
||||
**/lib64
|
||||
**/parts
|
||||
**/sdist
|
||||
**/var
|
||||
**/wheels
|
||||
**/share/python-wheels
|
||||
**/*.egg-info
|
||||
**/.installed.cfg
|
||||
**/*.egg
|
||||
**/MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
**/*.manifest
|
||||
**/*.spec
|
||||
|
||||
# Installer logs
|
||||
**/pip-log.txt
|
||||
**/pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
**/htmlcov
|
||||
**/.tox
|
||||
**/.nox
|
||||
**/.coverage
|
||||
**/.coverage.*
|
||||
**/.cache
|
||||
**/nosetests.xml
|
||||
**/coverage.xml
|
||||
**/*.cover
|
||||
**/*.py,cover
|
||||
**/.hypothesis
|
||||
**/.pytest_cache
|
||||
**/cover
|
||||
|
||||
# Translations
|
||||
**/*.mo
|
||||
**/*.pot
|
||||
|
||||
# Django stuff:
|
||||
**/*.log
|
||||
**/local_settings.py
|
||||
**/db.sqlite3
|
||||
**/db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
**/instance
|
||||
**/.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
**/.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
**/docs/_build
|
||||
|
||||
# PyBuilder
|
||||
**/.pybuilder
|
||||
**/target
|
||||
|
||||
# Jupyter Notebook
|
||||
**/.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
**/profile_default
|
||||
**/ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
**/.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
**/__pypackages__
|
||||
|
||||
# Celery stuff
|
||||
**/celerybeat-schedule
|
||||
**/celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
**/*.sage.py
|
||||
|
||||
# Environments
|
||||
**/.env
|
||||
**/.venv
|
||||
**/env
|
||||
**/venv
|
||||
**/ENV
|
||||
**/env.bak
|
||||
**/venv.bak
|
||||
|
||||
# Spyder project settings
|
||||
**/.spyderproject
|
||||
**/.spyproject
|
||||
|
||||
# Rope project settings
|
||||
**/.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
site
|
||||
|
||||
# mypy
|
||||
**/.mypy_cache
|
||||
**/.dmypy.json
|
||||
**/dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
**/.pyre
|
||||
|
||||
# pytype static type analyzer
|
||||
**/.pytype
|
||||
|
||||
# Cython debug symbols
|
||||
**/cython_debug
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# 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
|
||||
|
||||
**/*.html
|
||||
**/fast-mcp-docs.md
|
||||
|
||||
# Debug and test files
|
||||
**/debug_*
|
||||
**/test_*
|
||||
**/CLAUDE.md
|
||||
|
||||
# ASGI/Deployment files
|
||||
**/ssl
|
||||
**/*.pem
|
||||
**/*.key
|
||||
**/*.crt
|
||||
|
||||
# Docker volumes
|
||||
**/redis-data
|
||||
|
||||
# Production logs
|
||||
**/logs/*.log.*
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
# OAuth Configuration for Clerk + Google
|
||||
# Copy this file to .env and fill in your actual values
|
||||
|
||||
# =============================================================================
|
||||
# AUTHENTICATION SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
# Enable/disable authentication (set to "true" to enable OAuth)
|
||||
ENABLE_AUTH=false
|
||||
|
||||
# =============================================================================
|
||||
# CLERK CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
# Clerk API keys (get from https://dashboard.clerk.com/)
|
||||
CLERK_SECRET_KEY=sk_test_your_secret_key_here
|
||||
CLERK_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
|
||||
|
||||
# OAuth Redirect URLs
|
||||
CLERK_OAUTH_REDIRECT_URL=http://localhost:8000/auth/callback
|
||||
CLERK_FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# Clerk domain issuer (usually auto-configured)
|
||||
CLERK_ISSUER=https://your-clerk-domain.clerk.accounts.dev
|
||||
CLERK_DOMAIN=your-clerk-domain
|
||||
|
||||
# =============================================================================
|
||||
# GOOGLE OAUTH SETTINGS
|
||||
# =============================================================================
|
||||
# Note: Google OAuth is configured through Clerk dashboard
|
||||
# You need to:
|
||||
# 1. Go to Clerk Dashboard > Social Connections
|
||||
# 2. Enable Google provider
|
||||
# 3. Add your Google OAuth client ID and secret
|
||||
# 4. Configure redirect URIs in Google Console
|
||||
|
||||
# =============================================================================
|
||||
# STRIPE CONFIGURATION (for payments/subscriptions)
|
||||
# =============================================================================
|
||||
|
||||
STRIPE_SECRET=sk_test_your_stripe_secret_key_here
|
||||
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
|
||||
|
||||
# =============================================================================
|
||||
# SERVER CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
# CORS origins (comma-separated list)
|
||||
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8000,https://yourdomain.com
|
||||
|
||||
# Server settings
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Base URL for the application (used for OAuth callbacks and API URLs)
|
||||
BASE_URL=http://localhost:8000
|
||||
|
||||
# JWT Secret for MCP token generation
|
||||
JWT_SECRET_KEY=your_jwt_secret_key_here
|
||||
|
||||
# =============================================================================
|
||||
# MCP SERVER SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
# Additional MCP server configuration can go here
|
||||
# For example, rate limiting, feature flags, etc.
|
||||
|
||||
# Example: Rate limiting
|
||||
# MAX_REQUESTS_PER_MINUTE=60
|
||||
# BURST_CAPACITY=20
|
||||
|
||||
# =============================================================================
|
||||
# SEMANTIC SEARCH SETTINGS (Optional)
|
||||
# =============================================================================
|
||||
|
||||
# Embedding provider for the semantic_search tool.
|
||||
# Pick exactly one of: OpenRouter (hosted) or Local (your own server).
|
||||
|
||||
# --- Option A: OpenRouter (hosted, default) -----------------------------------
|
||||
# Get your API key from: https://openrouter.ai/keys
|
||||
# If neither this nor EMBEDDING_PROVIDER=local is set, semantic search is off.
|
||||
OPENROUTER_API_KEY=sk-or-v1-your_openrouter_api_key_here
|
||||
|
||||
# Optional: override the OpenRouter embedding model and dimension.
|
||||
# Defaults: google/gemini-embedding-001 at 3072 dims (paid on OpenRouter).
|
||||
# Pick any model from https://openrouter.ai/models?modality=embedding
|
||||
# and set the dimension to that model's output size — they must match.
|
||||
# OPENROUTER_EMBEDDING_MODEL=google/gemini-embedding-001
|
||||
# OPENROUTER_EMBEDDING_DIMENSION=3072
|
||||
|
||||
# --- Option B: Local OpenAI-compatible server (no API key required) ----------
|
||||
# Recommended for Turkish: intfloat/multilingual-e5-large served by HuggingFace
|
||||
# Text Embeddings Inference (TEI). One-line setup:
|
||||
#
|
||||
# docker run -p 8080:80 ghcr.io/huggingface/text-embeddings-inference:latest \
|
||||
# --model-id intfloat/multilingual-e5-large
|
||||
#
|
||||
# Then uncomment the block below. Other model families work too — set
|
||||
# EMBEDDING_PROMPT_STYLE to match: e5 / gemini / raw.
|
||||
#
|
||||
# EMBEDDING_PROVIDER=local
|
||||
# LOCAL_EMBEDDING_BASE_URL=http://localhost:8080/v1
|
||||
# LOCAL_EMBEDDING_MODEL=intfloat/multilingual-e5-large
|
||||
# LOCAL_EMBEDDING_DIMENSION=1024
|
||||
# EMBEDDING_PROMPT_STYLE=e5
|
||||
# LOCAL_EMBEDDING_API_KEY= # most local servers ignore this
|
||||
#
|
||||
# Ollama fallback (if you prefer Ollama and don't need top Turkish quality):
|
||||
# ollama serve && ollama pull nomic-embed-text
|
||||
# EMBEDDING_PROVIDER=local
|
||||
# LOCAL_EMBEDDING_BASE_URL=http://localhost:11434/v1
|
||||
# LOCAL_EMBEDDING_MODEL=nomic-embed-text
|
||||
# LOCAL_EMBEDDING_DIMENSION=768
|
||||
# EMBEDDING_PROMPT_STYLE=raw # nomic uses its own search_query/search_document
|
||||
|
||||
# =============================================================================
|
||||
# USAGE INSTRUCTIONS
|
||||
# =============================================================================
|
||||
|
||||
# 1. Copy this file to .env:
|
||||
# cp .env.example .env
|
||||
|
||||
# 2. Get Clerk credentials:
|
||||
# - Sign up at https://clerk.com/
|
||||
# - Create a new application
|
||||
# - Go to API Keys tab
|
||||
# - Copy Secret Key and Publishable Key
|
||||
|
||||
# 3. Configure Google OAuth in Clerk:
|
||||
# - In Clerk Dashboard, go to Social Connections
|
||||
# - Enable Google provider
|
||||
# - Get Google OAuth credentials from Google Console
|
||||
# - Add redirect URI: http://localhost:8000/auth/callback
|
||||
|
||||
# 4. Update OAuth URLs:
|
||||
# - Set CLERK_OAUTH_REDIRECT_URL to your callback URL
|
||||
# - Set CLERK_FRONTEND_URL to your frontend application URL
|
||||
|
||||
# 5. Enable authentication:
|
||||
# - Set ENABLE_AUTH=true
|
||||
|
||||
# 6. Test the OAuth flow:
|
||||
# - Start server: uvicorn asgi_app:app --reload
|
||||
# - Visit: http://localhost:8000/auth/login
|
||||
# - Complete OAuth flow with Google
|
||||
# - Check: http://localhost:8000/auth/user
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Publish to PyPI
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch: # Manual trigger for testing
|
||||
|
||||
jobs:
|
||||
pypi-publish:
|
||||
name: Upload release to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/p/yargi-mcp
|
||||
permissions:
|
||||
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install build
|
||||
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
|
||||
- name: Publish package to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
skip-existing: true
|
||||
+57
-1
@@ -1,3 +1,6 @@
|
||||
# Serena
|
||||
.serena/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
@@ -160,4 +163,57 @@ cython_debug/
|
||||
#.idea/
|
||||
.DS_Store
|
||||
hello.py
|
||||
*.toml
|
||||
|
||||
*.html
|
||||
fast-mcp-docs.md
|
||||
|
||||
# Debug and test files
|
||||
debug_*
|
||||
test_*
|
||||
CLAUDE.md
|
||||
|
||||
# ASGI/Deployment files
|
||||
ssl/
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
|
||||
# Docker volumes
|
||||
redis-data/
|
||||
|
||||
# Production logs
|
||||
logs/*.log.*
|
||||
|
||||
# Remove these lines - we need deployment files in git:
|
||||
# Dockerfile - NEEDED for SaaS deployment
|
||||
# fly.toml - NEEDED for Fly.io deployment
|
||||
# .github/workflows/fly-deploy.yml - NEEDED for GitHub Actions
|
||||
|
||||
GEMINI.md
|
||||
fly.toml
|
||||
scripts/deploy-flyio.sh
|
||||
docs/DEPLOYMENT_FLYIO.md
|
||||
setup_jwt_template.py
|
||||
mcp_server_main.py.backup
|
||||
mcp_overhead_content.json
|
||||
ANTHROPIC_TEST_README.md
|
||||
extract_mcp_overhead.py
|
||||
mcp_overhead_content.txt
|
||||
mcp_overhead_summary.txt
|
||||
run_http_server.py
|
||||
run_local_test.py
|
||||
|
||||
# MCP overhead analysis files
|
||||
mcp_overhead_*.json
|
||||
mcp_overhead_*.txt
|
||||
mcp_test_results_*.json
|
||||
mcp_quick_test_*.json
|
||||
|
||||
# General text files (temporary notes, etc)
|
||||
*.txt
|
||||
analyze_playwright_mcp.py
|
||||
measure_mcp_directly.py
|
||||
playwright_mcp_overhead.json
|
||||
simple_test.py
|
||||
analyze_anayasa_html.py
|
||||
CLAUDE.md
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
+54
@@ -0,0 +1,54 @@
|
||||
# Use Python 3.12 slim image
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies (gcc/g++ kept in case any wheel falls back to source build)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy project metadata first for better Docker layer caching
|
||||
COPY pyproject.toml ./
|
||||
COPY README.md ./
|
||||
|
||||
# Copy entry points
|
||||
COPY app.py ./
|
||||
COPY asgi_app.py ./
|
||||
COPY mcp_server_main.py ./
|
||||
|
||||
# Copy MCP modules and shared packages
|
||||
COPY anayasa_mcp_module ./anayasa_mcp_module
|
||||
COPY bddk_mcp_module ./bddk_mcp_module
|
||||
COPY bedesten_mcp_module ./bedesten_mcp_module
|
||||
COPY btk_mcp_module ./btk_mcp_module
|
||||
COPY danistay_mcp_module ./danistay_mcp_module
|
||||
COPY emsal_mcp_module ./emsal_mcp_module
|
||||
COPY gib_mcp_module ./gib_mcp_module
|
||||
COPY kik_mcp_module ./kik_mcp_module
|
||||
COPY kvkk_mcp_module ./kvkk_mcp_module
|
||||
COPY rekabet_mcp_module ./rekabet_mcp_module
|
||||
COPY sayistay_mcp_module ./sayistay_mcp_module
|
||||
COPY sigorta_tahkim_mcp_module ./sigorta_tahkim_mcp_module
|
||||
COPY uyusmazlik_mcp_module ./uyusmazlik_mcp_module
|
||||
COPY yargitay_mcp_module ./yargitay_mcp_module
|
||||
COPY semantic_search ./semantic_search
|
||||
|
||||
# Install the package with ASGI extras (uvicorn + starlette)
|
||||
RUN pip install --no-cache-dir -e ".[asgi]"
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Set environment variables
|
||||
ENV PORT=8000
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD python -c "import httpx; httpx.get('http://localhost:8000/health', timeout=5)" || exit 1
|
||||
|
||||
# Run the ASGI application
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -1,164 +1,521 @@
|
||||
# Yargı MCP: Türk Hukuk Kaynakları için MCP Sunucusu
|
||||
|
||||
> ## ✨ Profesyonel Sürüm Hazır: Yargı MCP Pro
|
||||
>
|
||||
> **Mevzuat ve içtihatı tek bir MCP sunucusunda birleştiren** profesyonel sürüm yayında:
|
||||
>
|
||||
> 👉 **https://yargi.betaspacestudio.com**
|
||||
|
||||
> ## 🚨 SUNUCU YENİ ADRESE TAŞINDI
|
||||
>
|
||||
> **Yeni Remote MCP adresi:** `https://yargimcp.surucu.dev/mcp`
|
||||
>
|
||||
> **Eski adres** (`https://yargimcp.fastmcp.app/mcp`) **artık kullanım dışıdır** — yalnızca taşındığını bildiren bir uyarı tool'u döner.
|
||||
>
|
||||
> **Yapmanız gereken:** MCP istemcinizdeki (Claude Desktop, 5ire, Google Antigravity, ChatGPT vb.) sunucu URL'sini yukarıdaki yeni adresle güncelleyin.
|
||||
|
||||
## Word'den UDF'ye profesyonel dönüşüm için yeni uygulamam [udfcevir.com](https://udfcevir.com) adresinde!
|
||||
|
||||
[](https://www.star-history.com/#saidsurucu/yargi-mcp&Date)
|
||||
|
||||
Bu proje, çeşitli Türk hukuk kaynaklarına (Yargıtay, Danıştay, Emsal Kararlar, Uyuşmazlık Mahkemesi ve Anayasa Mahkemesi - Norm Denetimi ile Bireysel Başvuru Kararları) erişimi kolaylaştıran bir [FastMCP](https://gofastmcp.com/) sunucusu oluşturur. Bu sayede, bu kaynaklardan veri arama ve belge getirme işlemleri, Model Context Protocol (MCP) destekleyen LLM (Büyük Dil Modeli) uygulamaları (örneğin Claude Desktop) ve diğer istemciler tarafından araç (tool) olarak kullanılabilir hale gelir.
|
||||
Bu proje, çeşitli Türk hukuk kaynaklarına (Yargıtay, Danıştay, Emsal Kararlar, Uyuşmazlık Mahkemesi, Anayasa Mahkemesi - Norm Denetimi ile Bireysel Başvuru Kararları, Kamu İhale Kurulu Kararları, Rekabet Kurumu Kararları, Sayıştay Kararları, KVKK Kararları, BDDK Kararları, BTK Kararları, GİB Özelgeleri ve Sigorta Tahkim Komisyonu Kararları) erişimi kolaylaştıran bir [FastMCP](https://gofastmcp.com/) sunucusu oluşturur. Bu sayede, bu kaynaklardan veri arama ve belge getirme işlemleri, Model Context Protocol (MCP) destekleyen LLM (Büyük Dil Modeli) uygulamaları (örneğin Claude Desktop veya [5ire](https://5ire.app)) ve diğer istemciler tarafından araç (tool) olarak kullanılabilir hale gelir.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 5 Dakikada Başla (Remote MCP)
|
||||
|
||||
### ✅ Kurulum Gerektirmez! Hemen Kullan!
|
||||
|
||||
🔗 **Remote MCP Adresi:** `https://yargimcp.surucu.dev/mcp`
|
||||
|
||||
> ⚠️ **Eski adres** `https://yargimcp.fastmcp.app/mcp` **artık kullanım dışıdır** — yalnızca taşındığını bildiren bir uyarı tool'u döner. Lütfen yukarıdaki yeni adresi kullanın.
|
||||
|
||||
### Claude Desktop ile Kullanım (Ücretli abonelik gerekir)
|
||||
|
||||
1. **Claude Desktop'ı açın**
|
||||
2. **Settings → Connectors → Add Custom Connector**
|
||||
3. **Bilgileri girin:**
|
||||
- **Name:** `Yargı MCP`
|
||||
- **URL:** `https://yargimcp.surucu.dev/mcp`
|
||||
4. **Add** butonuna tıklayın
|
||||
5. **Hemen kullanmaya başlayın!** 🎉
|
||||
|
||||
### Google Antigravity ile Kullanım (Lokal `uv` Kurulumu — Kopyala-Yapıştır)
|
||||
|
||||
> **Ön Gereksinimler:** Bilgisayarınızda **Python**, **`uv`** ([kurulum](https://docs.astral.sh/uv/getting-started/installation/)) ve **Node.js** ([indir](https://nodejs.org/en/download)) kurulu olmalı. (Node.js yalnızca aşağıdaki kurulum komutunu çalıştırmak için gerekir; MCP'yi `uvx` çalıştırır.)
|
||||
|
||||
Aşağıdaki **bloğun tamamını** terminale yapıştırın. Komut, Antigravity'nin okuduğu `~/.gemini/config/mcp_config.json` dosyasını sizin yerinize oluşturur/günceller (varsa diğer sunucularınız korunur):
|
||||
|
||||
**macOS / Linux** (Terminal):
|
||||
|
||||
```bash
|
||||
node - <<'YARGI'
|
||||
const fs=require("fs"),os=require("os"),path=require("path");
|
||||
const dir=path.join(os.homedir(),".gemini","config"),file=path.join(dir,"mcp_config.json");
|
||||
fs.mkdirSync(dir,{recursive:true});
|
||||
let cfg={};try{cfg=JSON.parse(fs.readFileSync(file,"utf8"))}catch{}
|
||||
if(typeof cfg!=="object"||cfg===null||Array.isArray(cfg))cfg={};
|
||||
if(typeof cfg.mcpServers!=="object"||cfg.mcpServers===null)cfg.mcpServers={};
|
||||
cfg.mcpServers["yargi-mcp"]={command:"uvx",args:["yargi-mcp"]};
|
||||
fs.writeFileSync(file,JSON.stringify(cfg,null,2)+"\n");
|
||||
console.log("yargi-mcp eklendi -> "+file);
|
||||
YARGI
|
||||
```
|
||||
|
||||
**Windows** (PowerShell):
|
||||
|
||||
```powershell
|
||||
@'
|
||||
const fs=require("fs"),os=require("os"),path=require("path");
|
||||
const dir=path.join(os.homedir(),".gemini","config"),file=path.join(dir,"mcp_config.json");
|
||||
fs.mkdirSync(dir,{recursive:true});
|
||||
let cfg={};try{cfg=JSON.parse(fs.readFileSync(file,"utf8"))}catch{}
|
||||
if(typeof cfg!=="object"||cfg===null||Array.isArray(cfg))cfg={};
|
||||
if(typeof cfg.mcpServers!=="object"||cfg.mcpServers===null)cfg.mcpServers={};
|
||||
cfg.mcpServers["yargi-mcp"]={command:"uvx",args:["yargi-mcp"]};
|
||||
fs.writeFileSync(file,JSON.stringify(cfg,null,2)+"\n");
|
||||
console.log("yargi-mcp eklendi -> "+file);
|
||||
'@ | node -
|
||||
```
|
||||
|
||||
Komut `yargi-mcp eklendi -> ...` çıktısını verdiğinde kurulum tamamlanmıştır. Antigravity'yi (açıksa kapatıp) yeniden başlatın; `yargi-mcp` araçları otomatik yüklenir.
|
||||
|
||||
> 💡 **İpucu:** Lokal kurulumda hukuk kaynaklarına erişim doğrudan bilgisayarınızda `uvx yargi-mcp` ile çalışır; uzaktan sunucuya ihtiyaç duymaz.
|
||||
|
||||
### Remote MCP Sorun Giderme
|
||||
|
||||
`https://yargimcp.surucu.dev/mcp` bir web sayfası değil, Streamable HTTP MCP uç noktasıdır. Tarayıcıda açınca veya düz `curl` ile GET isteği atınca `406 Not Acceptable` ve `Client must accept text/event-stream` benzeri bir yanıt görmek normaldir; bu, sunucunun kapalı olduğu anlamına gelmez. MCP istemcisi `Accept: application/json, text/event-stream` başlığıyla JSON-RPC isteği göndermelidir.
|
||||
|
||||
Hızlı sağlık kontrolü için tarayıcıda şu adresleri açabilirsiniz:
|
||||
|
||||
- `https://yargimcp.surucu.dev/health` — servis sağlık durumu
|
||||
|
||||
Claude.ai veya başka bir istemci "araç yok" gibi davranırsa:
|
||||
|
||||
1. Connector'ı kaldırıp yeniden ekleyin.
|
||||
2. URL olarak önce `https://yargimcp.surucu.dev/mcp` deneyin; istemciniz yönlendirmeleri takip etmiyorsa `https://yargimcp.surucu.dev/mcp/` deneyin.
|
||||
3. Eski `https://yargimcp.fastmcp.app/mcp` adresinin istemci ayarlarında veya önbellekte kalmadığından emin olun.
|
||||
4. İstemcinin remote/Streamable HTTP MCP desteklediğini ve `text/event-stream` kabul ettiğini kontrol edin.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
🎯 **Temel Özellikler**
|
||||
|
||||
🚀 **YÜKSEK PERFORMANS OPTİMİZASYONU:** Bu MCP sunucusu **%61.8 token azaltma** ile optimize edilmiştir (8,692 token tasarrufu). Claude AI ile daha hızlı yanıt süreleri ve daha verimli etkileşim sağlar.
|
||||
|
||||
* Çeşitli Türk hukuk veritabanlarına programatik erişim için standart bir MCP arayüzü.
|
||||
* **Kapsamlı Mahkeme Daire/Kurul Filtreleme:** 79 farklı daire/kurul filtreleme seçeneği
|
||||
* **Dual/Triple API Desteği:** Her mahkeme için birden fazla API kaynağı ile maksimum kapsama
|
||||
* **Kapsamlı Tarih Filtreleme:** Tüm Bedesten API araçlarında ISO 8601 formatında tarih aralığı filtreleme
|
||||
* **Kesin Cümle Arama:** Tüm Bedesten API araçlarında çift tırnak ile tam cümle arama desteği
|
||||
* Aşağıdaki kurumların kararlarını arama ve getirme yeteneği:
|
||||
* **Yargıtay:** Detaylı kriterlerle karar arama ve karar metinlerini Markdown formatında getirme.
|
||||
* **Danıştay:** Anahtar kelime bazlı ve detaylı kriterlerle karar arama; karar metinlerini Markdown formatında getirme.
|
||||
* **Yargıtay:** Detaylı kriterlerle karar arama ve karar metinlerini Markdown formatında getirme. **Dual API** (Ana + Bedesten) + **52 Daire/Kurul Filtreleme** + **Tarih & Kesin Cümle Arama** (Hukuk/Ceza Daireleri, Genel Kurullar)
|
||||
* **Danıştay:** Anahtar kelime bazlı ve detaylı kriterlerle karar arama; karar metinlerini Markdown formatında getirme. **Triple API** (Keyword + Detailed + Bedesten) + **27 Daire/Kurul Filtreleme** + **Tarih & Kesin Cümle Arama** (İdari Daireler, Vergi/İdare Kurulları, Askeri Yüksek İdare Mahkemesi)
|
||||
* **Yerel Hukuk Mahkemeleri:** Bedesten API ile yerel hukuk mahkemesi kararlarına erişim + **Tarih & Kesin Cümle Arama**
|
||||
* **İstinaf Hukuk Mahkemeleri:** Bedesten API ile istinaf mahkemesi kararlarına erişim + **Tarih & Kesin Cümle Arama**
|
||||
* **Kanun Yararına Bozma (KYB):** Bedesten API ile olağanüstü kanun yoluna erişim + **Tarih & Kesin Cümle Arama**
|
||||
* **Emsal (UYAP):** Detaylı kriterlerle emsal karar arama ve karar metinlerini Markdown formatında getirme.
|
||||
* **Uyuşmazlık Mahkemesi:** Form tabanlı kriterlerle karar arama ve karar metinlerini (URL ile erişilen) Markdown formatında getirme.
|
||||
* **Anayasa Mahkemesi (Norm Denetimi):** Kapsamlı kriterlerle norm denetimi kararlarını arama; uzun karar metinlerini (5.000 karakterlik) sayfalanmış Markdown formatında getirme.
|
||||
* **Anayasa Mahkemesi (Bireysel Başvuru):** Kapsamlı kriterlerle bireysel başvuru "Karar Arama Raporu" oluşturma ve listedeki kararların metinlerini (5.000 karakterlik) sayfalanmış Markdown formatında getirme.
|
||||
* **KİK (Kamu İhale Kurulu):** Çeşitli kriterlerle Kurul kararlarını arama; uzun karar metinlerini (varsayılan 5.000 karakterlik) sayfalanmış Markdown formatında getirme.
|
||||
* **Rekabet Kurumu:** Çeşitli kriterlerle Kurul kararlarını arama; karar metinlerini Markdown formatında getirme.
|
||||
* **Sayıştay:** 3 karar türü ile kapsamlı denetim kararlarına erişim + **8 Daire Filtreleme** + **Tarih Aralığı & İçerik Arama** (Genel Kurul yorumlayıcı kararları, Temyiz Kurulu itiraz kararları, Daire ilk derece denetim kararları)
|
||||
* **KVKK (Kişisel Verilerin Korunması Kurulu):** Brave Search API ile veri koruma kararlarını arama; uzun karar metinlerini (5.000 karakterlik) sayfalanmış Markdown formatında getirme + **Türkçe Arama** + **Site Hedeflemeli Arama** (kvkk.gov.tr kararları)
|
||||
* **BDDK (Bankacılık Düzenleme ve Denetleme Kurumu):** Bankacılık düzenleme kararlarını arama; karar metinlerini Markdown formatında getirme + **Optimized Search** + **"Karar Sayısı" Targeting** + **Spesifik URL Filtreleme** (bddk.org.tr/Mevzuat/DokumanGetir)
|
||||
* **BTK (Bilgi Teknolojileri ve İletişim Kurumu):** Kurul Kararlarını arama (anahtar kelime + karar no + karar tarihi + yayın tarihi + ilgili birim filtreleri); karar PDF'lerini (5.000 karakterlik) sayfalanmış Markdown formatında getirme (btk.gov.tr)
|
||||
* **GİB (Gelir İdaresi Başkanlığı) Özelgeleri:** Resmi vergi özelgelerini arama (18.000+ özelge: KDV, Kurumlar, Gelir, ÖTV, Damga vb.); tam metni sayfalanmış Markdown formatında getirme + **Keyword + Özelge No + Kanun No + Tarih Aralığı** + **Otomatik ISO 8601 Dönüşümü** + **Metadata Başlık Bloğu**
|
||||
* **Sigorta Tahkim Komisyonu:** Hakem Karar Dergisi (64 sayı, 2010-2025) içindeki sigorta tahkim kararlarını arama; dergi PDF'lerini Markdown formatında getirme + **Sayı İçi Karar Arama** + **Türkçe Büyük/Küçük Harf Desteği** + **Relevance Scoring**
|
||||
|
||||
* Karar metinlerinin daha kolay işlenebilmesi için Markdown formatına çevrilmesi.
|
||||
* Claude Desktop uygulaması ile `fastmcp install` komutu kullanılarak kolay entegrasyon.
|
||||
* Yargı MCP artık [5ire](https://5ire.app) gibi Claude Desktop haricindeki MCP istemcilerini de destekliyor!
|
||||
---
|
||||
<details>
|
||||
<summary>🚀 <strong>Claude Haricindeki Modellerle Kullanmak İçin Çok Kolay Kurulum (Örnek: 5ire için)</strong></summary>
|
||||
|
||||
📋 **Ön Gereksinimler**
|
||||
Bu bölüm, Yargı MCP aracını 5ire gibi Claude Desktop dışındaki MCP istemcileriyle kullanmak isteyenler içindir.
|
||||
|
||||
* **Python Sürümü:** Python 3.10 veya daha yeni bir sürümünün sisteminizde kurulu olması gerekmektedir. Python'ı [python.org](https://www.python.org/) adresinden indirebilirsiniz.
|
||||
* **Paket Yöneticisi:** `pip` (Python ile birlikte gelir) veya tercihen `uv` ([Astral](https://astral.sh/uv) tarafından geliştirilen hızlı Python paket yöneticisi).
|
||||
|
||||
⚙️ **Kurulum Adımları (Claude Desktop Entegrasyonu Odaklı)**
|
||||
|
||||
Claude Desktop uygulamasına yükleme yapabilmek için öncelikle `uv` (önerilir) ve `fastmcp` komut satırı araçlarını kurmanız, ardından proje dosyalarını almanız gerekmektedir.
|
||||
|
||||
**1. `uv` Kurulumu (Önerilir)**
|
||||
|
||||
* **macOS ve Linux için:**
|
||||
```bash
|
||||
curl -LsSf [https://astral.sh/uv/install.sh](https://astral.sh/uv/install.sh) | sh
|
||||
* **Python Kurulumu:** Sisteminizde Python 3.11 veya üzeri kurulu olmalıdır. Kurulum sırasında "**Add Python to PATH**" (Python'ı PATH'e ekle) seçeneğini işaretlemeyi unutmayın. [Buradan](https://www.python.org/downloads/) indirebilirsiniz.
|
||||
* **Git Kurulumu (Windows):** Bilgisayarınıza [git](https://git-scm.com/downloads/win) yazılımını indirip kurun. "Git for Windows/x64 Setup" seçeneğini indirmelisiniz.
|
||||
* **`uv` Kurulumu:**
|
||||
* **Windows Kullanıcıları (PowerShell):** Bir CMD ekranı açın ve bu kodu çalıştırın: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`
|
||||
* **Mac/Linux Kullanıcıları (Terminal):** Bir Terminal ekranı açın ve bu kodu çalıştırın: `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
||||
* **Microsoft Visual C++ Redistributable (Windows):** Bazı Python paketlerinin doğru çalışması için gereklidir. [Buradan](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170) indirip kurun.
|
||||
* İşletim sisteminize uygun [5ire](https://5ire.app) MCP istemcisini indirip kurun.
|
||||
* 5ire'ı açın. **Workspace -> Providers** menüsünden kullanmak istediğiniz LLM servisinin API anahtarını girin.
|
||||
* **Tools** menüsüne girin. **+Local** veya **New** yazan butona basın.
|
||||
* **Tool Key:** `yargimcp`
|
||||
* **Name:** `Yargı MCP`
|
||||
* **Command:**
|
||||
```
|
||||
* **Windows için (PowerShell kullanarak):**
|
||||
```powershell
|
||||
powershell -c "irm [https://astral.sh/uv/install.ps1](https://astral.sh/uv/install.ps1) | iex"
|
||||
uvx yargi-mcp
|
||||
```
|
||||
* Kurulumdan sonra, `uv` komutunun sisteminiz tarafından tanınması için terminalinizi yeniden başlatmanız veya `PATH` ortam değişkeninizi güncellemeniz gerekebilir. `uv --version` komutu ile kurulumu doğrulayabilirsiniz.
|
||||
* **Save** butonuna basarak kaydedin.
|
||||

|
||||
* Şimdi **Tools** altında **Yargı MCP**'yi görüyor olmalısınız. Üstüne geldiğinizde sağda çıkan butona tıklayıp etkinleştirin (yeşil ışık yanmalı).
|
||||
* Artık Yargı MCP ile konuşabilirsiniz.
|
||||
|
||||
**2. `fastmcp` Komut Satırı Aracının (CLI) Kurulumu**
|
||||
</details>
|
||||
|
||||
* **`uv` kullanarak (önerilir):**
|
||||
```bash
|
||||
uv pip install fastmcp
|
||||
```
|
||||
* **`pip` kullanarak (alternatif):**
|
||||
```bash
|
||||
pip install fastmcp
|
||||
```
|
||||
`fastmcp --version` komutu ile kurulumu doğrulayabilirsiniz.
|
||||
---
|
||||
<details>
|
||||
<summary>⚙️ <strong>Claude Desktop Lokal Kurulumu (Kopyala-Yapıştır)</strong></summary>
|
||||
|
||||
**3. Proje Dosyalarını Alın**
|
||||
> **Ön Gereksinimler:** Bilgisayarınızda **Python**, **`uv`** ([kurulum](https://docs.astral.sh/uv/getting-started/installation/)), **Node.js** ([indir](https://nodejs.org/en/download)) ve (Windows için) Microsoft Visual C++ Redistributable kurulu olmalı. (Node.js yalnızca aşağıdaki kurulum komutunu çalıştırmak için gerekir; MCP'yi `uvx` çalıştırır.)
|
||||
|
||||
Aşağıdaki **bloğun tamamını** terminale yapıştırın. Komut, Claude Desktop'ın `claude_desktop_config.json` dosyasını sizin yerinize oluşturur/günceller (varsa diğer sunucularınız korunur):
|
||||
|
||||
**macOS / Linux** (Terminal):
|
||||
|
||||
Bu Yargı MCP sunucusunun kaynak kodlarını bilgisayarınıza indirin:
|
||||
```bash
|
||||
git clone https://github.com/saidsurucu/yargi-mcp.git
|
||||
cd yargi-mcp
|
||||
node - <<'YARGI'
|
||||
const fs=require("fs"),os=require("os"),path=require("path");
|
||||
const dir=process.platform==="darwin"
|
||||
? path.join(os.homedir(),"Library","Application Support","Claude")
|
||||
: path.join(os.homedir(),".config","Claude");
|
||||
const file=path.join(dir,"claude_desktop_config.json");
|
||||
fs.mkdirSync(dir,{recursive:true});
|
||||
let cfg={};try{cfg=JSON.parse(fs.readFileSync(file,"utf8"))}catch{}
|
||||
if(typeof cfg!=="object"||cfg===null||Array.isArray(cfg))cfg={};
|
||||
if(typeof cfg.mcpServers!=="object"||cfg.mcpServers===null)cfg.mcpServers={};
|
||||
cfg.mcpServers["yargi-mcp"]={command:"uvx",args:["yargi-mcp"]};
|
||||
fs.writeFileSync(file,JSON.stringify(cfg,null,2)+"\n");
|
||||
console.log("yargi-mcp eklendi -> "+file);
|
||||
YARGI
|
||||
```
|
||||
Bu README.md dosyasının ve `mcp_server_main.py` script'inin bulunduğu dizine `cd` komutu ile geçmiş olacaksınız.
|
||||
|
||||
**4. Sunucuya Özel Bağımlılıkların Bilinmesi**
|
||||
**Windows** (PowerShell):
|
||||
|
||||
Bu sunucunun (`mcp_server_main.py`) çalışması için aşağıdaki Python kütüphanelerine ihtiyacı vardır. Bu kütüphaneler `fastmcp install` sırasında `--with` parametreleriyle belirtilecektir:
|
||||
|
||||
```text
|
||||
# requirements.txt
|
||||
fastmcp
|
||||
httpx
|
||||
beautifulsoup4
|
||||
markitdown
|
||||
pydantic
|
||||
aiohttp
|
||||
```powershell
|
||||
@'
|
||||
const fs=require("fs"),os=require("os"),path=require("path");
|
||||
const dir=path.join(process.env.APPDATA||path.join(os.homedir(),"AppData","Roaming"),"Claude");
|
||||
const file=path.join(dir,"claude_desktop_config.json");
|
||||
fs.mkdirSync(dir,{recursive:true});
|
||||
let cfg={};try{cfg=JSON.parse(fs.readFileSync(file,"utf8"))}catch{}
|
||||
if(typeof cfg!=="object"||cfg===null||Array.isArray(cfg))cfg={};
|
||||
if(typeof cfg.mcpServers!=="object"||cfg.mcpServers===null)cfg.mcpServers={};
|
||||
cfg.mcpServers["yargi-mcp"]={command:"uvx",args:["yargi-mcp"]};
|
||||
fs.writeFileSync(file,JSON.stringify(cfg,null,2)+"\n");
|
||||
console.log("yargi-mcp eklendi -> "+file);
|
||||
'@ | node -
|
||||
```
|
||||
(Eğer sunucuyu bağımsız olarak geliştirmek veya test etmek isterseniz, projenizin kök dizininde bir sanal ortam oluşturup – örn: `uv venv` & `source .venv/bin/activate` – bu bağımlılıkları `uv pip install -r requirements.txt` komutuyla kurabilirsiniz.)
|
||||
|
||||
🚀 **Claude Desktop Entegrasyonu (`fastmcp install` ile - Önerilen)**
|
||||
Komut `yargi-mcp eklendi -> ...` çıktısını verdiğinde kurulum tamamlanmıştır. **Claude Desktop'ı tamamen kapatıp yeniden başlatın**; `yargi-mcp` araçları otomatik yüklenir.
|
||||
|
||||
Yukarıdaki kurulum adımlarını tamamladıktan sonra, bu sunucuyu Claude Desktop uygulamasına kalıcı bir araç olarak eklemenin en kolay yolu `fastmcp install` komutunu kullanmaktır:
|
||||
---
|
||||
|
||||
1. Terminalde `mcp_server_main.py` dosyasının bulunduğu `yargi-mcp` dizininde olduğunuzdan emin olun.
|
||||
2. Aşağıdaki komutu çalıştırın:
|
||||
**Manuel alternatif:** Claude Desktop **Settings → Developer → Edit Config** menüsünden `claude_desktop_config.json` dosyasını açıp `mcpServers` altına ekleyebilirsiniz:
|
||||
|
||||
```bash
|
||||
fastmcp install mcp_server_main.py \
|
||||
--name "Yargı MCP" \
|
||||
--with httpx \
|
||||
--with beautifulsoup4 \
|
||||
--with markitdown \
|
||||
--with pydantic \
|
||||
--with aiohttp
|
||||
```
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"yargi-mcp": {
|
||||
"command": "uvx",
|
||||
"args": ["yargi-mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
* `--name "Yargı MCP"`: Araç Claude Desktop'ta bu isimle görünecektir.
|
||||
* `--with ...`: Sunucunun çalışması için gereken Python bağımlılıklarını belirtir.
|
||||
</details>
|
||||
|
||||
Bu komut, `uv` kullanarak sunucunuz için izole bir Python ortamı oluşturacak, belirtilen bağımlılıkları kuracak ve aracı Claude Desktop uygulamasına kaydedecektir.
|
||||
---
|
||||
<details>
|
||||
<summary>🌟 <strong>Gemini CLI ile Kullanım</strong></summary>
|
||||
|
||||
⚙️ **Claude Desktop Manuel Kurulumu (Yapılandırma Dosyası ile - Alternatif)**
|
||||
Yargı MCP'yi Gemini CLI ile kullanmak için:
|
||||
|
||||
1. **Claude Desktop Ayarları**'nı açın.
|
||||
2. **Developer** sekmesine gidin ve **Edit Config** düğmesine tıklayın.
|
||||
3. Açılan `claude_desktop_config.json` dosyasını bir metin düzenleyici ile açın.
|
||||
4. `mcpServers` nesnesine aşağıdaki JSON bloğunu ekleyin:
|
||||
1. **Ön Gereksinimler:** Python, `uv`, (Windows için) Microsoft Visual C++ Redistributable'ın sisteminizde kurulu olduğundan emin olun. Detaylı bilgi için yukarıdaki "5ire için Kurulum" bölümündeki ilgili adımlara bakabilirsiniz.
|
||||
|
||||
2. **Gemini CLI ayarlarını yapılandırın:**
|
||||
|
||||
Gemini CLI'ın ayar dosyasını düzenleyin:
|
||||
- **macOS/Linux:** `~/.gemini/settings.json`
|
||||
- **Windows:** `%USERPROFILE%\.gemini\settings.json`
|
||||
|
||||
Aşağıdaki `mcpServers` bloğunu ekleyin:
|
||||
```json
|
||||
{
|
||||
"theme": "Default",
|
||||
"selectedAuthType": "###",
|
||||
"mcpServers": {
|
||||
// ... (varsa diğer sunucu tanımlamalarınız) ...
|
||||
|
||||
"Yargı MCP": {
|
||||
"command": "uv",
|
||||
"yargi_mcp": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"run",
|
||||
"--with", "httpx",
|
||||
"--with", "beautifulsoup4",
|
||||
"--with", "markitdown",
|
||||
"--with", "pydantic",
|
||||
"--with", "aiohttp",
|
||||
"--with", "fastmcp",
|
||||
"fastmcp", "run",
|
||||
"/TAM/PROJE/YOLUNUZ/yargi-mcp/mcp_server_main.py"
|
||||
"yargi-mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
* **Önemli:** `/TAM/PROJE/YOLUNUZ/yargi-mcp/mcp_server_main.py` kısmını, `mcp_server_main.py` dosyasının sisteminizdeki **tam ve doğru yolu** ile değiştirmeyi unutmayın.
|
||||
5. Claude Desktop'ı yeniden başlatın.
|
||||
|
||||
🛠️ **Kullanılabilir Araçlar (MCP Tools)**
|
||||
**Yapılandırma açıklamaları:**
|
||||
- `"yargi_mcp"`: Sunucunuz için yerel bir isim
|
||||
- `"command"`: `uvx` komutu (uv'nin paket çalıştırma aracı)
|
||||
- `"args"`: GitHub'dan doğrudan Yargı MCP'yi çalıştırmak için gerekli argümanlar
|
||||
|
||||
Bu FastMCP sunucusu aşağıdaki temel araçları sunar:
|
||||
3. **Kullanım:**
|
||||
- Gemini CLI'ı başlatın
|
||||
- Yargı MCP araçları otomatik olarak kullanılabilir olacaktır
|
||||
- Örnek komutlar:
|
||||
- "Yargıtay'ın mülkiyet hakkı ile ilgili son kararlarını ara"
|
||||
- "Danıştay'ın imar planı iptaline ilişkin kararlarını bul"
|
||||
- "Anayasa Mahkemesi'nin ifade özgürlüğü kararlarını getir"
|
||||
|
||||
* **Yargıtay Araçları:**
|
||||
* `search_yargitay_detailed(search_query: YargitayDetailedSearchRequest) -> CompactYargitaySearchResult`: Yargıtay kararlarını detaylı kriterlerle arar.
|
||||
* `get_yargitay_document_markdown(document_id: str) -> YargitayDocumentMarkdown`: Belirli bir Yargıtay kararının metnini Markdown formatında getirir.
|
||||
</details>
|
||||
|
||||
* **Danıştay Araçları:**
|
||||
* `search_danistay_by_keyword(search_query: DanistayKeywordSearchRequest) -> CompactDanistaySearchResult`: Danıştay kararlarını anahtar kelimelerle arar.
|
||||
* `search_danistay_detailed(search_query: DanistayDetailedSearchRequest) -> CompactDanistaySearchResult`: Danıştay kararlarını detaylı kriterlerle arar.
|
||||
* `get_danistay_document_markdown(document_id: str) -> DanistayDocumentMarkdown`: Belirli bir Danıştay kararının metnini Markdown formatında getirir.
|
||||
---
|
||||
<details>
|
||||
<summary>🧠 <strong>Semantik Arama (Opsiyonel)</strong></summary>
|
||||
|
||||
* **Emsal Karar Araçları:**
|
||||
* `search_emsal_detailed_decisions(search_query: EmsalSearchRequest) -> CompactEmsalSearchResult`: Emsal (UYAP) kararlarını detaylı kriterlerle arar.
|
||||
* `get_emsal_document_markdown(document_id: str) -> EmsalDocumentMarkdown`: Belirli bir Emsal kararının metnini Markdown formatında getirir.
|
||||
Yargı MCP, **semantik arama** özelliği ile kararları anlamsal olarak sıralayabilir. Opsiyoneldir; iki yoldan biri yapılandırıldığında otomatik etkinleşir:
|
||||
|
||||
* **Uyuşmazlık Mahkemesi Araçları:**
|
||||
* `search_uyusmazlik_decisions(search_params: UyusmazlikSearchRequest) -> UyusmazlikSearchResponse`: Uyuşmazlık Mahkemesi kararlarını çeşitli form kriterleriyle arar.
|
||||
* `get_uyusmazlik_document_markdown_from_url(document_url: HttpUrl) -> UyusmazlikDocumentMarkdown`: Bir Uyuşmazlık kararını tam URL'sinden alıp Markdown formatında getirir.
|
||||
- **Yerel** (önerilen, ücretsiz): kendi makinenizdeki OpenAI-uyumlu embedding sunucusu (HuggingFace TEI, llama.cpp, Ollama, vLLM, LM Studio…)
|
||||
- **Hosted**: OpenRouter API anahtarı
|
||||
|
||||
* **Anayasa Mahkemesi (Norm Denetimi) Araçları:**
|
||||
* `search_anayasa_norm_denetimi_decisions(search_query: AnayasaNormDenetimiSearchRequest) -> AnayasaSearchResult`: AYM Norm Denetimi kararlarını kapsamlı kriterlerle arar.
|
||||
* `get_anayasa_norm_denetimi_document_markdown(document_url: str, page_number: Optional[int] = 1) -> AnayasaDocumentMarkdown`: Belirli bir AYM Norm Denetimi kararını URL'sinden alır ve 5.000 karakterlik sayfalanmış Markdown içeriğini getirir.
|
||||
### Semantik Arama Nasıl Çalışır?
|
||||
1. `initial_keyword` ile Bedesten API'den 100 karar çekilir
|
||||
2. `query` ile bu kararlar embedding modeli kullanılarak anlamsal olarak sıralanır
|
||||
3. En alakalı kararlar döndürülür
|
||||
|
||||
* **Anayasa Mahkemesi (Bireysel Başvuru) Araçları:**
|
||||
* `search_anayasa_bireysel_basvuru_report(search_query: AnayasaBireyselReportSearchRequest) -> AnayasaBireyselReportSearchResult`: AYM Bireysel Başvuru "Karar Arama Raporu" oluşturur.
|
||||
* `get_anayasa_bireysel_basvuru_document_markdown(document_url_path: str, page_number: Optional[int] = 1) -> AnayasaBireyselBasvuruDocumentMarkdown`: Belirli bir AYM Bireysel Başvuru kararını URL path'inden alır ve 5.000 karakterlik sayfalanmış Markdown içeriğini getirir.
|
||||
### Önerilen Türkçe Kurulumu (Yerel — `multilingual-e5-large`)
|
||||
|
||||
`intfloat/multilingual-e5-large` Türkçe için kıyas ettiğimiz açık kaynak modeller arasında en iyilerinden. HuggingFace'in **Text Embeddings Inference (TEI)** sunucusuyla tek komutta ayağa kalkar ve OpenAI-uyumlu API sunar:
|
||||
|
||||
```bash
|
||||
docker run -p 8080:80 ghcr.io/huggingface/text-embeddings-inference:latest \
|
||||
--model-id intfloat/multilingual-e5-large
|
||||
```
|
||||
|
||||
Sonra Yargı MCP'ye şu env vars'ları geçirin:
|
||||
|
||||
```bash
|
||||
EMBEDDING_PROVIDER=local
|
||||
LOCAL_EMBEDDING_BASE_URL=http://localhost:8080/v1
|
||||
LOCAL_EMBEDDING_MODEL=intfloat/multilingual-e5-large
|
||||
LOCAL_EMBEDDING_DIMENSION=1024
|
||||
EMBEDDING_PROMPT_STYLE=e5
|
||||
```
|
||||
|
||||
> ⚠️ **Önemli:** `EMBEDDING_PROMPT_STYLE=e5` şart — e5 modelleri `query:` / `passage:` öneki bekleyecek şekilde eğitilmiştir; yanlış önek sessizce kaliteyi düşürür.
|
||||
|
||||
#### Claude Desktop örneği (yerel TEI)
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"Yargı MCP": {
|
||||
"command": "uvx",
|
||||
"args": ["yargi-mcp"],
|
||||
"env": {
|
||||
"EMBEDDING_PROVIDER": "local",
|
||||
"LOCAL_EMBEDDING_BASE_URL": "http://localhost:8080/v1",
|
||||
"LOCAL_EMBEDDING_MODEL": "intfloat/multilingual-e5-large",
|
||||
"LOCAL_EMBEDDING_DIMENSION": "1024",
|
||||
"EMBEDDING_PROMPT_STYLE": "e5"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Alternatif 1: Ollama (yerel, daha hafif kurulum)
|
||||
|
||||
```bash
|
||||
ollama serve
|
||||
ollama pull nomic-embed-text # 768 dim, İngilizce ağırlıklı
|
||||
```
|
||||
|
||||
```bash
|
||||
EMBEDDING_PROVIDER=local
|
||||
LOCAL_EMBEDDING_BASE_URL=http://localhost:11434/v1
|
||||
LOCAL_EMBEDDING_MODEL=nomic-embed-text
|
||||
LOCAL_EMBEDDING_DIMENSION=768
|
||||
EMBEDDING_PROMPT_STYLE=raw
|
||||
```
|
||||
|
||||
> Ollama kütüphanesinde `multilingual-e5-large` doğrudan yok; Türkçe için TEI yolu daha doğru sonuç verir.
|
||||
|
||||
### Alternatif 2: OpenRouter (hosted)
|
||||
|
||||
```bash
|
||||
OPENROUTER_API_KEY=sk-or-v1-xxx...
|
||||
# İsteğe bağlı — varsayılan google/gemini-embedding-001 (3072 dim, ÜCRETLİ)
|
||||
# OPENROUTER_EMBEDDING_MODEL=...
|
||||
# OPENROUTER_EMBEDDING_DIMENSION=...
|
||||
# EMBEDDING_PROMPT_STYLE=gemini # varsayılan
|
||||
```
|
||||
|
||||
API anahtarınızı [openrouter.ai/keys](https://openrouter.ai/keys) adresinden alın. Varsayılan model `google/gemini-embedding-001` artık ücretli — ücretsiz bir model seçerseniz `OPENROUTER_EMBEDDING_MODEL`, `OPENROUTER_EMBEDDING_DIMENSION` ve uygun `EMBEDDING_PROMPT_STYLE` değerlerini birlikte ayarlayın.
|
||||
|
||||
### Yapılandırma Referansı
|
||||
|
||||
| Env Var | Açıklama | Örnek |
|
||||
|---|---|---|
|
||||
| `EMBEDDING_PROVIDER` | `local` ise yerel sunucu, boş ise OpenRouter | `local` |
|
||||
| `EMBEDDING_PROMPT_STYLE` | `gemini` / `e5` / `raw` — modelin beklediği önek | `e5` |
|
||||
| `LOCAL_EMBEDDING_BASE_URL` | Yerel sunucunun OpenAI-uyumlu URL'i | `http://localhost:8080/v1` |
|
||||
| `LOCAL_EMBEDDING_MODEL` | Model adı | `intfloat/multilingual-e5-large` |
|
||||
| `LOCAL_EMBEDDING_DIMENSION` | Modelin çıktı boyutu (mutlaka eşleşmeli) | `1024` |
|
||||
| `OPENROUTER_API_KEY` | OpenRouter anahtarı (sadece hosted için) | `sk-or-v1-…` |
|
||||
| `OPENROUTER_EMBEDDING_MODEL` | OpenRouter model id'si | `google/gemini-embedding-001` |
|
||||
| `OPENROUTER_EMBEDDING_DIMENSION` | OpenRouter modelinin çıktı boyutu | `3072` |
|
||||
|
||||
> 💡 **Not:** Hiçbir embedding sağlayıcı yapılandırılmazsa semantik arama aracı görünmez, diğer 28 araç normal şekilde çalışır.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🛠️ <strong>Kullanılabilir Araçlar (MCP Tools)</strong></summary>
|
||||
|
||||
Bu FastMCP sunucusu **26 aktif MCP aracı** + **1 opsiyonel semantik arama aracı** sunar (token verimliliği için optimize edilmiş):
|
||||
|
||||
### **Yargıtay Araçları (Birleşik Bedesten API - Token Optimized)**
|
||||
*Not: Yargıtay araçları token verimliliği için birleşik Bedesten API'ye entegre edilmiştir*
|
||||
|
||||
### **Danıştay Araçları (Birleşik Bedesten API - Token Optimized)**
|
||||
*Not: Danıştay araçları token verimliliği için birleşik Bedesten API'ye entegre edilmiştir*
|
||||
|
||||
### **Birleşik Bedesten API Araçları (5 Mahkeme) - 🚀 TOKEN OPTİMİZE**
|
||||
1. `search_bedesten_unified(phrase, court_types, birimAdi, kararTarihiStart, kararTarihiEnd, ...)`: **5 mahkeme türünü** birleşik arama (Yargıtay, Danıştay, Yerel Hukuk, İstinaf Hukuk, KYB) + **79 daire filtreleme** + **Tarih & Kesin Cümle Arama**
|
||||
2. `get_bedesten_document_markdown(documentId: str)`: Bedesten API'den herhangi bir belgeyi Markdown formatında getirir (HTML/PDF → Markdown)
|
||||
|
||||
### **Emsal Karar Araçları (UYAP)**
|
||||
3. `search_emsal_detailed_decisions(keyword, ...)`: Emsal (UYAP) kararlarını detaylı kriterlerle arar.
|
||||
4. `get_emsal_document_markdown(id: str)`: Belirli bir Emsal kararının metnini Markdown formatında getirir.
|
||||
|
||||
### **Uyuşmazlık Mahkemesi Araçları**
|
||||
5. `search_uyusmazlik_decisions(icerik, ...)`: Uyuşmazlık Mahkemesi kararlarını çeşitli form kriterleriyle arar.
|
||||
6. `get_uyusmazlik_document_markdown_from_url(document_url)`: Bir Uyuşmazlık kararını tam URL'sinden alıp Markdown formatında getirir.
|
||||
|
||||
### **Anayasa Mahkemesi Araçları (Birleşik API) - 🚀 TOKEN OPTİMİZE**
|
||||
7. `search_anayasa_unified(decision_type, keywords_all, ...)`: AYM kararlarını birleşik arama (Norm Denetimi + Bireysel Başvuru) - **4 araç → 2 araç optimizasyonu**
|
||||
8. `get_anayasa_document_unified(document_url, page_number)`: AYM kararlarını birleşik belge getirme - **sayfalanmış Markdown** içeriği
|
||||
|
||||
### **KİK (Kamu İhale Kurulu) Araçları**
|
||||
9. `search_kik_v2_decisions(decision_type, karar_metni, karar_no, basvuran, idare_adi, baslangic_tarihi, bitis_tarihi)`: KİK v2 API ile uyuşmazlık, düzenleyici ve mahkeme kararlarını arar.
|
||||
10. `get_kik_v2_document_markdown(gundemMaddesiId)`: Arama sonucundaki `gundemMaddesiId` ile KİK karar metnini Markdown formatında getirir.
|
||||
### **Rekabet Kurumu Araçları**
|
||||
* `search_rekabet_kurumu_decisions(KararTuru: Literal[...], ...) -> RekabetSearchResult`: Rekabet Kurumu kararlarını arar. `KararTuru` için kullanıcı dostu isimler kullanılır (örn: "Birleşme ve Devralma").
|
||||
* `get_rekabet_kurumu_document(karar_id: str, page_number: Optional[int] = 1) -> RekabetDocument`: Belirli bir Rekabet Kurumu kararını `karar_id` ile alır. Kararın PDF formatındaki orijinalinden istenen sayfayı ayıklar ve Markdown formatında döndürür.
|
||||
|
||||
|
||||
---
|
||||
|
||||
* **Sayıştay Araçları (Birleşik API, 3 Karar Türü + 8 Daire Filtreleme):**
|
||||
* `search_sayistay_unified(decision_type, start, length, ...)`: `genel_kurul`, `temyiz_kurulu` veya `daire` kararlarını tek araçla arar. `length` 1-100 aralığındadır.
|
||||
* `get_sayistay_document_unified(decision_id, decision_type)`: Birleşik arama sonucundaki karar ID'si ve karar türüyle tam metni Markdown formatında getirir.
|
||||
|
||||
* **KVKK Araçları (Brave Search API + Türkçe Arama):**
|
||||
* `search_kvkk_decisions(keywords, page)`: KVKK (Kişisel Verilerin Korunması Kurulu) kararlarını Brave Search API ile arar. **Türkçe arama** + **Site hedeflemeli** (`site:kvkk.gov.tr "karar özeti"`) + **Sayfalama desteği**. Sonuç sayısı sunucuda 10 olarak sabitlenmiştir.
|
||||
* `get_kvkk_document_markdown(decision_url: str, page_number: Optional[int] = 1)`: KVKK kararının tam metnini **sayfalanmış Markdown** formatında getirir (5.000 karakterlik sayfa)
|
||||
|
||||
### BDDK Araçları
|
||||
* `search_bddk_decisions(keywords, page)`: BDDK (Bankacılık Düzenleme ve Denetleme Kurumu) kararlarını arar. **"Karar Sayısı" targeting** + **Spesifik URL filtreleme** (`bddk.org.tr/Mevzuat/DokumanGetir`) + **Optimized search**
|
||||
* `get_bddk_document_markdown(document_id: str, page_number: Optional[int] = 1)`: BDDK kararının tam metnini **sayfalanmış Markdown** formatında getirir (5.000 karakterlik sayfa)
|
||||
|
||||
### BTK (Bilgi Teknolojileri ve İletişim Kurumu) Araçları (Resmi BTK JSON API)
|
||||
* `search_btk_decisions(keywords, decision_no, decision_date, publication_date, relevant_unit, page, pageSize)`: BTK Kurul Kararlarını arar. **Anahtar kelime + Karar No** (ör. `2026/DK-THD/91`) **+ Karar Tarihi + Yayın Tarihi + İlgili Birim** filtreleri + **Sayfalama** (`pageSize` 1-50)
|
||||
* `get_btk_document_markdown(pdf_url: str, page_number: int = 1)`: BTK kararının PDF'ini indirip **sayfalanmış Markdown** formatında getirir (5.000 karakterlik sayfa). `pdf_url`, `search_btk_decisions` sonucundaki `pdf_url` alanından alınır (`btk.gov.tr`)
|
||||
|
||||
### GİB (Gelir İdaresi Başkanlığı) Özelge Araçları (Resmi GİB JSON API)
|
||||
* `search_gib_ozelge(keywords, ozelgeNo, kanunNo, ozelgeStartDate, ozelgeEndDate, page, pageSize)`: GİB özelgelerini (Türk Gelir İdaresi Başkanlığı vergi özelgeleri) arar — **18.000+ özelge** (KDV, Kurumlar, Gelir, ÖTV, Damga, VUK vb.). **Keyword + Özelge No + Kanun No + Tarih Aralığı** + **Otomatik ISO 8601 Dönüşümü** (`YYYY-MM-DD` girdileri otomatik olarak full ISO 8601'e çevrilir)
|
||||
* `get_gib_ozelge_document_markdown(ozelge_id: int, page_number: int = 1)`: Belirli bir özelgenin tam metnini **sayfalanmış Markdown** formatında getirir (5.000 karakterlik sayfa) + **Metadata başlık bloğu** (Başlık, Sayı, Tarih, Kanun, Kaynak URL)
|
||||
|
||||
### Sigorta Tahkim Komisyonu Araçları (Tavily Search API + PDF)
|
||||
* `search_sigorta_tahkim_decisions(keywords, page)`: Sigorta Tahkim Komisyonu kararlarını Tavily Search API ile arar. **Site hedeflemeli** (`sigortatahkim.org`) + **Sayfalama desteği**. Sonuç sayısı sunucuda 10 olarak sabitlenmiştir.
|
||||
* `get_sigorta_tahkim_document_markdown(issue_number: str, page_number: int)`: Hakem Karar Dergisi sayısının PDF'ini indirip **sayfalanmış Markdown** formatında getirir (5.000 karakterlik sayfa). 64 sayı (2010-2025)
|
||||
* `search_within_sigorta_tahkim_issue(issue_number: str, keyword: str, max_results: int)`: Belirli bir dergi sayısı içindeki kararları anahtar kelime ile arar. **Türkçe İ/I desteği** + **Relevance scoring** + **Excerpt** ile sonuç
|
||||
|
||||
### Yardımcı ve Uyumluluk Araçları
|
||||
* `check_government_servers_health()`: Yargı kaynaklarının erişilebilirliğini kontrol eder.
|
||||
* `search(query)`: ChatGPT Deep Research uyumluluğu için Bedesten destekli kaynaklarda arama yapar.
|
||||
* `fetch(id)`: ChatGPT Deep Research uyumluluğu için tek bir Bedesten belge ID'sinin tam metnini getirir.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>📊 <strong>Kapsamlı İstatistikler & Optimizasyon Başarıları</strong></summary>
|
||||
|
||||
🚀 **TOKEN OPTİMİZASYON BAŞARISI:**
|
||||
- **%61.8 Token Azaltma:** 14,061 → 5,369 tokens (8,692 token tasarrufu)
|
||||
- **Hedef Aşım:** 10,000 token hedefini 4,631 token aştık
|
||||
- **Daha Hızlı Yanıt:** Claude AI ile optimize edilmiş etkileşim
|
||||
- **Korunan İşlevsellik:** %100 özellik desteği devam ediyor
|
||||
|
||||
**GENEL İSTATİSTİKLER:**
|
||||
- **Toplam Mahkeme/Kurum:** 16 farklı hukuki kurum (BTK, GİB Özelgeleri ve Sigorta Tahkim Komisyonu dahil)
|
||||
- **Toplam MCP Tool:** 28 aktif araç + 1 opsiyonel semantik arama aracı
|
||||
- **Daire/Kurul Filtreleme:** 87 farklı seçenek (52 Yargıtay + 27 Danıştay + 8 Sayıştay)
|
||||
- **Tarih Filtreleme:** Birleşik Bedesten API aracında ISO 8601 formatında tam tarih aralığı desteği
|
||||
- **Kesin Cümle Arama:** Birleşik Bedesten API aracında çift tırnak ile tam cümle arama (`"\"mülkiyet kararı\""` formatı)
|
||||
- **Birleşik API:** 10 ayrı Bedesten aracı → 2 birleşik araç (search_bedesten_unified + get_bedesten_document_markdown)
|
||||
- **API Kaynağı:** Dual/Triple API desteği ile maksimum kapsama
|
||||
- **Tam Türk Adalet Sistemi:** Yerel mahkemelerden en yüksek mahkemelere kadar
|
||||
|
||||
**🏛️ Desteklenen Mahkeme Hiyerarşisi:**
|
||||
```
|
||||
Yerel Mahkemeler → İstinaf → Yargıtay/Danıştay → Anayasa Mahkemesi
|
||||
↓ ↓ ↓ ↓
|
||||
Bedesten API Bedesten API Dual/Triple API Norm+Bireysel API
|
||||
+ Tarih + Kesin + Tarih + Kesin + Daire + Tarih + Gelişmiş
|
||||
Cümle Arama Cümle Arama + Kesin Cümle Arama
|
||||
```
|
||||
|
||||
**⚖️ Kapsamlı Filtreleme Özellikleri:**
|
||||
- **Daire Filtreleme:** 79 seçenek (52 Yargıtay + 27 Danıştay)
|
||||
- **Yargıtay:** 52 seçenek (1-23 Hukuk, 1-23 Ceza, Genel Kurullar, Başkanlar Kurulu)
|
||||
- **Danıştay:** 27 seçenek (1-17 Daireler, İdare/Vergi Kurulları, Askeri Mahkemeler)
|
||||
- **Tarih Filtreleme:** 5 Bedesten API aracında ISO 8601 formatı (YYYY-MM-DDTHH:MM:SS.000Z)
|
||||
- Tek tarih, tarih aralığı, tek taraflı filtreleme desteği
|
||||
- Yargıtay, Danıştay, Yerel Hukuk, İstinaf Hukuk, KYB kararları
|
||||
- **Kesin Cümle Arama:** 5 Bedesten API aracında çift tırnak formatı
|
||||
- Normal arama: `"mülkiyet kararı"` (kelimeler ayrı ayrı)
|
||||
- Kesin arama: `"\"mülkiyet kararı\""` (tam cümle olarak)
|
||||
- Daha kesin sonuçlar için hukuki terimler ve kavramlar
|
||||
|
||||
**🔧 OPTİMİZASYON DETAYLARI:**
|
||||
- **Anayasa Mahkemesi:** 4 araç → 2 birleşik araç (search_anayasa_unified + get_anayasa_document_unified)
|
||||
- **Yargıtay & Danıştay:** Ana API araçları birleşik Bedesten API'ye entegre edildi
|
||||
- **Sayıştay:** 6 araç → 2 birleşik araç (search_sayistay_unified + get_sayistay_document_unified)
|
||||
- **Parameter Optimizasyonu:** pageSize parametreleri optimize edildi
|
||||
- **Açıklama Optimizasyonu:** Uzun açıklamalar kısaltıldı (örn: KIK karar_metni)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>🌐 <strong>Web Service / ASGI Deployment</strong></summary>
|
||||
|
||||
Yargı MCP artık web servisi olarak da çalıştırılabilir! ASGI desteği sayesinde:
|
||||
|
||||
- **Web API olarak erişim**: HTTP endpoint'leri üzerinden MCP araçlarına erişim
|
||||
- **Cloud deployment**: Heroku, Railway, Google Cloud Run, AWS Lambda desteği
|
||||
- **Docker desteği**: Production-ready Docker container
|
||||
- **FastAPI entegrasyonu**: REST API ve interaktif dokümantasyon
|
||||
|
||||
**Hızlı başlangıç:**
|
||||
```bash
|
||||
# ASGI dependencies yükle
|
||||
pip install yargi-mcp[asgi]
|
||||
|
||||
# Web servisi olarak başlat
|
||||
python run_asgi.py
|
||||
# veya
|
||||
uvicorn asgi_app:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Detaylı deployment rehberi için: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
📜 **Lisans**
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Entry point for yargi-mcp package."""
|
||||
|
||||
from mcp_server_main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Analyze KİK v2 hash generation by examining JavaScript code patterns
|
||||
and trying to reverse engineer the hash generation logic.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import hashlib
|
||||
import hmac
|
||||
import base64
|
||||
from fastmcp import Client
|
||||
from mcp_server_main import app
|
||||
|
||||
def analyze_webpack_hash_patterns():
|
||||
"""
|
||||
Analyze the webpack JavaScript code you provided to find hash generation patterns
|
||||
"""
|
||||
print("🔍 Analyzing webpack hash generation patterns...")
|
||||
|
||||
# From the JavaScript code, I can see several hash/ID generation patterns:
|
||||
hash_patterns = {
|
||||
# Webpack chunk system hashes (from the JS code)
|
||||
"webpack_chunks": {
|
||||
315: "d9a9486a4f5ba326",
|
||||
531: "cd8fb385c88033ae",
|
||||
671: "04c48b287646627a",
|
||||
856: "682c9a7b87351f90",
|
||||
1017: "9de022378fc275f6",
|
||||
# ... many more from the __webpack_require__.u function
|
||||
},
|
||||
|
||||
# Symbol generation from Zone.js
|
||||
"zone_symbols": [
|
||||
"__zone_symbol__",
|
||||
"__Zone_symbol_prefix",
|
||||
"Zone.__symbol__"
|
||||
],
|
||||
|
||||
# Angular module federation patterns
|
||||
"module_federation": [
|
||||
"__webpack_modules__",
|
||||
"__webpack_module_cache__",
|
||||
"__webpack_require__"
|
||||
]
|
||||
}
|
||||
|
||||
# The target hash format
|
||||
target_hash = "42f9bcd59e0dfbca36dec9accf5686c7a92aa97724cd8fc3550beb84b80409da"
|
||||
print(f"🎯 Target hash: {target_hash}")
|
||||
print(f" Length: {len(target_hash)} characters")
|
||||
print(f" Format: {'SHA256' if len(target_hash) == 64 else 'Other'} (64 chars = SHA256)")
|
||||
|
||||
return hash_patterns
|
||||
|
||||
def test_webpack_style_hashing(data_dict):
|
||||
"""Test webpack-style hash generation methods"""
|
||||
hashes = {}
|
||||
|
||||
for key, value in data_dict.items():
|
||||
test_string = str(value)
|
||||
|
||||
# Try various webpack-style hash methods
|
||||
hashes[f"webpack_md5_{key}"] = hashlib.md5(test_string.encode()).hexdigest()
|
||||
hashes[f"webpack_sha1_{key}"] = hashlib.sha1(test_string.encode()).hexdigest()
|
||||
hashes[f"webpack_sha256_{key}"] = hashlib.sha256(test_string.encode()).hexdigest()
|
||||
|
||||
# Try with various prefixes/suffixes (common in webpack)
|
||||
prefixed = f"__webpack__{test_string}"
|
||||
hashes[f"webpack_prefixed_sha256_{key}"] = hashlib.sha256(prefixed.encode()).hexdigest()
|
||||
|
||||
# Try with module federation style
|
||||
module_style = f"shell:{test_string}"
|
||||
hashes[f"module_fed_sha256_{key}"] = hashlib.sha256(module_style.encode()).hexdigest()
|
||||
|
||||
# Try JSON stringified
|
||||
json_style = json.dumps({"id": value, "type": "decision"}, separators=(',', ':'))
|
||||
hashes[f"json_sha256_{key}"] = hashlib.sha256(json_style.encode()).hexdigest()
|
||||
|
||||
# Try with timestamp or sequence
|
||||
with_seq = f"{test_string}_0"
|
||||
hashes[f"seq_sha256_{key}"] = hashlib.sha256(with_seq.encode()).hexdigest()
|
||||
|
||||
return hashes
|
||||
|
||||
def test_angular_routing_hashes(data_dict):
|
||||
"""Test Angular routing/state management hash generation"""
|
||||
hashes = {}
|
||||
|
||||
for key, value in data_dict.items():
|
||||
# Angular often uses route parameters for hash generation
|
||||
route_style = f"/kurul-kararlari/{value}"
|
||||
hashes[f"route_sha256_{key}"] = hashlib.sha256(route_style.encode()).hexdigest()
|
||||
|
||||
# Component state style
|
||||
state_style = f"KurulKararGoster_{value}"
|
||||
hashes[f"state_sha256_{key}"] = hashlib.sha256(state_style.encode()).hexdigest()
|
||||
|
||||
# Angular module style
|
||||
module_style = f"kik.kurul.karar.{value}"
|
||||
hashes[f"module_sha256_{key}"] = hashlib.sha256(module_style.encode()).hexdigest()
|
||||
|
||||
return hashes
|
||||
|
||||
def test_base64_encoding_variants(data_dict):
|
||||
"""Test various base64 and encoding variants"""
|
||||
hashes = {}
|
||||
|
||||
for key, value in data_dict.items():
|
||||
test_string = str(value)
|
||||
|
||||
# Try base64 encoding then hashing
|
||||
b64_encoded = base64.b64encode(test_string.encode()).decode()
|
||||
hashes[f"b64_sha256_{key}"] = hashlib.sha256(b64_encoded.encode()).hexdigest()
|
||||
|
||||
# Try URL-safe base64
|
||||
b64_url = base64.urlsafe_b64encode(test_string.encode()).decode()
|
||||
hashes[f"b64url_sha256_{key}"] = hashlib.sha256(b64_url.encode()).hexdigest()
|
||||
|
||||
# Try hex encoding
|
||||
hex_encoded = test_string.encode().hex()
|
||||
hashes[f"hex_sha256_{key}"] = hashlib.sha256(hex_encoded.encode()).hexdigest()
|
||||
|
||||
return hashes
|
||||
|
||||
async def test_hash_generation_comprehensive():
|
||||
print("🔐 Comprehensive KİK document hash generation analysis...")
|
||||
print("=" * 70)
|
||||
|
||||
# First analyze the webpack patterns
|
||||
webpack_patterns = analyze_webpack_hash_patterns()
|
||||
|
||||
client = Client(app)
|
||||
|
||||
async with client:
|
||||
print("✅ MCP client connected")
|
||||
|
||||
# Get sample decisions
|
||||
print("\n📊 Getting sample decisions for hash analysis...")
|
||||
search_result = await client.call_tool("search_kik_v2_decisions", {
|
||||
"decision_type": "uyusmazlik",
|
||||
"karar_metni": "2024"
|
||||
})
|
||||
|
||||
if hasattr(search_result, 'content') and search_result.content:
|
||||
search_data = json.loads(search_result.content[0].text)
|
||||
decisions = search_data.get('decisions', [])
|
||||
|
||||
if decisions:
|
||||
print(f"✅ Found {len(decisions)} decisions")
|
||||
|
||||
# Test with first decision
|
||||
sample_decision = decisions[0]
|
||||
print(f"\n📋 Sample decision for hash analysis:")
|
||||
for key, value in sample_decision.items():
|
||||
print(f" {key}: {value}")
|
||||
|
||||
target_hash = "42f9bcd59e0dfbca36dec9accf5686c7a92aa97724cd8fc3550beb84b80409da"
|
||||
print(f"\n🎯 Target hash to match: {target_hash}")
|
||||
|
||||
all_hashes = {}
|
||||
|
||||
# Test different hash generation methods
|
||||
print(f"\n🔨 Testing webpack-style hashing...")
|
||||
webpack_hashes = test_webpack_style_hashing(sample_decision)
|
||||
all_hashes.update(webpack_hashes)
|
||||
|
||||
print(f"🔨 Testing Angular routing hashes...")
|
||||
angular_hashes = test_angular_routing_hashes(sample_decision)
|
||||
all_hashes.update(angular_hashes)
|
||||
|
||||
print(f"🔨 Testing base64 encoding variants...")
|
||||
b64_hashes = test_base64_encoding_variants(sample_decision)
|
||||
all_hashes.update(b64_hashes)
|
||||
|
||||
# Check for matches
|
||||
print(f"\n🎯 Checking for hash matches...")
|
||||
matches_found = []
|
||||
partial_matches = []
|
||||
|
||||
for hash_name, hash_value in all_hashes.items():
|
||||
if hash_value == target_hash:
|
||||
matches_found.append((hash_name, hash_value))
|
||||
print(f" 🎉 EXACT MATCH FOUND: {hash_name}")
|
||||
elif hash_value[:8] == target_hash[:8]: # First 8 chars match
|
||||
partial_matches.append((hash_name, hash_value))
|
||||
print(f" 🔍 Partial match (first 8): {hash_name} -> {hash_value[:16]}...")
|
||||
elif hash_value[-8:] == target_hash[-8:]: # Last 8 chars match
|
||||
partial_matches.append((hash_name, hash_value))
|
||||
print(f" 🔍 Partial match (last 8): {hash_name} -> ...{hash_value[-16:]}")
|
||||
|
||||
if not matches_found and not partial_matches:
|
||||
print(f" ❌ No matches found")
|
||||
print(f"\n📝 Sample generated hashes (first 10):")
|
||||
for i, (hash_name, hash_value) in enumerate(list(all_hashes.items())[:10]):
|
||||
print(f" {hash_name}: {hash_value}")
|
||||
|
||||
# Try combinations with other decisions
|
||||
print(f"\n🔄 Testing hash combinations with multiple decisions...")
|
||||
if len(decisions) > 1:
|
||||
for i, decision in enumerate(decisions[1:3]): # Test 2 more
|
||||
print(f"\n Testing decision {i+2}: {decision.get('kararNo')}")
|
||||
decision_hashes = test_webpack_style_hashing(decision)
|
||||
|
||||
for hash_name, hash_value in decision_hashes.items():
|
||||
if hash_value == target_hash:
|
||||
print(f" 🎉 MATCH FOUND in decision {i+2}: {hash_name}")
|
||||
matches_found.append((f"decision_{i+2}_{hash_name}", hash_value))
|
||||
|
||||
# Try composite hashes (combining multiple fields)
|
||||
print(f"\n🔗 Testing composite hash generation...")
|
||||
composite_tests = [
|
||||
f"{sample_decision.get('gundemMaddesiId')}_{sample_decision.get('kararNo')}",
|
||||
f"{sample_decision.get('kararNo')}_{sample_decision.get('kararTarihi')}",
|
||||
f"uyusmazlik_{sample_decision.get('gundemMaddesiId')}_{sample_decision.get('kararTarihi')}",
|
||||
json.dumps(sample_decision, separators=(',', ':'), sort_keys=True),
|
||||
f"{sample_decision.get('basvuran')}_{sample_decision.get('gundemMaddesiId')}",
|
||||
]
|
||||
|
||||
for i, composite_str in enumerate(composite_tests):
|
||||
composite_hash = hashlib.sha256(composite_str.encode()).hexdigest()
|
||||
if composite_hash == target_hash:
|
||||
print(f" 🎉 COMPOSITE MATCH FOUND: test_{i} -> {composite_str[:50]}...")
|
||||
matches_found.append((f"composite_{i}", composite_hash))
|
||||
|
||||
print(f"\n🎯 Hash analysis completed!")
|
||||
print(f" Total matches found: {len(matches_found)}")
|
||||
print(f" Partial matches: {len(partial_matches)}")
|
||||
|
||||
else:
|
||||
print("❌ No decisions found")
|
||||
else:
|
||||
print("❌ Search failed")
|
||||
|
||||
print("=" * 70)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(test_hash_generation_comprehensive())
|
||||
@@ -0,0 +1,201 @@
|
||||
# anayasa_mcp_module/api_client.py
|
||||
# Low-level client for the new Anayasa Mahkemesi "Kararlar Bilgi Bankası" (KBB) JSON API.
|
||||
#
|
||||
# Both the Norm Denetimi host (normkararlarbilgibankasi.anayasa.gov.tr) and the
|
||||
# Bireysel Başvuru host (kararlarbilgibankasi.anayasa.gov.tr) share the SAME
|
||||
# backend, exposed at POST /api/core/public/search. The request differs only by
|
||||
# the "kararTipi" discriminator:
|
||||
#
|
||||
# {"kararTipi": "NormDenetimi", "query": "mülkiyet", "page": 1, "size": 10}
|
||||
# -> {"total": N, "page": 1, "data": [...summary records...], "page_size": 10}
|
||||
#
|
||||
# {"kararTipi": "NormDenetimi", "id": "<uuid>", "page": 1, "size": 1}
|
||||
# -> data[0] additionally includes "icerik" = full decision HTML
|
||||
#
|
||||
# The previous HTML-scraping endpoints (/Ara, /ND/.., /BB/..) were retired when
|
||||
# the sites were rebuilt as a single-page app; they now return HTTP 404.
|
||||
|
||||
import base64
|
||||
import html as html_module
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.parse import urlparse, parse_qs, quote
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
from markitdown import MarkItDown
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Markdown pagination chunk size (characters), shared across AYM document tools.
|
||||
DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000
|
||||
|
||||
|
||||
def strip_html_text(value: Optional[str]) -> str:
|
||||
"""Return plain text from a possibly-HTML field (e.g. kararKonusu)."""
|
||||
if not value:
|
||||
return ""
|
||||
text = BeautifulSoup(html_module.unescape(value), "html.parser").get_text(" ", strip=True)
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def convert_icerik_to_markdown(icerik_html: Optional[str]) -> Optional[str]:
|
||||
"""Convert the "icerik" decision HTML returned by the KBB API to Markdown.
|
||||
|
||||
The icerik field is a self-contained HTML fragment (the rendered decision
|
||||
body). Scripts/styles are stripped before handing it to MarkItDown.
|
||||
"""
|
||||
if not icerik_html:
|
||||
return None
|
||||
|
||||
processed_html = html_module.unescape(icerik_html)
|
||||
soup = BeautifulSoup(processed_html, "html.parser")
|
||||
for tag in soup.find_all(["script", "style"]):
|
||||
tag.decompose()
|
||||
|
||||
body = soup.find("body")
|
||||
html_fragment = str(body) if body else str(soup)
|
||||
if not html_fragment.strip().lower().startswith(("<html", "<!doctype")):
|
||||
html_fragment = f'<html><head><meta charset="UTF-8"></head><body>{html_fragment}</body></html>'
|
||||
|
||||
try:
|
||||
html_stream = io.BytesIO(html_fragment.encode("utf-8"))
|
||||
conversion_result = MarkItDown().convert(html_stream)
|
||||
return conversion_result.text_content
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.error("AnayasaApiClient: MarkItDown conversion error: %s", e)
|
||||
return None
|
||||
|
||||
# kararTipi discriminator values accepted by the API.
|
||||
KARAR_TIPI_NORM = "NormDenetimi"
|
||||
KARAR_TIPI_BIREYSEL = "BireyselBasvuru"
|
||||
|
||||
NORM_HOST = "https://normkararlarbilgibankasi.anayasa.gov.tr"
|
||||
BIREYSEL_HOST = "https://kararlarbilgibankasi.anayasa.gov.tr"
|
||||
SEARCH_PATH = "/api/core/public/search"
|
||||
|
||||
# Map kararTipi -> the host whose SPA can display the decision (cosmetic only;
|
||||
# either host's API answers for any kararTipi).
|
||||
_HOST_FOR_TIPI = {
|
||||
KARAR_TIPI_NORM: NORM_HOST,
|
||||
KARAR_TIPI_BIREYSEL: BIREYSEL_HOST,
|
||||
}
|
||||
|
||||
|
||||
def encode_document_token(uuid: str) -> str:
|
||||
"""Encode a raw decision UUID into the base64url token the SPA uses in its URLs.
|
||||
|
||||
The SPA addresses decisions as base64url("kbb:" + uuid) (no padding).
|
||||
"""
|
||||
raw = f"kbb:{uuid}".encode("utf-8")
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def decode_document_token(token: str) -> Optional[str]:
|
||||
"""Decode a base64url SPA token back into the raw decision UUID.
|
||||
|
||||
Returns None if the token is not a valid "kbb:<uuid>" token.
|
||||
"""
|
||||
try:
|
||||
padded = token + "=" * (-len(token) % 4)
|
||||
decoded = base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
if decoded.startswith("kbb:"):
|
||||
return decoded[len("kbb:"):]
|
||||
return None
|
||||
|
||||
|
||||
def build_document_url(karar_tipi: str, uuid: str) -> str:
|
||||
"""Build a clickable SPA URL for a decision, used as its document_url."""
|
||||
host = _HOST_FOR_TIPI.get(karar_tipi, BIREYSEL_HOST)
|
||||
token = encode_document_token(uuid)
|
||||
return f"{host}/kbb/pages/search/{karar_tipi}?id={quote(token)}&type={karar_tipi}"
|
||||
|
||||
|
||||
def parse_document_url(document_url: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Extract (karar_tipi, uuid) from a document URL.
|
||||
|
||||
Handles the new SPA URLs (?id=<token>&type=<kararTipi>) and is lenient about
|
||||
older /ND/ and /BB/ style paths so historical references still resolve.
|
||||
Returns (None, None) if neither the type nor id can be determined.
|
||||
"""
|
||||
parsed = urlparse(document_url)
|
||||
qs = parse_qs(parsed.query)
|
||||
|
||||
karar_tipi = None
|
||||
type_param = qs.get("type", [None])[0]
|
||||
path = parsed.path or ""
|
||||
if type_param in (KARAR_TIPI_NORM, KARAR_TIPI_BIREYSEL):
|
||||
karar_tipi = type_param
|
||||
elif "/ND/" in path or "NormDenetimi" in path:
|
||||
karar_tipi = KARAR_TIPI_NORM
|
||||
elif "/BB/" in path or "BireyselBasvuru" in path:
|
||||
karar_tipi = KARAR_TIPI_BIREYSEL
|
||||
|
||||
uuid = None
|
||||
id_param = qs.get("id", [None])[0]
|
||||
if id_param:
|
||||
# The id may be the raw uuid or the base64url SPA token.
|
||||
uuid = decode_document_token(id_param) or id_param
|
||||
|
||||
return karar_tipi, uuid
|
||||
|
||||
|
||||
class AnayasaApiClient:
|
||||
"""Thin async wrapper around the KBB /api/core/public/search endpoint."""
|
||||
|
||||
def __init__(self, request_timeout: float = 60.0):
|
||||
self.http_client = httpx.AsyncClient(
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"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 _search_url(self, karar_tipi: str) -> str:
|
||||
host = _HOST_FOR_TIPI.get(karar_tipi, BIREYSEL_HOST)
|
||||
return f"{host}{SEARCH_PATH}"
|
||||
|
||||
async def search(
|
||||
self,
|
||||
karar_tipi: str,
|
||||
query: str = "",
|
||||
page: int = 1,
|
||||
size: int = 10,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run a list search and return the parsed JSON envelope.
|
||||
|
||||
Envelope shape: {"total": int, "page": int, "data": [..], "page_size": int}.
|
||||
"""
|
||||
body: Dict[str, Any] = {"kararTipi": karar_tipi, "page": page, "size": size}
|
||||
if query:
|
||||
body["query"] = query
|
||||
logger.info("AnayasaApiClient: search kararTipi=%s query=%r page=%s size=%s",
|
||||
karar_tipi, query, page, size)
|
||||
response = await self.http_client.post(self._search_url(karar_tipi), json=body)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_decision(self, karar_tipi: str, uuid: str) -> Optional[Dict[str, Any]]:
|
||||
"""Fetch a single decision record (including the "icerik" HTML) by UUID."""
|
||||
body = {"kararTipi": karar_tipi, "id": uuid, "page": 1, "size": 1}
|
||||
logger.info("AnayasaApiClient: get_decision kararTipi=%s id=%s", karar_tipi, uuid)
|
||||
response = await self.http_client.post(self._search_url(karar_tipi), json=body)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
data = payload.get("data") or []
|
||||
return data[0] if data else None
|
||||
|
||||
async def close(self):
|
||||
if self.http_client and not self.http_client.is_closed:
|
||||
await self.http_client.aclose()
|
||||
logger.info("AnayasaApiClient: HTTP client session closed.")
|
||||
@@ -1,24 +1,27 @@
|
||||
# anayasa_mcp_module/bireysel_client.py
|
||||
# This client is for Bireysel Başvuru: https://kararlarbilgibankasi.anayasa.gov.tr
|
||||
# Bireysel Başvuru client backed by the new KBB JSON API (see api_client.py).
|
||||
#
|
||||
# Same backend as Norm Denetimi, distinguished by kararTipi="BireyselBasvuru".
|
||||
# The legacy /Ara report-scraping endpoint was retired and now returns HTTP 404.
|
||||
|
||||
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
|
||||
import math
|
||||
from typing import List, Optional
|
||||
|
||||
from .api_client import (
|
||||
AnayasaApiClient,
|
||||
KARAR_TIPI_BIREYSEL,
|
||||
DOCUMENT_MARKDOWN_CHUNK_SIZE,
|
||||
build_document_url,
|
||||
parse_document_url,
|
||||
convert_icerik_to_markdown,
|
||||
strip_html_text,
|
||||
)
|
||||
from .models import (
|
||||
AnayasaBireyselReportSearchRequest,
|
||||
AnayasaBireyselReportDecisionDetail,
|
||||
AnayasaBireyselReportDecisionSummary,
|
||||
AnayasaBireyselReportSearchResult,
|
||||
AnayasaBireyselBasvuruDocumentMarkdown, # Model for Bireysel Başvuru document
|
||||
AnayasaBireyselBasvuruDocumentMarkdown,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -27,330 +30,93 @@ if not logger.hasHandlers():
|
||||
|
||||
|
||||
class AnayasaBireyselBasvuruApiClient:
|
||||
BASE_URL = "https://kararlarbilgibankasi.anayasa.gov.tr"
|
||||
SEARCH_PATH = "/Ara"
|
||||
DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000 # Character limit per page
|
||||
"""Bireysel Başvuru search/document client over the KBB JSON API."""
|
||||
|
||||
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
|
||||
self.api = AnayasaApiClient(request_timeout)
|
||||
|
||||
async def search_bireysel_basvuru_report(
|
||||
self,
|
||||
params: AnayasaBireyselReportSearchRequest
|
||||
params: AnayasaBireyselReportSearchRequest,
|
||||
) -> AnayasaBireyselReportSearchResult:
|
||||
final_query_params = self._build_query_params_for_bireysel_report(params)
|
||||
request_url = self.SEARCH_PATH
|
||||
query = " ".join(t for t in (params.keywords or []) if t).strip()
|
||||
payload = await self.api.search(
|
||||
karar_tipi=KARAR_TIPI_BIREYSEL,
|
||||
query=query,
|
||||
page=params.page_to_fetch,
|
||||
size=getattr(params, "results_per_page", 10),
|
||||
)
|
||||
|
||||
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
|
||||
total_records = int(payload.get("total") or 0)
|
||||
decisions: List[AnayasaBireyselReportDecisionSummary] = []
|
||||
for item in payload.get("data") or []:
|
||||
decisions.append(AnayasaBireyselReportDecisionSummary(
|
||||
title=item.get("basvuruAdi") or "",
|
||||
decision_reference_no=item.get("basvuruNo") or "",
|
||||
decision_page_url=build_document_url(KARAR_TIPI_BIREYSEL, item.get("id", "")),
|
||||
decision_type_summary=item.get("kararTuruBasvuruSonucuLabel") or "",
|
||||
decision_making_body=item.get("kararVerenBirimLabel") or "",
|
||||
application_date_summary=item.get("basvuruTarihi") or "",
|
||||
decision_date_summary=item.get("kararTarihi") or "",
|
||||
application_subject_summary=strip_html_text(item.get("kararKonusu")),
|
||||
details=[],
|
||||
))
|
||||
|
||||
return AnayasaBireyselReportSearchResult(
|
||||
decisions=processed_decisions,
|
||||
decisions=decisions,
|
||||
total_records_found=total_records,
|
||||
retrieved_page_number=params.page_to_fetch
|
||||
retrieved_page_number=params.page_to_fetch,
|
||||
)
|
||||
|
||||
def _convert_html_to_markdown_bireysel(self, full_decision_html_content: str) -> Optional[str]:
|
||||
if not full_decision_html_content:
|
||||
return None
|
||||
|
||||
processed_html = html.unescape(full_decision_html_content)
|
||||
soup = BeautifulSoup(processed_html, "html.parser")
|
||||
html_input_for_markdown = ""
|
||||
|
||||
karar_tab_content = soup.find("div", id="Karar")
|
||||
if karar_tab_content:
|
||||
karar_html_span = karar_tab_content.find("span", class_="kararHtml")
|
||||
if karar_html_span:
|
||||
word_section = karar_html_span.find("div", class_="WordSection1")
|
||||
if word_section:
|
||||
for s in word_section.select('script, style, .item.col-xs-12.col-sm-12, center:has(b)'):
|
||||
s.decompose()
|
||||
html_input_for_markdown = str(word_section)
|
||||
else:
|
||||
logger.warning("AnayasaBireyselBasvuruApiClient: WordSection1 not found in span.kararHtml. Using span.kararHtml content.")
|
||||
for s in karar_html_span.select('script, style, .item.col-xs-12.col-sm-12, center:has(b)'):
|
||||
s.decompose()
|
||||
html_input_for_markdown = str(karar_html_span)
|
||||
else:
|
||||
logger.warning("AnayasaBireyselBasvuruApiClient: span.kararHtml not found in div#Karar. Using div#Karar content.")
|
||||
for s in karar_tab_content.select('script, style, .item.col-xs-12.col-sm-12, center:has(b)'):
|
||||
s.decompose()
|
||||
html_input_for_markdown = str(karar_tab_content)
|
||||
else:
|
||||
logger.warning("AnayasaBireyselBasvuruApiClient: div#Karar (KARAR tab) not found. Trying WordSection1 fallback.")
|
||||
word_section_fallback = soup.find("div", class_="WordSection1")
|
||||
if word_section_fallback:
|
||||
for s in word_section_fallback.select('script, style, .item.col-xs-12.col-sm-12, center:has(b)'):
|
||||
s.decompose()
|
||||
html_input_for_markdown = str(word_section_fallback)
|
||||
else:
|
||||
body_tag = soup.find("body")
|
||||
if body_tag:
|
||||
for s in body_tag.select('script, style, .item.col-xs-12.col-sm-12, center:has(b), .banner, .footer, .yazdirmaalani, .filtreler, .menu, .altmenu, .geri, .arabuton, .temizlebutonu, form#KararGetir, .TabBaslik, #KararDetaylari, .share-button-container'):
|
||||
s.decompose()
|
||||
html_input_for_markdown = str(body_tag)
|
||||
else:
|
||||
html_input_for_markdown = processed_html
|
||||
|
||||
markdown_text = None
|
||||
temp_file_path = None
|
||||
try:
|
||||
md_converter = MarkItDown(enable_plugins=False)
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as tmp_file:
|
||||
if not html_input_for_markdown.strip().lower().startswith(("<html", "<!doctype")):
|
||||
tmp_file.write(f"<html><head><meta charset=\"UTF-8\"></head><body>{html_input_for_markdown}</body></html>")
|
||||
else:
|
||||
tmp_file.write(html_input_for_markdown)
|
||||
temp_file_path = tmp_file.name
|
||||
|
||||
conversion_result = md_converter.convert(temp_file_path)
|
||||
markdown_text = conversion_result.text_content
|
||||
except Exception as e:
|
||||
logger.error(f"AnayasaBireyselBasvuruApiClient: MarkItDown conversion error: {e}")
|
||||
finally:
|
||||
if temp_file_path and os.path.exists(temp_file_path):
|
||||
os.remove(temp_file_path)
|
||||
return markdown_text
|
||||
|
||||
async def get_decision_document_as_markdown(
|
||||
self,
|
||||
document_url_path: str, # e.g. /BB/2021/20295
|
||||
page_number: int = 1
|
||||
document_url_path: str,
|
||||
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}")
|
||||
karar_tipi, uuid = parse_document_url(document_url_path)
|
||||
if karar_tipi is None:
|
||||
karar_tipi = KARAR_TIPI_BIREYSEL
|
||||
|
||||
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
|
||||
record = await self.api.get_decision(karar_tipi, uuid) if uuid else 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}.")
|
||||
if not record:
|
||||
logger.warning("AnayasaBireyselBasvuruApiClient: No record for %s", document_url_path)
|
||||
return AnayasaBireyselBasvuruDocumentMarkdown(
|
||||
source_url=full_url, markdown_chunk=None, current_page=page_number, total_pages=0, is_paginated=False
|
||||
source_url=document_url_path, markdown_chunk=None,
|
||||
current_page=page_number, total_pages=0, is_paginated=False,
|
||||
)
|
||||
|
||||
soup = BeautifulSoup(html_content_from_api, 'html.parser')
|
||||
rg_tarihi = record.get("resmiGazeteTarihi") or ""
|
||||
rg_sayisi = record.get("resmiGazeteSayisi")
|
||||
official_gazette = f"{rg_tarihi} / {rg_sayisi}".strip(" /") if (rg_tarihi or rg_sayisi) else None
|
||||
|
||||
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
|
||||
full_markdown = convert_icerik_to_markdown(record.get("icerik"))
|
||||
common = dict(
|
||||
source_url=document_url_path,
|
||||
basvuru_no_from_page=record.get("basvuruNo"),
|
||||
karar_tarihi_from_page=record.get("kararTarihi"),
|
||||
basvuru_tarihi_from_page=record.get("basvuruTarihi"),
|
||||
karari_veren_birim_from_page=record.get("kararVerenBirimLabel"),
|
||||
karar_turu_from_page=record.get("kararTuruBasvuruSonucuLabel"),
|
||||
resmi_gazete_info_from_page=official_gazette,
|
||||
)
|
||||
|
||||
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]
|
||||
|
||||
if not full_markdown:
|
||||
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)
|
||||
**common, markdown_chunk=None, current_page=page_number,
|
||||
total_pages=0, is_paginated=False,
|
||||
)
|
||||
|
||||
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
|
||||
total_pages = max(1, math.ceil(len(full_markdown) / DOCUMENT_MARKDOWN_CHUNK_SIZE))
|
||||
current_page = max(1, min(page_number, total_pages))
|
||||
start = (current_page - 1) * DOCUMENT_MARKDOWN_CHUNK_SIZE
|
||||
chunk = full_markdown[start:start + DOCUMENT_MARKDOWN_CHUNK_SIZE]
|
||||
|
||||
return AnayasaBireyselBasvuruDocumentMarkdown(
|
||||
**common, markdown_chunk=chunk, current_page=current_page,
|
||||
total_pages=total_pages, is_paginated=(total_pages > 1),
|
||||
)
|
||||
|
||||
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()
|
||||
await self.api.close()
|
||||
logger.info("AnayasaBireyselBasvuruApiClient: HTTP client session closed.")
|
||||
+102
-306
@@ -1,354 +1,150 @@
|
||||
# anayasa_mcp_module/client.py
|
||||
# This client is for Norm Denetimi: https://normkararlarbilgibankasi.anayasa.gov.tr
|
||||
# Norm Denetimi client backed by the new KBB JSON API (see api_client.py).
|
||||
#
|
||||
# The Anayasa Mahkemesi sites were rebuilt as a single-page app; the old
|
||||
# HTML-scraping endpoints on normkararlarbilgibankasi.anayasa.gov.tr/Ara now
|
||||
# return HTTP 404. This client maps the rich legacy request model onto the new
|
||||
# free-text "query" search and rebuilds the legacy response models from the JSON
|
||||
# payload so existing tooling keeps working.
|
||||
|
||||
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
|
||||
import math
|
||||
from typing import List, Optional
|
||||
|
||||
from .api_client import (
|
||||
AnayasaApiClient,
|
||||
KARAR_TIPI_NORM,
|
||||
DOCUMENT_MARKDOWN_CHUNK_SIZE,
|
||||
build_document_url,
|
||||
parse_document_url,
|
||||
convert_icerik_to_markdown,
|
||||
strip_html_text,
|
||||
)
|
||||
from .models import (
|
||||
AnayasaNormDenetimiSearchRequest,
|
||||
AnayasaDecisionSummary,
|
||||
AnayasaReviewedNormInfo,
|
||||
AnayasaSearchResult,
|
||||
AnayasaDocumentMarkdown, # Model for Norm Denetimi document
|
||||
AnayasaDocumentMarkdown,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if not logger.hasHandlers():
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
|
||||
|
||||
def _build_query(params: AnayasaNormDenetimiSearchRequest) -> str:
|
||||
"""Derive the free-text query string the new API expects from the legacy model.
|
||||
|
||||
The new endpoint only supports a single full-text "query" field, so the
|
||||
keyword lists are flattened. Esas/Karar numbers are appended when no keyword
|
||||
is provided so number-based lookups still return results.
|
||||
"""
|
||||
terms: List[str] = []
|
||||
for bucket in (params.keywords_all, params.keywords_any):
|
||||
if bucket:
|
||||
terms.extend(t for t in bucket if t)
|
||||
if not terms:
|
||||
for value in (params.case_number_esas, params.decision_number_karar):
|
||||
if value:
|
||||
terms.append(value)
|
||||
return " ".join(terms).strip()
|
||||
|
||||
|
||||
class AnayasaMahkemesiApiClient:
|
||||
BASE_URL = "https://normkararlarbilgibankasi.anayasa.gov.tr"
|
||||
SEARCH_PATH_SEGMENT = "Ara"
|
||||
DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000 # Character limit per page
|
||||
"""Norm Denetimi search/document client over the KBB JSON API."""
|
||||
|
||||
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
|
||||
self.api = AnayasaApiClient(request_timeout)
|
||||
|
||||
async def search_norm_denetimi_decisions(
|
||||
self,
|
||||
params: AnayasaNormDenetimiSearchRequest
|
||||
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}")
|
||||
query = _build_query(params)
|
||||
payload = await self.api.search(
|
||||
karar_tipi=KARAR_TIPI_NORM,
|
||||
query=query,
|
||||
page=params.page_to_fetch,
|
||||
size=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
|
||||
total_records = int(payload.get("total") or 0)
|
||||
decisions: List[AnayasaDecisionSummary] = []
|
||||
for item in payload.get("data") or []:
|
||||
esas_no = item.get("esasNo") or ""
|
||||
karar_no = item.get("kararNo") or ""
|
||||
if esas_no and karar_no:
|
||||
reference = f"E.{esas_no}, K.{karar_no}"
|
||||
else:
|
||||
reference = esas_no or karar_no or ""
|
||||
decisions.append(AnayasaDecisionSummary(
|
||||
decision_reference_no=reference,
|
||||
decision_page_url=build_document_url(KARAR_TIPI_NORM, item.get("id", "")),
|
||||
keywords_found_count=item.get("highlightCount") or 0,
|
||||
application_type_summary=item.get("basvuruTuruLabel") or "",
|
||||
applicant_summary=item.get("basvuranGenelLabel") or "",
|
||||
decision_outcome_summary=strip_html_text(item.get("kararKonusu")),
|
||||
decision_date_summary=item.get("kararTarihi") or "",
|
||||
reviewed_norms=[],
|
||||
))
|
||||
|
||||
return AnayasaSearchResult(
|
||||
decisions=processed_decisions,
|
||||
decisions=decisions,
|
||||
total_records_found=total_records,
|
||||
retrieved_page_number=params.page_to_fetch
|
||||
retrieved_page_number=params.page_to_fetch,
|
||||
)
|
||||
|
||||
def _convert_html_to_markdown_norm_denetimi(self, full_decision_html_content: str) -> Optional[str]:
|
||||
"""Converts direct HTML content from an Anayasa Mahkemesi Norm Denetimi decision page to Markdown."""
|
||||
if not full_decision_html_content:
|
||||
return None
|
||||
|
||||
processed_html = html.unescape(full_decision_html_content)
|
||||
soup = BeautifulSoup(processed_html, "html.parser")
|
||||
html_input_for_markdown = ""
|
||||
|
||||
karar_tab_content = soup.find("div", id="Karar") # "KARAR" tab content
|
||||
if karar_tab_content:
|
||||
karar_metni_div = karar_tab_content.find("div", class_="KararMetni")
|
||||
if karar_metni_div:
|
||||
# Remove scripts and styles
|
||||
for script_tag in karar_metni_div.find_all("script"): script_tag.decompose()
|
||||
for style_tag in karar_metni_div.find_all("style"): style_tag.decompose()
|
||||
# Remove "Künye Kopyala" button and other non-content divs
|
||||
for item_div in karar_metni_div.find_all("div", class_="item col-sm-12"): item_div.decompose()
|
||||
for modal_div in karar_metni_div.find_all("div", class_="modal fade"): modal_div.decompose() # If any modals
|
||||
|
||||
word_section = karar_metni_div.find("div", class_="WordSection1")
|
||||
html_input_for_markdown = str(word_section) if word_section else str(karar_metni_div)
|
||||
else:
|
||||
html_input_for_markdown = str(karar_tab_content)
|
||||
else:
|
||||
# Fallback if specific structure is not found
|
||||
word_section_fallback = soup.find("div", class_="WordSection1")
|
||||
if word_section_fallback:
|
||||
html_input_for_markdown = str(word_section_fallback)
|
||||
else:
|
||||
# Last resort: use the whole body or the raw HTML
|
||||
body_tag = soup.find("body")
|
||||
html_input_for_markdown = str(body_tag) if body_tag else processed_html
|
||||
|
||||
markdown_text = None
|
||||
temp_file_path = None
|
||||
try:
|
||||
md_converter = MarkItDown(enable_plugins=False)
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as tmp_file:
|
||||
# Ensure the content is wrapped in basic HTML structure if it's not already
|
||||
if not html_input_for_markdown.strip().lower().startswith(("<html", "<!doctype")):
|
||||
tmp_file.write(f"<html><head><meta charset=\"UTF-8\"></head><body>{html_input_for_markdown}</body></html>")
|
||||
else:
|
||||
tmp_file.write(html_input_for_markdown)
|
||||
temp_file_path = tmp_file.name
|
||||
|
||||
conversion_result = md_converter.convert(temp_file_path)
|
||||
markdown_text = conversion_result.text_content
|
||||
except Exception as e:
|
||||
logger.error(f"AnayasaMahkemesiApiClient: MarkItDown conversion error: {e}")
|
||||
finally:
|
||||
if temp_file_path and os.path.exists(temp_file_path):
|
||||
os.remove(temp_file_path)
|
||||
return markdown_text
|
||||
|
||||
async def get_decision_document_as_markdown(
|
||||
self,
|
||||
document_url: str,
|
||||
page_number: int = 1
|
||||
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}")
|
||||
karar_tipi, uuid = parse_document_url(document_url)
|
||||
if karar_tipi is None:
|
||||
karar_tipi = KARAR_TIPI_NORM
|
||||
|
||||
decision_ek_no_from_page = None
|
||||
decision_date_from_page = None
|
||||
official_gazette_from_page = None
|
||||
record = await self.api.get_decision(karar_tipi, uuid) if uuid else 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}.")
|
||||
if not record:
|
||||
logger.warning("AnayasaMahkemesiApiClient: No record for document_url %s", document_url)
|
||||
return AnayasaDocumentMarkdown(
|
||||
source_url=full_url, markdown_chunk=None, current_page=page_number, total_pages=0, is_paginated=False
|
||||
source_url=document_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")
|
||||
esas_no = record.get("esasNo") or ""
|
||||
karar_no = record.get("kararNo") or ""
|
||||
reference = f"E.{esas_no}, K.{karar_no}" if (esas_no and karar_no) else (esas_no or karar_no or "")
|
||||
rg_tarihi = record.get("resmiGazeteTarihi") or ""
|
||||
rg_sayisi = record.get("resmiGazeteSayisi")
|
||||
official_gazette = f"{rg_tarihi} / {rg_sayisi}".strip(" /") if (rg_tarihi or rg_sayisi) else ""
|
||||
|
||||
if karar_metni_div:
|
||||
# Attempt to find E.K. No (Esas No, Karar No)
|
||||
# Norm Denetimi pages often have this in bold <p> tags directly or in the WordSection1
|
||||
# Look for patterns like "Esas No.: YYYY/NN" and "Karar No.: YYYY/NN"
|
||||
|
||||
esas_no_tag = karar_metni_div.find(lambda tag: tag.name == "p" and tag.find("b") and "Esas No.:" in tag.find("b").get_text())
|
||||
karar_no_tag = karar_metni_div.find(lambda tag: tag.name == "p" and tag.find("b") and "Karar No.:" in tag.find("b").get_text())
|
||||
karar_tarihi_tag = karar_metni_div.find(lambda tag: tag.name == "p" and tag.find("b") and "Karar tarihi:" in tag.find("b").get_text()) # Less common on Norm pages
|
||||
resmi_gazete_tag = karar_metni_div.find(lambda tag: tag.name == "p" and ("Resmî Gazete tarih ve sayısı:" in tag.get_text() or "Resmi Gazete tarih/sayı:" in tag.get_text()))
|
||||
|
||||
|
||||
if esas_no_tag and esas_no_tag.find("b") and karar_no_tag and karar_no_tag.find("b"):
|
||||
esas_str = esas_no_tag.find("b").get_text(strip=True).replace('Esas No.:', '').strip()
|
||||
karar_str = karar_no_tag.find("b").get_text(strip=True).replace('Karar No.:', '').strip()
|
||||
decision_ek_no_from_page = f"E.{esas_str}, K.{karar_str}"
|
||||
|
||||
if karar_tarihi_tag and karar_tarihi_tag.find("b"):
|
||||
decision_date_from_page = karar_tarihi_tag.find("b").get_text(strip=True).replace("Karar tarihi:", "").strip()
|
||||
elif karar_metni_div: # Fallback for Karar Tarihi if not in specific tag
|
||||
date_match = re.search(r"Karar Tarihi\s*:\s*([\d\.]+)", karar_metni_div.get_text()) # Norm pages often use DD.MM.YYYY
|
||||
if date_match: decision_date_from_page = date_match.group(1).strip()
|
||||
|
||||
|
||||
if resmi_gazete_tag:
|
||||
# Try to get the bold part first if it exists
|
||||
bold_rg_tag = resmi_gazete_tag.find("b")
|
||||
rg_text_content = bold_rg_tag.get_text(strip=True) if bold_rg_tag else resmi_gazete_tag.get_text(strip=True)
|
||||
official_gazette_from_page = rg_text_content.replace("Resmî Gazete tarih ve sayısı:", "").replace("Resmi Gazete tarih/sayı:", "").strip()
|
||||
|
||||
|
||||
full_markdown_content = self._convert_html_to_markdown_norm_denetimi(html_content_from_api)
|
||||
|
||||
if not full_markdown_content:
|
||||
full_markdown = convert_icerik_to_markdown(record.get("icerik"))
|
||||
if not full_markdown:
|
||||
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
|
||||
source_url=document_url,
|
||||
decision_reference_no_from_page=reference,
|
||||
decision_date_from_page=record.get("kararTarihi") or "",
|
||||
official_gazette_info_from_page=official_gazette,
|
||||
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]
|
||||
total_pages = max(1, math.ceil(len(full_markdown) / DOCUMENT_MARKDOWN_CHUNK_SIZE))
|
||||
current_page = max(1, min(page_number, total_pages))
|
||||
start = (current_page - 1) * DOCUMENT_MARKDOWN_CHUNK_SIZE
|
||||
chunk = full_markdown[start:start + DOCUMENT_MARKDOWN_CHUNK_SIZE]
|
||||
|
||||
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,
|
||||
source_url=document_url,
|
||||
decision_reference_no_from_page=reference,
|
||||
decision_date_from_page=record.get("kararTarihi") or "",
|
||||
official_gazette_info_from_page=official_gazette,
|
||||
markdown_chunk=chunk,
|
||||
current_page=current_page,
|
||||
total_pages=total_pages,
|
||||
is_paginated=(total_pages > 1)
|
||||
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()
|
||||
await self.api.close()
|
||||
logger.info("AnayasaMahkemesiApiClient (Norm Denetimi): HTTP client session closed.")
|
||||
@@ -1,46 +1,24 @@
|
||||
# anayasa_mcp_module/models.py
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
from typing import List, Optional, Dict, Any
|
||||
from typing import List, Optional, Dict, Any, Literal
|
||||
from enum import Enum
|
||||
|
||||
# --- Enums (AnayasaDonemEnum, AnayasaBasvuruTuruEnum, etc. - same as before) ---
|
||||
# --- Enums (AnayasaDonemEnum, etc. - same as before) ---
|
||||
class AnayasaDonemEnum(str, Enum):
|
||||
TUMU = ""
|
||||
TUMU = "ALL"
|
||||
DONEM_1961 = "1"
|
||||
DONEM_1982 = "2"
|
||||
|
||||
class AnayasaBasvuruTuruEnum(str, Enum):
|
||||
TUMU = ""
|
||||
IPTAL = "1"
|
||||
ITIRAZ = "2"
|
||||
DIGER = "3"
|
||||
|
||||
class AnayasaVarYokEnum(str, Enum):
|
||||
TUMU = ""
|
||||
TUMU = "ALL"
|
||||
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 = ""
|
||||
TUMU = "ALL"
|
||||
ESAS_ACILMAMIS_SAYILMA = "1"
|
||||
ESAS_IPTAL = "2"
|
||||
ESAS_KARAR_YER_OLMADIGI = "3"
|
||||
@@ -52,7 +30,7 @@ class AnayasaIncelemeSonucuEnum(str, Enum):
|
||||
KANUN_6216_M43_4_IPTAL = "12"
|
||||
|
||||
class AnayasaSonucGerekcesiEnum(str, Enum):
|
||||
TUMU = ""
|
||||
TUMU = "ALL"
|
||||
ANAYASAYA_AYKIRI_DEGIL = "29"
|
||||
ANAYASAYA_ESAS_YONUNDEN_AYKIRILIK = "1"
|
||||
ANAYASAYA_ESAS_YONUNDEN_UYGUNLUK = "2"
|
||||
@@ -89,60 +67,60 @@ class AnayasaNormDenetimiSearchRequest(BaseModel):
|
||||
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).")
|
||||
period: Optional[Literal["ALL", "1", "2"]] = Field(default="ALL", description="Constitutional period (Donemler_id).")
|
||||
case_number_esas: str = Field("", description="Case registry number (EsasNo), e.g., '2023/123'.")
|
||||
decision_number_karar: str = Field("", description="Decision number (KararNo), e.g., '2023/456'.")
|
||||
first_review_date_start: str = Field("", description="First review start date (IlkIncelemeTarihiIlk), format DD/MM/YYYY.")
|
||||
first_review_date_end: str = Field("", description="First review end date (IlkIncelemeTarihiSon), format DD/MM/YYYY.")
|
||||
decision_date_start: str = Field("", description="Decision start date (KararTarihiIlk), format DD/MM/YYYY.")
|
||||
decision_date_end: str = Field("", description="Decision end date (KararTarihiSon), format DD/MM/YYYY.")
|
||||
application_type: Optional[Literal["ALL", "1", "2", "3"]] = Field(default="ALL", description="Type of application (BasvuruTurler_id).")
|
||||
applicant_general_name: str = Field("", description="General applicant name (BasvuranGeneller_id).")
|
||||
applicant_specific_name: str = Field("", description="Specific applicant name (BasvuranOzeller_id).")
|
||||
official_gazette_date_start: str = Field("", description="Official Gazette start date (ResmiGazeteTarihiIlk), format DD/MM/YYYY.")
|
||||
official_gazette_date_end: str = Field("", description="Official Gazette end date (ResmiGazeteTarihiSon), format DD/MM/YYYY.")
|
||||
official_gazette_number_start: str = Field("", description="Official Gazette starting number (ResmiGazeteSayisiIlk).")
|
||||
official_gazette_number_end: str = Field("", description="Official Gazette ending number (ResmiGazeteSayisiSon).")
|
||||
has_press_release: Optional[Literal["ALL", "0", "1"]] = Field(default="ALL", description="Press release available (BasinDuyurusu).")
|
||||
has_dissenting_opinion: Optional[Literal["ALL", "0", "1"]] = Field(default="ALL", description="Dissenting opinion exists (KarsiOy).")
|
||||
has_different_reasoning: Optional[Literal["ALL", "0", "1"]] = Field(default="ALL", 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).")
|
||||
rapporteur_name: str = Field("", description="Rapporteur's exact name (Raportorler_id).")
|
||||
norm_type: Optional[Literal["ALL", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "0"]] = Field(default="ALL", description="Type of the reviewed norm (NormunTurler_id).")
|
||||
norm_id_or_name: str = Field("", description="Number or name of the norm (NormunNumarasiAdlar_id).")
|
||||
norm_article: str = Field("", description="Article number of the norm (NormunMaddeNumarasi).")
|
||||
review_outcomes: Optional[List[Literal["1", "2", "3", "4", "5", "6", "7", "8", "12"]]] = Field(default_factory=list, description="List of review types and outcomes (IncelemeTuruKararSonuclar_id[]).")
|
||||
reason_for_final_outcome: Optional[Literal["ALL", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "29", "30"]] = Field(default="ALL", 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).")
|
||||
results_per_page: int = Field(10, ge=1, le=10, description="Results per page.")
|
||||
page_to_fetch: int = Field(1, ge=1, description="Page number to fetch for results list.")
|
||||
sort_by_criteria: 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
|
||||
norm_name_or_number: str = Field("", description="Norm name or number")
|
||||
article_number: str = Field("", description="Article number")
|
||||
review_type_and_outcome: str = Field("", description="Review type and outcome")
|
||||
outcome_reason: str = Field("", description="Outcome reason")
|
||||
basis_constitution_articles_cited: List[str] = Field(default_factory=list)
|
||||
postponement_period: Optional[str] = None
|
||||
postponement_period: str = Field("", description="Postponement period")
|
||||
|
||||
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
|
||||
decision_reference_no: str = Field("", description="Decision reference number")
|
||||
decision_page_url: str = Field("", description="Decision page URL")
|
||||
keywords_found_count: Optional[int] = Field(0, description="Keywords found count")
|
||||
application_type_summary: str = Field("", description="Application type summary")
|
||||
applicant_summary: str = Field("", description="Applicant summary")
|
||||
decision_outcome_summary: str = Field("", description="Decision outcome summary")
|
||||
decision_date_summary: str = Field("", description="Decision date summary")
|
||||
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
|
||||
total_records_found: int = Field(0, description="Total records found")
|
||||
retrieved_page_number: int = Field(1, description="Retrieved page number")
|
||||
|
||||
class AnayasaDocumentMarkdown(BaseModel):
|
||||
"""
|
||||
@@ -150,10 +128,10 @@ class AnayasaDocumentMarkdown(BaseModel):
|
||||
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
|
||||
decision_reference_no_from_page: str = Field("", description="E.K. No parsed from the document page.")
|
||||
decision_date_from_page: str = Field("", description="Decision date parsed from the document page.")
|
||||
official_gazette_info_from_page: str = Field("", description="Official Gazette info parsed from the document page.")
|
||||
markdown_chunk: str = Field("", 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.")
|
||||
@@ -162,33 +140,34 @@ class AnayasaDocumentMarkdown(BaseModel):
|
||||
# --- 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[]).")
|
||||
"""Model for Anayasa Mahkemesi (Bireysel Başvuru) search request."""
|
||||
keywords: Optional[List[str]] = Field(default_factory=list, description="Keywords joined into the full-text query.")
|
||||
page_to_fetch: int = Field(1, ge=1, description="Page number to fetch for the report (page). Default is 1.")
|
||||
results_per_page: int = Field(10, ge=1, le=100, description="Results per page.")
|
||||
|
||||
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).")
|
||||
hak: str = Field("", description="İhlal edildiği iddia edilen hak (örneğin, Mülkiyet hakkı).")
|
||||
mudahale_iddiasi: str = Field("", description="İhlale neden olan müdahale iddiası.")
|
||||
sonuc: str = Field("", description="İnceleme sonucu (örneğin, İhlal, Düşme).")
|
||||
giderim: str = Field("", 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.")
|
||||
title: str = Field("", description="Başvurunun başlığı (e.g., 'HASAN DURMUŞ Başvurusuna İlişkin Karar').")
|
||||
decision_reference_no: str = Field("", description="Başvuru Numarası (e.g., '2019/19126').")
|
||||
decision_page_url: str = Field("", description="URL to the full decision page.")
|
||||
decision_type_summary: str = Field("", description="Karar Türü (Başvuru Sonucu) (e.g., 'Esas (İhlal)').")
|
||||
decision_making_body: str = Field("", description="Kararı Veren Birim (e.g., 'Genel Kurul', 'Birinci Bölüm').")
|
||||
application_date_summary: str = Field("", description="Başvuru Tarihi (DD/MM/YYYY).")
|
||||
decision_date_summary: str = Field("", description="Karar Tarihi (DD/MM/YYYY).")
|
||||
application_subject_summary: str = Field("", 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ı.")
|
||||
total_records_found: int = Field(0, description="Raporda bulunan toplam karar sayısı.")
|
||||
retrieved_page_number: int = Field(description="Alınan rapor sayfa numarası.")
|
||||
|
||||
|
||||
@@ -210,3 +189,36 @@ class AnayasaBireyselBasvuruDocumentMarkdown(BaseModel):
|
||||
is_paginated: bool = Field(description="True if the full markdown content is split into multiple pages.")
|
||||
|
||||
# --- End Models for Bireysel Başvuru ---
|
||||
|
||||
# --- Unified Models ---
|
||||
class AnayasaUnifiedSearchRequest(BaseModel):
|
||||
"""Unified search request for both Norm Denetimi and Bireysel Başvuru.
|
||||
|
||||
The KBB API only exposes a single free-text "query" field plus pagination,
|
||||
so the keyword lists below are flattened into that query.
|
||||
"""
|
||||
decision_type: Literal["norm_denetimi", "bireysel_basvuru"] = Field(..., description="Decision type: norm_denetimi or bireysel_basvuru")
|
||||
|
||||
# Common parameters
|
||||
keywords: List[str] = Field(default_factory=list, description="Keywords to search for (joined into a single full-text query)")
|
||||
keywords_all: List[str] = Field(default_factory=list, description="Additional keywords to include in the query")
|
||||
keywords_any: List[str] = Field(default_factory=list, description="Additional alternative keywords to include in the query")
|
||||
page_to_fetch: int = Field(1, ge=1, le=100, description="Page number to fetch (1-100)")
|
||||
results_per_page: int = Field(10, ge=1, le=100, description="Results per page (1-100)")
|
||||
|
||||
class AnayasaUnifiedSearchResult(BaseModel):
|
||||
"""Unified search result containing decisions from either system."""
|
||||
decision_type: Literal["norm_denetimi", "bireysel_basvuru"] = Field(..., description="Type of decisions returned")
|
||||
decisions: List[Dict[str, Any]] = Field(default_factory=list, description="Decision list (structure varies by type)")
|
||||
total_records_found: int = Field(0, description="Total number of records found")
|
||||
retrieved_page_number: int = Field(1, description="Page number that was retrieved")
|
||||
|
||||
class AnayasaUnifiedDocumentMarkdown(BaseModel):
|
||||
"""Unified document model for both Norm Denetimi and Bireysel Başvuru."""
|
||||
decision_type: Literal["norm_denetimi", "bireysel_basvuru"] = Field(..., description="Type of document")
|
||||
source_url: HttpUrl = Field(..., description="Source URL of the document")
|
||||
document_data: Dict[str, Any] = Field(default_factory=dict, description="Document content and metadata")
|
||||
markdown_chunk: Optional[str] = Field(None, description="Markdown content chunk")
|
||||
current_page: int = Field(1, description="Current page number")
|
||||
total_pages: int = Field(1, description="Total number of pages")
|
||||
is_paginated: bool = Field(False, description="Whether document is paginated")
|
||||
@@ -0,0 +1,118 @@
|
||||
# anayasa_mcp_module/unified_client.py
|
||||
# Unified client for both Norm Denetimi and Bireysel Başvuru, backed by the new
|
||||
# KBB JSON API. Routing between the two is by the "decision_type" discriminator
|
||||
# on search, and by the document URL (?type=...) on document retrieval.
|
||||
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from .models import (
|
||||
AnayasaUnifiedSearchRequest,
|
||||
AnayasaUnifiedSearchResult,
|
||||
AnayasaUnifiedDocumentMarkdown,
|
||||
AnayasaNormDenetimiSearchRequest,
|
||||
AnayasaBireyselReportSearchRequest,
|
||||
)
|
||||
from .client import AnayasaMahkemesiApiClient
|
||||
from .bireysel_client import AnayasaBireyselBasvuruApiClient
|
||||
from .api_client import (
|
||||
KARAR_TIPI_NORM,
|
||||
KARAR_TIPI_BIREYSEL,
|
||||
parse_document_url,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def normalize_anayasa_document_url(document_url: str) -> Tuple[Optional[str], str]:
|
||||
"""Detect the AYM decision type from a document URL.
|
||||
|
||||
Returns ``(decision_type, document_url)`` where ``decision_type`` is
|
||||
``"norm_denetimi"``, ``"bireysel_basvuru"``, or ``None`` if it cannot be
|
||||
determined. The URL is returned unchanged (kept for backwards compatibility
|
||||
with callers that expect a possibly-normalized URL).
|
||||
"""
|
||||
karar_tipi, _ = parse_document_url(document_url)
|
||||
if karar_tipi == KARAR_TIPI_NORM:
|
||||
return "norm_denetimi", document_url
|
||||
if karar_tipi == KARAR_TIPI_BIREYSEL:
|
||||
return "bireysel_basvuru", document_url
|
||||
return None, document_url
|
||||
|
||||
|
||||
class AnayasaUnifiedClient:
|
||||
"""Unified client that handles both Norm Denetimi and Bireysel Başvuru searches."""
|
||||
|
||||
def __init__(self, request_timeout: float = 60.0):
|
||||
self.norm_client = AnayasaMahkemesiApiClient(request_timeout)
|
||||
self.bireysel_client = AnayasaBireyselBasvuruApiClient(request_timeout)
|
||||
|
||||
async def search_unified(self, params: AnayasaUnifiedSearchRequest) -> AnayasaUnifiedSearchResult:
|
||||
"""Unified search that routes to the appropriate client based on decision_type."""
|
||||
|
||||
if params.decision_type == "norm_denetimi":
|
||||
norm_params = AnayasaNormDenetimiSearchRequest(
|
||||
keywords_all=params.keywords_all or params.keywords,
|
||||
keywords_any=params.keywords_any,
|
||||
page_to_fetch=params.page_to_fetch,
|
||||
results_per_page=params.results_per_page,
|
||||
)
|
||||
result = await self.norm_client.search_norm_denetimi_decisions(norm_params)
|
||||
|
||||
return AnayasaUnifiedSearchResult(
|
||||
decision_type="norm_denetimi",
|
||||
decisions=[d.model_dump() for d in result.decisions],
|
||||
total_records_found=result.total_records_found,
|
||||
retrieved_page_number=result.retrieved_page_number,
|
||||
)
|
||||
|
||||
elif params.decision_type == "bireysel_basvuru":
|
||||
bireysel_params = AnayasaBireyselReportSearchRequest(
|
||||
keywords=params.keywords or params.keywords_all,
|
||||
page_to_fetch=params.page_to_fetch,
|
||||
results_per_page=params.results_per_page,
|
||||
)
|
||||
result = await self.bireysel_client.search_bireysel_basvuru_report(bireysel_params)
|
||||
|
||||
return AnayasaUnifiedSearchResult(
|
||||
decision_type="bireysel_basvuru",
|
||||
decisions=[d.model_dump() for d in result.decisions],
|
||||
total_records_found=result.total_records_found,
|
||||
retrieved_page_number=result.retrieved_page_number,
|
||||
)
|
||||
|
||||
raise ValueError(f"Unsupported decision type: {params.decision_type}")
|
||||
|
||||
async def get_document_unified(self, document_url: str, page_number: int = 1) -> AnayasaUnifiedDocumentMarkdown:
|
||||
"""Unified document retrieval that auto-detects the decision type from the URL."""
|
||||
|
||||
decision_type, _ = normalize_anayasa_document_url(document_url)
|
||||
|
||||
if decision_type == "bireysel_basvuru":
|
||||
result = await self.bireysel_client.get_decision_document_as_markdown(document_url, page_number)
|
||||
return AnayasaUnifiedDocumentMarkdown(
|
||||
decision_type="bireysel_basvuru",
|
||||
source_url=result.source_url,
|
||||
document_data=result.model_dump(mode="json"),
|
||||
markdown_chunk=result.markdown_chunk,
|
||||
current_page=result.current_page,
|
||||
total_pages=result.total_pages,
|
||||
is_paginated=result.is_paginated,
|
||||
)
|
||||
|
||||
# Default to norm_denetimi (also covers explicit norm_denetimi detection).
|
||||
result = await self.norm_client.get_decision_document_as_markdown(document_url, page_number)
|
||||
return AnayasaUnifiedDocumentMarkdown(
|
||||
decision_type="norm_denetimi",
|
||||
source_url=result.source_url,
|
||||
document_data=result.model_dump(mode="json"),
|
||||
markdown_chunk=result.markdown_chunk,
|
||||
current_page=result.current_page,
|
||||
total_pages=result.total_pages,
|
||||
is_paginated=result.is_paginated,
|
||||
)
|
||||
|
||||
async def close_client_session(self):
|
||||
"""Close both client sessions."""
|
||||
await self.norm_client.close_client_session()
|
||||
await self.bireysel_client.close_client_session()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
ASGI application for Yargı MCP Server (simple deployment variant).
|
||||
|
||||
This is a minimal ASGI application that can be run with:
|
||||
uvicorn app:app --host 0.0.0.0 --port 8000
|
||||
|
||||
The MCP server will be available at:
|
||||
http://localhost:8000/mcp/
|
||||
|
||||
For the FastAPI-wrapped variant with CORS and extra metadata routes,
|
||||
see asgi_app.py instead.
|
||||
"""
|
||||
|
||||
from starlette.responses import JSONResponse
|
||||
from mcp_server_main import create_app
|
||||
|
||||
mcp = create_app()
|
||||
|
||||
|
||||
@mcp.custom_route("/health", methods=["GET"])
|
||||
async def health_check(request):
|
||||
"""Health check endpoint for monitoring services (Fly.io, Render, etc.)."""
|
||||
return JSONResponse({
|
||||
"status": "healthy",
|
||||
"service": "Yargı MCP Server",
|
||||
"version": "0.2.1",
|
||||
})
|
||||
|
||||
|
||||
# Create ASGI app directly from FastMCP server
|
||||
app = mcp.http_app()
|
||||
|
||||
# Endpoints:
|
||||
# - /mcp/ - MCP server (Streamable HTTP transport, default FastMCP path)
|
||||
# - /health - Health check for monitoring
|
||||
Executable
+147
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
ASGI application for Yargı MCP Server
|
||||
|
||||
This module provides ASGI/HTTP access to the Yargı MCP server,
|
||||
allowing it to be deployed as a web service with FastAPI wrapper.
|
||||
|
||||
Usage:
|
||||
uvicorn asgi_app:app --host 0.0.0.0 --port 8000
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
|
||||
from mcp_server_main import create_app
|
||||
|
||||
# Setup logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configure CORS
|
||||
cors_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",")
|
||||
|
||||
# Create MCP app
|
||||
mcp_server = create_app()
|
||||
|
||||
# Create MCP Starlette sub-application
|
||||
mcp_app = mcp_server.http_app(path="/")
|
||||
|
||||
|
||||
# Configure JSON encoder for proper Turkish character support
|
||||
class UTF8JSONResponse(JSONResponse):
|
||||
def __init__(self, content=None, status_code=200, headers=None, **kwargs):
|
||||
if headers is None:
|
||||
headers = {}
|
||||
headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
super().__init__(content, status_code, headers, **kwargs)
|
||||
|
||||
def render(self, content) -> bytes:
|
||||
return json.dumps(
|
||||
content,
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
indent=None,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
custom_middleware = [
|
||||
Middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "X-Request-ID", "X-Session-ID"],
|
||||
),
|
||||
]
|
||||
|
||||
# Create FastAPI wrapper application
|
||||
app = FastAPI(
|
||||
title="Yargı MCP Server",
|
||||
description="MCP server for Turkish legal databases",
|
||||
version="0.1.0",
|
||||
middleware=custom_middleware,
|
||||
default_response_class=UTF8JSONResponse,
|
||||
redirect_slashes=False,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint for monitoring"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "Yargı MCP Server",
|
||||
"version": "0.1.0",
|
||||
"tools_count": len(mcp_server._tool_manager._tools),
|
||||
}
|
||||
|
||||
|
||||
@app.api_route("/mcp", methods=["GET", "POST", "HEAD", "OPTIONS"])
|
||||
async def redirect_to_slash(request: Request):
|
||||
"""Redirect /mcp to /mcp/ preserving HTTP method with 308"""
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url="/mcp/", status_code=308)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint with service information"""
|
||||
return {
|
||||
"service": "Yargı MCP Server",
|
||||
"description": "MCP server for Turkish legal databases",
|
||||
"endpoints": {
|
||||
"mcp": "/mcp",
|
||||
"health": "/health",
|
||||
"status": "/status",
|
||||
},
|
||||
"transports": {
|
||||
"http": "/mcp"
|
||||
},
|
||||
"supported_databases": [
|
||||
"Yargıtay (Court of Cassation)",
|
||||
"Danıştay (Council of State)",
|
||||
"Emsal (Precedent)",
|
||||
"Uyuşmazlık Mahkemesi (Court of Jurisdictional Disputes)",
|
||||
"Anayasa Mahkemesi (Constitutional Court)",
|
||||
"Kamu İhale Kurulu (Public Procurement Authority)",
|
||||
"Rekabet Kurumu (Competition Authority)",
|
||||
"Sayıştay (Court of Accounts)",
|
||||
"KVKK (Personal Data Protection Authority)",
|
||||
"BDDK (Banking Regulation and Supervision Agency)",
|
||||
"BTK (Information and Communication Technologies Authority)",
|
||||
"Bedesten API (Multiple courts)",
|
||||
"Sigorta Tahkim Komisyonu (Insurance Arbitration Commission)",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/status")
|
||||
async def status():
|
||||
"""Status endpoint with detailed information"""
|
||||
tools = []
|
||||
for tool in mcp_server._tool_manager._tools.values():
|
||||
tools.append({
|
||||
"name": tool.name,
|
||||
"description": tool.description[:100] + "..." if len(tool.description) > 100 else tool.description
|
||||
})
|
||||
|
||||
return {
|
||||
"status": "operational",
|
||||
"tools": tools,
|
||||
"total_tools": len(tools),
|
||||
"transport": "streamable_http",
|
||||
}
|
||||
|
||||
|
||||
# Mount MCP app at /mcp/
|
||||
app.mount("/mcp/", mcp_app)
|
||||
|
||||
# Set the lifespan context after mounting
|
||||
app.router.lifespan_context = mcp_app.lifespan
|
||||
|
||||
# Export for uvicorn
|
||||
__all__ = ["app"]
|
||||
@@ -0,0 +1,17 @@
|
||||
# bddk_mcp_module/__init__.py
|
||||
|
||||
from .client import BddkApiClient
|
||||
from .models import (
|
||||
BddkSearchRequest,
|
||||
BddkDecisionSummary,
|
||||
BddkSearchResult,
|
||||
BddkDocumentMarkdown
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BddkApiClient",
|
||||
"BddkSearchRequest",
|
||||
"BddkDecisionSummary",
|
||||
"BddkSearchResult",
|
||||
"BddkDocumentMarkdown"
|
||||
]
|
||||
@@ -0,0 +1,253 @@
|
||||
# bddk_mcp_module/client.py
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import List, Optional, Dict, Any
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import io
|
||||
import math
|
||||
from urllib.parse import urlparse
|
||||
from markitdown import MarkItDown
|
||||
|
||||
from .models import (
|
||||
BddkSearchRequest,
|
||||
BddkDecisionSummary,
|
||||
BddkSearchResult,
|
||||
BddkDocumentMarkdown
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if not logger.hasHandlers():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
class BddkApiClient:
|
||||
"""
|
||||
API client for searching and retrieving BDDK (Banking Regulation Authority) decisions
|
||||
using Tavily Search API for discovery and direct HTTP requests for content retrieval.
|
||||
"""
|
||||
|
||||
TAVILY_API_URL = "https://api.tavily.com/search"
|
||||
BDDK_BASE_URL = "https://www.bddk.org.tr"
|
||||
DOCUMENT_URL_TEMPLATE = "https://www.bddk.org.tr/Mevzuat/DokumanGetir/{document_id}"
|
||||
DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000 # Character limit per page
|
||||
|
||||
def __init__(self, request_timeout: float = 60.0):
|
||||
"""Initialize the BDDK API client."""
|
||||
self.tavily_api_key = os.getenv("TAVILY_API_KEY")
|
||||
if not self.tavily_api_key:
|
||||
# Fallback to development token
|
||||
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("BddkApiClient: HTTP client session closed.")
|
||||
|
||||
def _extract_document_id(self, url: str) -> Optional[str]:
|
||||
"""Extract document ID from BDDK URL."""
|
||||
# Primary pattern: https://www.bddk.org.tr/Mevzuat/DokumanGetir/310
|
||||
match = re.search(r'/DokumanGetir/(\d+)', url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Alternative patterns for different BDDK URL formats
|
||||
# Pattern: /Liste/55 -> use as document ID
|
||||
match = re.search(r'/Liste/(\d+)', url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Pattern: /EkGetir/13?ekId=381 -> use ekId as document ID
|
||||
match = re.search(r'ekId=(\d+)', url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
async def search_decisions(
|
||||
self,
|
||||
request: BddkSearchRequest
|
||||
) -> BddkSearchResult:
|
||||
"""
|
||||
Search for BDDK decisions using Tavily API.
|
||||
|
||||
Args:
|
||||
request: Search request parameters
|
||||
|
||||
Returns:
|
||||
BddkSearchResult with matching decisions
|
||||
"""
|
||||
try:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.tavily_api_key}"
|
||||
}
|
||||
|
||||
# Tavily API request - enhanced for BDDK decision documents
|
||||
query = f"{request.keywords} \"Karar Sayısı\""
|
||||
payload = {
|
||||
"query": query,
|
||||
"country": "turkey",
|
||||
"include_domains": ["https://www.bddk.org.tr/Mevzuat/DokumanGetir"],
|
||||
"max_results": request.pageSize,
|
||||
"search_depth": "advanced"
|
||||
}
|
||||
|
||||
# Calculate offset for pagination
|
||||
if request.page > 1:
|
||||
# Tavily doesn't have direct pagination, so we'll need to handle this
|
||||
# For now, we'll just return empty for pages > 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()
|
||||
|
||||
# Log raw Tavily response for debugging
|
||||
logger.info(f"Tavily returned {len(data.get('results', []))} results")
|
||||
|
||||
# Convert Tavily results to our format
|
||||
decisions = []
|
||||
for result in data.get("results", []):
|
||||
# Extract document ID from URL
|
||||
url = result.get("url", "")
|
||||
logger.debug(f"Processing URL: {url}")
|
||||
doc_id = self._extract_document_id(url)
|
||||
if doc_id:
|
||||
decision = BddkDecisionSummary(
|
||||
title=result.get("title", "").replace("[PDF] ", "").strip(),
|
||||
document_id=doc_id,
|
||||
content=result.get("content", "")[:500] # Limit content length
|
||||
)
|
||||
decisions.append(decision)
|
||||
logger.debug(f"Added decision: {decision.title} (ID: {doc_id})")
|
||||
else:
|
||||
logger.warning(f"Could not extract document ID from URL: {url}")
|
||||
|
||||
return BddkSearchResult(
|
||||
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 BDDK decisions: {e}")
|
||||
if e.response.status_code == 401:
|
||||
raise Exception("Tavily API authentication failed. Check API key.")
|
||||
raise Exception(f"Failed to search BDDK decisions: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching BDDK decisions: {e}")
|
||||
raise Exception(f"Failed to search BDDK decisions: {str(e)}")
|
||||
|
||||
async def get_document_markdown(
|
||||
self,
|
||||
document_id: str,
|
||||
page_number: int = 1
|
||||
) -> BddkDocumentMarkdown:
|
||||
"""
|
||||
Retrieve a BDDK document and convert it to Markdown format.
|
||||
|
||||
Args:
|
||||
document_id: BDDK document ID (e.g., '310')
|
||||
page_number: Page number for paginated content (1-indexed)
|
||||
|
||||
Returns:
|
||||
BddkDocumentMarkdown with paginated content
|
||||
"""
|
||||
try:
|
||||
# Try different URL patterns for BDDK documents
|
||||
potential_urls = [
|
||||
f"https://www.bddk.org.tr/Mevzuat/DokumanGetir/{document_id}",
|
||||
f"https://www.bddk.org.tr/Mevzuat/Liste/{document_id}",
|
||||
f"https://www.bddk.org.tr/KurumHakkinda/EkGetir/13?ekId={document_id}",
|
||||
f"https://www.bddk.org.tr/KurumHakkinda/EkGetir/5?ekId={document_id}"
|
||||
]
|
||||
|
||||
document_url = None
|
||||
response = None
|
||||
|
||||
# Try each URL pattern until one works
|
||||
for url in potential_urls:
|
||||
try:
|
||||
logger.info(f"Trying BDDK document URL: {url}")
|
||||
response = await self.http_client.get(
|
||||
url,
|
||||
follow_redirects=True
|
||||
)
|
||||
response.raise_for_status()
|
||||
document_url = url
|
||||
break
|
||||
except httpx.HTTPStatusError:
|
||||
continue
|
||||
|
||||
if not response or not document_url:
|
||||
raise Exception(f"Could not find document with ID {document_id}")
|
||||
|
||||
logger.info(f"Successfully fetched BDDK document from: {document_url}")
|
||||
|
||||
# Determine content type
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
|
||||
# Convert to Markdown based on content type
|
||||
if "pdf" in content_type:
|
||||
# Handle PDF documents. markitdown is sync; offload to thread
|
||||
# so PDF parsing doesn't block the event-loop / other requests.
|
||||
pdf_stream = io.BytesIO(response.content)
|
||||
result = await asyncio.to_thread(
|
||||
self.markitdown.convert_stream, pdf_stream, file_extension=".pdf"
|
||||
)
|
||||
markdown_content = result.text_content
|
||||
else:
|
||||
# Handle HTML documents (sync conversion offloaded to thread)
|
||||
html_stream = io.BytesIO(response.content)
|
||||
result = await asyncio.to_thread(
|
||||
self.markitdown.convert_stream, html_stream, file_extension=".html"
|
||||
)
|
||||
markdown_content = result.text_content
|
||||
|
||||
# Clean up the markdown content
|
||||
markdown_content = markdown_content.strip()
|
||||
|
||||
# Calculate pagination
|
||||
total_length = len(markdown_content)
|
||||
total_pages = math.ceil(total_length / self.DOCUMENT_MARKDOWN_CHUNK_SIZE)
|
||||
|
||||
# Extract the requested page
|
||||
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 BddkDocumentMarkdown(
|
||||
document_id=document_id,
|
||||
markdown_content=page_content,
|
||||
page_number=page_number,
|
||||
total_pages=total_pages
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"HTTP error fetching BDDK document {document_id}: {e}")
|
||||
raise Exception(f"Failed to fetch BDDK document: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing BDDK document {document_id}: {e}")
|
||||
raise Exception(f"Failed to process BDDK document: {str(e)}")
|
||||
@@ -0,0 +1,43 @@
|
||||
# bddk_mcp_module/models.py
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
|
||||
class BddkSearchRequest(BaseModel):
|
||||
"""
|
||||
Request model for searching BDDK decisions via Tavily API.
|
||||
|
||||
BDDK (Bankacılık Düzenleme ve Denetleme Kurumu) is Turkey's Banking
|
||||
Regulation and Supervision Agency responsible for banking licenses,
|
||||
electronic money institutions, and financial regulations.
|
||||
"""
|
||||
keywords: str = Field(..., description="Search keywords in Turkish")
|
||||
page: int = Field(1, ge=1, description="Page number (1-indexed)")
|
||||
pageSize: int = Field(10, ge=1, le=50, description="Results per page (1-50)")
|
||||
|
||||
class BddkDecisionSummary(BaseModel):
|
||||
"""Summary of a BDDK decision from search results."""
|
||||
title: str = Field(..., description="Decision title")
|
||||
document_id: str = Field(..., description="BDDK document ID (e.g., '310')")
|
||||
content: str = Field(..., description="Decision summary/excerpt")
|
||||
|
||||
class BddkSearchResult(BaseModel):
|
||||
"""Response model for BDDK decision search results."""
|
||||
decisions: List[BddkDecisionSummary] = Field(
|
||||
default_factory=list,
|
||||
description="List of matching BDDK decisions"
|
||||
)
|
||||
total_results: int = Field(0, description="Total number of results")
|
||||
page: int = Field(1, description="Current page number")
|
||||
pageSize: int = Field(10, description="Results per page")
|
||||
|
||||
class BddkDocumentMarkdown(BaseModel):
|
||||
"""
|
||||
BDDK decision document converted to Markdown format.
|
||||
|
||||
Supports paginated content for long documents (5000 chars per page).
|
||||
"""
|
||||
document_id: str = Field(..., description="BDDK document ID")
|
||||
markdown_content: str = Field("", description="Document content in Markdown")
|
||||
page_number: int = Field(1, description="Current page number")
|
||||
total_pages: int = Field(1, description="Total number of pages")
|
||||
@@ -0,0 +1 @@
|
||||
# bedesten_mcp_module/__init__.py
|
||||
@@ -0,0 +1,308 @@
|
||||
# bedesten_mcp_module/client.py
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from markitdown import MarkItDown
|
||||
|
||||
from .models import (
|
||||
BedestenSearchRequest, BedestenSearchResponse,
|
||||
BedestenDocumentRequest, BedestenDocumentResponse,
|
||||
BedestenDocumentMarkdown, BedestenDocumentRequestData
|
||||
)
|
||||
from .enums import get_full_birim_adi
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BedestenRateLimited(Exception):
|
||||
"""Raised when the local rate-limit bucket would block longer than allowed.
|
||||
|
||||
Carries the suggested retry-after (seconds) so callers can surface a
|
||||
structured 429-style response to the MCP client instead of silently
|
||||
blocking the event-loop slot for the full bucket-pause window.
|
||||
"""
|
||||
|
||||
def __init__(self, retry_after: float) -> None:
|
||||
self.retry_after = retry_after
|
||||
super().__init__(f"local bucket would block {retry_after:.1f}s")
|
||||
|
||||
|
||||
class _TokenBucket:
|
||||
"""Asyncio token bucket with explicit back-pressure.
|
||||
|
||||
Measured Bedesten limit (per source IP, 2026-05-08): 10 requests per
|
||||
rolling 30s window with full refill — equivalent to capacity=10,
|
||||
refill_rate=1 token / 3s. Even with margin, 429s still leak through
|
||||
when other clients share the egress IP, so we also expose
|
||||
``penalize_until`` so callers can freeze the bucket when the server
|
||||
actually returns 429 (Retry-After).
|
||||
"""
|
||||
|
||||
def __init__(self, capacity: int, refill_per_s: float) -> None:
|
||||
self.capacity = float(capacity)
|
||||
self.refill_per_s = float(refill_per_s)
|
||||
self._tokens = float(capacity)
|
||||
self._last = time.monotonic()
|
||||
self._not_before = 0.0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def acquire(self, max_wait: Optional[float] = None) -> None:
|
||||
"""Acquire one token. If ``max_wait`` is set and the next wait would
|
||||
exceed it, raise :class:`BedestenRateLimited` immediately instead of
|
||||
sleeping — keeps a single rate-limited request from holding the
|
||||
worker-slot for the full bucket-pause window (up to ~30s on 429)."""
|
||||
deadline = (time.monotonic() + max_wait) if max_wait is not None else None
|
||||
while True:
|
||||
async with self._lock:
|
||||
now = time.monotonic()
|
||||
if now < self._not_before:
|
||||
wait_s = self._not_before - now
|
||||
else:
|
||||
self._tokens = min(
|
||||
self.capacity,
|
||||
self._tokens + (now - self._last) * self.refill_per_s,
|
||||
)
|
||||
self._last = now
|
||||
if self._tokens >= 1.0:
|
||||
self._tokens -= 1.0
|
||||
return
|
||||
wait_s = (1.0 - self._tokens) / self.refill_per_s
|
||||
if deadline is not None:
|
||||
remaining = deadline - time.monotonic()
|
||||
if wait_s > remaining:
|
||||
raise BedestenRateLimited(retry_after=wait_s)
|
||||
await asyncio.sleep(wait_s)
|
||||
|
||||
def penalize_until(self, monotonic_deadline: float) -> None:
|
||||
"""Pause the bucket until ``monotonic_deadline`` (drains tokens)."""
|
||||
self._not_before = max(self._not_before, monotonic_deadline)
|
||||
self._tokens = 0.0
|
||||
self._last = time.monotonic()
|
||||
|
||||
class BedestenApiClient:
|
||||
"""
|
||||
API Client for Bedesten (bedesten.adalet.gov.tr) - Alternative legal decision search system.
|
||||
Currently used for Yargıtay decisions, but can be extended for other court types.
|
||||
"""
|
||||
BASE_URL = "https://bedesten.adalet.gov.tr"
|
||||
SEARCH_ENDPOINT = "/emsal-karar/searchDocuments"
|
||||
DOCUMENT_ENDPOINT = "/emsal-karar/getDocumentContent"
|
||||
|
||||
# Measured limit (per source IP): 10 requests per 30s window with full
|
||||
# refill (≈ 1 token / 3s steady). We default to 1-token capacity and
|
||||
# 3.5s spacing (no burst, ~14% safety margin). Override via env:
|
||||
# BEDESTEN_RATE_CAPACITY (default 1)
|
||||
# BEDESTEN_RATE_REFILL_S (default 3.5; seconds per token)
|
||||
# BEDESTEN_RATE_MAX_WAIT_S (default 8.0; max seconds to wait in the
|
||||
# local bucket before returning a structured 429 to the caller)
|
||||
_DEFAULT_CAPACITY = int(os.getenv("BEDESTEN_RATE_CAPACITY", "1"))
|
||||
_DEFAULT_REFILL_S = float(os.getenv("BEDESTEN_RATE_REFILL_S", "3.5"))
|
||||
_DEFAULT_MAX_WAIT_S = float(os.getenv("BEDESTEN_RATE_MAX_WAIT_S", "8.0"))
|
||||
|
||||
def __init__(self, request_timeout: float = 60.0):
|
||||
self.http_client = httpx.AsyncClient(
|
||||
base_url=self.BASE_URL,
|
||||
headers={
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"AdaletApplicationName": "UyapMevzuat",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Origin": "https://mevzuat.adalet.gov.tr",
|
||||
"Referer": "https://mevzuat.adalet.gov.tr/",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-site",
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
|
||||
},
|
||||
timeout=request_timeout
|
||||
)
|
||||
self._bucket = _TokenBucket(
|
||||
capacity=self._DEFAULT_CAPACITY,
|
||||
refill_per_s=1.0 / self._DEFAULT_REFILL_S,
|
||||
)
|
||||
|
||||
def _handle_429(self, response: httpx.Response, op: str) -> None:
|
||||
"""Apply back-pressure to the shared bucket based on Retry-After."""
|
||||
retry_after_raw = response.headers.get("Retry-After", "")
|
||||
try:
|
||||
retry_after = float(retry_after_raw)
|
||||
except (TypeError, ValueError):
|
||||
retry_after = 30.0
|
||||
# Cap penalty so a hostile/buggy server can't freeze us indefinitely.
|
||||
retry_after = max(1.0, min(retry_after, 60.0))
|
||||
self._bucket.penalize_until(time.monotonic() + retry_after + 0.5)
|
||||
logger.warning(
|
||||
f"BedestenApiClient: 429 on {op}; bucket paused {retry_after + 0.5:.1f}s"
|
||||
)
|
||||
|
||||
async def search_documents(self, search_request: BedestenSearchRequest) -> BedestenSearchResponse:
|
||||
"""
|
||||
Search for documents using Bedesten API.
|
||||
Currently supports: YARGITAYKARARI, DANISTAYKARARI, YERELHUKMAHKARARI, etc.
|
||||
"""
|
||||
logger.info(f"BedestenApiClient: Searching documents with phrase: {search_request.data.phrase}")
|
||||
|
||||
# Map abbreviated birimAdi to full Turkish name before sending to API
|
||||
original_birim_adi = search_request.data.birimAdi
|
||||
mapped_birim_adi = get_full_birim_adi(original_birim_adi)
|
||||
search_request.data.birimAdi = mapped_birim_adi
|
||||
if original_birim_adi != "ALL":
|
||||
logger.info(f"BedestenApiClient: Mapped birimAdi '{original_birim_adi}' to '{mapped_birim_adi}'")
|
||||
|
||||
try:
|
||||
# Create request dict and remove birimAdi if empty
|
||||
request_dict = search_request.model_dump()
|
||||
if not request_dict["data"]["birimAdi"]: # Remove if empty string
|
||||
del request_dict["data"]["birimAdi"]
|
||||
|
||||
await self._bucket.acquire(max_wait=self._DEFAULT_MAX_WAIT_S)
|
||||
response = await self.http_client.post(
|
||||
self.SEARCH_ENDPOINT,
|
||||
json=request_dict
|
||||
)
|
||||
if response.status_code == 429:
|
||||
self._handle_429(response, "search")
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
|
||||
# Parse and return the response
|
||||
return BedestenSearchResponse(**response_json)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"BedestenApiClient: HTTP request error during search: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"BedestenApiClient: Error processing search response: {e}")
|
||||
raise
|
||||
|
||||
async def get_document_as_markdown(self, document_id: str) -> BedestenDocumentMarkdown:
|
||||
"""
|
||||
Get document content and convert to markdown.
|
||||
Handles both HTML (text/html) and PDF (application/pdf) content types.
|
||||
"""
|
||||
logger.info(f"BedestenApiClient: Fetching document for markdown conversion (ID: {document_id})")
|
||||
|
||||
try:
|
||||
# Prepare request
|
||||
doc_request = BedestenDocumentRequest(
|
||||
data=BedestenDocumentRequestData(documentId=document_id)
|
||||
)
|
||||
|
||||
# Get document
|
||||
await self._bucket.acquire(max_wait=self._DEFAULT_MAX_WAIT_S)
|
||||
response = await self.http_client.post(
|
||||
self.DOCUMENT_ENDPOINT,
|
||||
json=doc_request.model_dump()
|
||||
)
|
||||
if response.status_code == 429:
|
||||
self._handle_429(response, f"document {document_id}")
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
doc_response = BedestenDocumentResponse(**response_json)
|
||||
|
||||
# Add null safety checks for document data
|
||||
if not hasattr(doc_response, 'data') or doc_response.data is None:
|
||||
raise ValueError("Document response does not contain data")
|
||||
|
||||
if not hasattr(doc_response.data, 'content') or doc_response.data.content is None:
|
||||
raise ValueError("Document data does not contain content")
|
||||
|
||||
if not hasattr(doc_response.data, 'mimeType') or doc_response.data.mimeType is None:
|
||||
raise ValueError("Document data does not contain mimeType")
|
||||
|
||||
# Decode base64 content with error handling
|
||||
try:
|
||||
content_bytes = base64.b64decode(doc_response.data.content)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to decode base64 content: {str(e)}")
|
||||
|
||||
mime_type = doc_response.data.mimeType
|
||||
|
||||
logger.info(f"BedestenApiClient: Document mime type: {mime_type}")
|
||||
|
||||
# Convert to markdown based on mime type. markitdown is sync and
|
||||
# PDF parsing in particular can block the event-loop for seconds,
|
||||
# which on a single-worker uvicorn deployment stalls every other
|
||||
# in-flight MCP request and new TLS handshakes. Offload to a
|
||||
# thread so the event-loop stays responsive.
|
||||
if mime_type == "text/html":
|
||||
html_content = content_bytes.decode('utf-8')
|
||||
markdown_content = await asyncio.to_thread(
|
||||
self._convert_html_to_markdown, html_content
|
||||
)
|
||||
elif mime_type == "application/pdf":
|
||||
markdown_content = await asyncio.to_thread(
|
||||
self._convert_pdf_to_markdown, content_bytes
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Unsupported mime type: {mime_type}")
|
||||
markdown_content = f"Unsupported content type: {mime_type}. Unable to convert to markdown."
|
||||
|
||||
return BedestenDocumentMarkdown(
|
||||
documentId=document_id,
|
||||
markdown_content=markdown_content,
|
||||
source_url=f"https://mevzuat.adalet.gov.tr/ictihat/{document_id}",
|
||||
mime_type=mime_type
|
||||
)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"BedestenApiClient: HTTP error fetching document {document_id}: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"BedestenApiClient: Error processing document {document_id}: {e}")
|
||||
raise
|
||||
|
||||
def _convert_html_to_markdown(self, html_content: str) -> Optional[str]:
|
||||
"""Convert HTML to Markdown using MarkItDown"""
|
||||
if not html_content:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Convert HTML string to bytes and create BytesIO stream
|
||||
html_bytes = html_content.encode('utf-8')
|
||||
html_stream = io.BytesIO(html_bytes)
|
||||
|
||||
# Pass BytesIO stream to MarkItDown to avoid temp file creation
|
||||
md_converter = MarkItDown()
|
||||
result = md_converter.convert(html_stream)
|
||||
markdown_content = result.text_content
|
||||
|
||||
logger.info("Successfully converted HTML to Markdown")
|
||||
return markdown_content
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error converting HTML to Markdown: {e}")
|
||||
return f"Error converting HTML content: {str(e)}"
|
||||
|
||||
def _convert_pdf_to_markdown(self, pdf_bytes: bytes) -> Optional[str]:
|
||||
"""Convert PDF to Markdown using MarkItDown"""
|
||||
if not pdf_bytes:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Create BytesIO stream from PDF bytes
|
||||
pdf_stream = io.BytesIO(pdf_bytes)
|
||||
|
||||
# Pass BytesIO stream to MarkItDown to avoid temp file creation
|
||||
md_converter = MarkItDown()
|
||||
result = md_converter.convert(pdf_stream)
|
||||
markdown_content = result.text_content
|
||||
|
||||
logger.info("Successfully converted PDF to Markdown")
|
||||
return markdown_content
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error converting PDF to Markdown: {e}")
|
||||
return f"Error converting PDF content: {str(e)}. The document may be corrupted or in an unsupported format."
|
||||
|
||||
async def close_client_session(self):
|
||||
"""Close HTTP client session"""
|
||||
await self.http_client.aclose()
|
||||
logger.info("BedestenApiClient: HTTP client session closed.")
|
||||
@@ -0,0 +1,113 @@
|
||||
# bedesten_mcp_module/enums.py
|
||||
|
||||
from typing import Literal
|
||||
|
||||
# Unified compressed enum for both Yargıtay and Danıştay chambers
|
||||
BirimAdiEnum = Literal[
|
||||
"ALL", # All chambers
|
||||
|
||||
# Yargıtay (Court of Cassation) - Civil Chambers
|
||||
"H1", "H2", "H3", "H4", "H5", "H6", "H7", "H8", "H9", "H10",
|
||||
"H11", "H12", "H13", "H14", "H15", "H16", "H17", "H18", "H19", "H20",
|
||||
"H21", "H22", "H23",
|
||||
|
||||
# Yargıtay - Criminal Chambers
|
||||
"C1", "C2", "C3", "C4", "C5", "C6", "C7", "C8", "C9", "C10",
|
||||
"C11", "C12", "C13", "C14", "C15", "C16", "C17", "C18", "C19", "C20",
|
||||
"C21", "C22", "C23",
|
||||
|
||||
# Yargıtay - Councils and Assemblies
|
||||
"HGK", # Hukuk Genel Kurulu
|
||||
"CGK", # Ceza Genel Kurulu
|
||||
"BGK", # Büyük Genel Kurulu
|
||||
"HBK", # Hukuk Daireleri Başkanlar Kurulu
|
||||
"CBK", # Ceza Daireleri Başkanlar Kurulu
|
||||
|
||||
# Danıştay (Council of State) - Chambers
|
||||
"D1", "D2", "D3", "D4", "D5", "D6", "D7", "D8", "D9", "D10",
|
||||
"D11", "D12", "D13", "D14", "D15", "D16", "D17",
|
||||
|
||||
# Danıştay - Councils and Boards
|
||||
"DBGK", # Büyük Gen.Kur. (Grand General Assembly)
|
||||
"IDDK", # İdare Dava Daireleri Kurulu
|
||||
"VDDK", # Vergi Dava Daireleri Kurulu
|
||||
"IBK", # İçtihatları Birleştirme Kurulu
|
||||
"IIK", # İdari İşler Kurulu
|
||||
"DBK", # Başkanlar Kurulu
|
||||
|
||||
# Military High Administrative Court
|
||||
"AYIM", # Askeri Yüksek İdare Mahkemesi
|
||||
"AYIMDK", # Askeri Yüksek İdare Mahkemesi Daireler Kurulu
|
||||
"AYIMB", # Askeri Yüksek İdare Mahkemesi Başsavcılığı
|
||||
"AYIM1", # Askeri Yüksek İdare Mahkemesi 1. Daire
|
||||
"AYIM2", # Askeri Yüksek İdare Mahkemesi 2. Daire
|
||||
"AYIM3" # Askeri Yüksek İdare Mahkemesi 3. Daire
|
||||
]
|
||||
|
||||
# Mapping from abbreviated values to full Turkish API values
|
||||
BIRIM_ADI_MAPPING = {
|
||||
"ALL": None, # Will be handled specially in client
|
||||
|
||||
# Yargıtay Civil Chambers (1-23)
|
||||
"H1": "1. Hukuk Dairesi", "H2": "2. Hukuk Dairesi", "H3": "3. Hukuk Dairesi",
|
||||
"H4": "4. Hukuk Dairesi", "H5": "5. Hukuk Dairesi", "H6": "6. Hukuk Dairesi",
|
||||
"H7": "7. Hukuk Dairesi", "H8": "8. Hukuk Dairesi", "H9": "9. Hukuk Dairesi",
|
||||
"H10": "10. Hukuk Dairesi", "H11": "11. Hukuk Dairesi", "H12": "12. Hukuk Dairesi",
|
||||
"H13": "13. Hukuk Dairesi", "H14": "14. Hukuk Dairesi", "H15": "15. Hukuk Dairesi",
|
||||
"H16": "16. Hukuk Dairesi", "H17": "17. Hukuk Dairesi", "H18": "18. Hukuk Dairesi",
|
||||
"H19": "19. Hukuk Dairesi", "H20": "20. Hukuk Dairesi", "H21": "21. Hukuk Dairesi",
|
||||
"H22": "22. Hukuk Dairesi", "H23": "23. Hukuk Dairesi",
|
||||
|
||||
# Yargıtay Criminal Chambers (1-23)
|
||||
"C1": "1. Ceza Dairesi", "C2": "2. Ceza Dairesi", "C3": "3. Ceza Dairesi",
|
||||
"C4": "4. Ceza Dairesi", "C5": "5. Ceza Dairesi", "C6": "6. Ceza Dairesi",
|
||||
"C7": "7. Ceza Dairesi", "C8": "8. Ceza Dairesi", "C9": "9. Ceza Dairesi",
|
||||
"C10": "10. Ceza Dairesi", "C11": "11. Ceza Dairesi", "C12": "12. Ceza Dairesi",
|
||||
"C13": "13. Ceza Dairesi", "C14": "14. Ceza Dairesi", "C15": "15. Ceza Dairesi",
|
||||
"C16": "16. Ceza Dairesi", "C17": "17. Ceza Dairesi", "C18": "18. Ceza Dairesi",
|
||||
"C19": "19. Ceza Dairesi", "C20": "20. Ceza Dairesi", "C21": "21. Ceza Dairesi",
|
||||
"C22": "22. Ceza Dairesi", "C23": "23. Ceza Dairesi",
|
||||
|
||||
# Yargıtay Councils and Assemblies
|
||||
"HGK": "Hukuk Genel Kurulu",
|
||||
"CGK": "Ceza Genel Kurulu",
|
||||
"BGK": "Büyük Genel Kurulu",
|
||||
"HBK": "Hukuk Daireleri Başkanlar Kurulu",
|
||||
"CBK": "Ceza Daireleri Başkanlar Kurulu",
|
||||
|
||||
# Danıştay Chambers (1-17)
|
||||
"D1": "1. Daire", "D2": "2. Daire", "D3": "3. Daire", "D4": "4. Daire",
|
||||
"D5": "5. Daire", "D6": "6. Daire", "D7": "7. Daire", "D8": "8. Daire",
|
||||
"D9": "9. Daire", "D10": "10. Daire", "D11": "11. Daire", "D12": "12. Daire",
|
||||
"D13": "13. Daire", "D14": "14. Daire", "D15": "15. Daire", "D16": "16. Daire",
|
||||
"D17": "17. Daire",
|
||||
|
||||
# Danıştay Councils and Boards
|
||||
"DBGK": "Büyük Gen.Kur.",
|
||||
"IDDK": "İdare Dava Daireleri Kurulu",
|
||||
"VDDK": "Vergi Dava Daireleri Kurulu",
|
||||
"IBK": "İçtihatları Birleştirme Kurulu",
|
||||
"IIK": "İdari İşler Kurulu",
|
||||
"DBK": "Başkanlar Kurulu",
|
||||
|
||||
# Military High Administrative Court
|
||||
"AYIM": "Askeri Yüksek İdare Mahkemesi",
|
||||
"AYIMDK": "Askeri Yüksek İdare Mahkemesi Daireler Kurulu",
|
||||
"AYIMB": "Askeri Yüksek İdare Mahkemesi Başsavcılığı",
|
||||
"AYIM1": "Askeri Yüksek İdare Mahkemesi 1. Daire",
|
||||
"AYIM2": "Askeri Yüksek İdare Mahkemesi 2. Daire",
|
||||
"AYIM3": "Askeri Yüksek İdare Mahkemesi 3. Daire"
|
||||
}
|
||||
|
||||
# Helper function to get full Turkish name from abbreviated value
|
||||
def get_full_birim_adi(abbreviated_value: str) -> str:
|
||||
"""Convert abbreviated birimAdi value to full Turkish name for API calls."""
|
||||
if abbreviated_value == "ALL" or not abbreviated_value:
|
||||
return "" # Empty string for ALL or None
|
||||
|
||||
return BIRIM_ADI_MAPPING.get(abbreviated_value, abbreviated_value)
|
||||
|
||||
# Helper function to validate abbreviated value
|
||||
def is_valid_birim_adi(abbreviated_value: str) -> bool:
|
||||
"""Check if abbreviated birimAdi value is valid."""
|
||||
return abbreviated_value in BIRIM_ADI_MAPPING
|
||||
@@ -0,0 +1,91 @@
|
||||
# bedesten_mcp_module/models.py
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any, Literal, Union
|
||||
from datetime import datetime
|
||||
|
||||
# Import compressed BirimAdiEnum for chamber filtering
|
||||
from .enums import BirimAdiEnum
|
||||
|
||||
# Court Type Options for Unified Search
|
||||
BedestenCourtTypeEnum = Literal[
|
||||
"YARGITAYKARARI", # Yargıtay (Court of Cassation)
|
||||
"DANISTAYKARAR", # Danıştay (Council of State)
|
||||
"YERELHUKUK", # Local Civil Courts
|
||||
"ISTINAFHUKUK", # Civil Courts of Appeals
|
||||
"KYB" # Extraordinary Appeals (Kanun Yararına Bozma)
|
||||
]
|
||||
|
||||
# Search Request Models
|
||||
class BedestenSearchData(BaseModel):
|
||||
pageSize: int = Field(..., description="Results per page (1-10)")
|
||||
pageNumber: int = Field(..., description="Page number (1-indexed)")
|
||||
itemTypeList: List[str] = Field(..., description="Court type filter (YARGITAYKARARI/DANISTAYKARAR/YERELHUKUK/ISTINAFHUKUK/KYB)")
|
||||
phrase: str = Field(..., description="Search phrase. Supports: 'word', \"exact phrase\", +required, -exclude, AND/OR/NOT operators. No wildcards or regex.")
|
||||
birimAdi: BirimAdiEnum = Field("ALL", description="""
|
||||
Chamber filter (optional). Abbreviated values with Turkish names:
|
||||
• Yargıtay: H1-H23 (1-23. Hukuk Dairesi), C1-C23 (1-23. Ceza Dairesi), HGK (Hukuk Genel Kurulu), CGK (Ceza Genel Kurulu), BGK (Büyük Genel Kurulu), HBK (Hukuk Daireleri Başkanlar Kurulu), CBK (Ceza Daireleri Başkanlar Kurulu)
|
||||
• Danıştay: D1-D17 (1-17. Daire), DBGK (Büyük Gen.Kur.), IDDK (İdare Dava Daireleri Kurulu), VDDK (Vergi Dava Daireleri Kurulu), IBK (İçtihatları Birleştirme Kurulu), IIK (İdari İşler Kurulu), DBK (Başkanlar Kurulu), AYIM (Askeri Yüksek İdare Mahkemesi), AYIM1-3 (Askeri Yüksek İdare Mahkemesi 1-3. Daire)
|
||||
""")
|
||||
kararTarihiStart: Optional[str] = Field(None, description="Start date (ISO 8601 format)")
|
||||
kararTarihiEnd: Optional[str] = Field(None, description="End date (ISO 8601 format)")
|
||||
sortFields: List[str] = Field(default=["KARAR_TARIHI"], description="Sort fields")
|
||||
sortDirection: str = Field(default="desc", description="Sort direction (asc/desc)")
|
||||
|
||||
class BedestenSearchRequest(BaseModel):
|
||||
data: BedestenSearchData
|
||||
applicationName: str = "UyapMevzuat"
|
||||
paging: bool = True
|
||||
|
||||
# Search Response Models
|
||||
class BedestenItemType(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
|
||||
class BedestenDecisionEntry(BaseModel):
|
||||
documentId: str
|
||||
itemType: BedestenItemType
|
||||
birimId: Optional[str] = None
|
||||
birimAdi: Optional[str]
|
||||
esasNoYil: Optional[int] = None
|
||||
esasNoSira: Optional[int] = None
|
||||
kararNoYil: Optional[int] = None
|
||||
kararNoSira: Optional[int] = None
|
||||
kararTuru: Optional[str] = None
|
||||
kararTarihi: str
|
||||
kararTarihiStr: str
|
||||
kesinlesmeDurumu: Optional[str] = None
|
||||
kararNo: Optional[str] = None
|
||||
esasNo: Optional[str] = None
|
||||
|
||||
class BedestenSearchDataResponse(BaseModel):
|
||||
emsalKararList: List[BedestenDecisionEntry]
|
||||
total: int
|
||||
start: int
|
||||
|
||||
class BedestenSearchResponse(BaseModel):
|
||||
data: Optional[BedestenSearchDataResponse]
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
# Document Request/Response Models
|
||||
class BedestenDocumentRequestData(BaseModel):
|
||||
documentId: str
|
||||
|
||||
class BedestenDocumentRequest(BaseModel):
|
||||
data: BedestenDocumentRequestData
|
||||
applicationName: str = "UyapMevzuat"
|
||||
|
||||
class BedestenDocumentData(BaseModel):
|
||||
content: str # Base64 encoded HTML or PDF
|
||||
mimeType: str
|
||||
version: int
|
||||
|
||||
class BedestenDocumentResponse(BaseModel):
|
||||
data: BedestenDocumentData
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
class BedestenDocumentMarkdown(BaseModel):
|
||||
documentId: str = Field(..., description="The document ID (Belge Kimliği) from Bedesten")
|
||||
markdown_content: Optional[str] = Field(None, description="The decision content (Karar İçeriği) converted to Markdown")
|
||||
source_url: str = Field(..., description="The source URL (Kaynak URL) of the document")
|
||||
mime_type: Optional[str] = Field(None, description="Original content type (İçerik Türü) (text/html or application/pdf)")
|
||||
@@ -0,0 +1,17 @@
|
||||
# btk_mcp_module/__init__.py
|
||||
|
||||
from .client import BtkApiClient
|
||||
from .models import (
|
||||
BtkDocumentMarkdown,
|
||||
BtkDecisionSummary,
|
||||
BtkSearchRequest,
|
||||
BtkSearchResult,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BtkApiClient",
|
||||
"BtkDocumentMarkdown",
|
||||
"BtkDecisionSummary",
|
||||
"BtkSearchRequest",
|
||||
"BtkSearchResult",
|
||||
]
|
||||
@@ -0,0 +1,206 @@
|
||||
# btk_mcp_module/client.py
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from markitdown import MarkItDown
|
||||
from pydantic import HttpUrl
|
||||
|
||||
from .models import (
|
||||
BtkDecisionSummary,
|
||||
BtkDocumentMarkdown,
|
||||
BtkSearchRequest,
|
||||
BtkSearchResult,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if not logger.hasHandlers():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
|
||||
class BtkApiClient:
|
||||
"""Client for BTK (Information and Communication Technologies Authority) decisions."""
|
||||
|
||||
BASE_URL = "https://www.btk.tr"
|
||||
API_PATH = "/api/content/board-decisions"
|
||||
DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000
|
||||
|
||||
def __init__(self, request_timeout: float = 60.0):
|
||||
self.http_client = httpx.AsyncClient(
|
||||
base_url=self.BASE_URL,
|
||||
headers={
|
||||
"Accept": "application/json,text/html,application/xhtml+xml,application/xml;q=0.9,*/*;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,
|
||||
)
|
||||
self.markitdown = MarkItDown(enable_plugins=False)
|
||||
|
||||
def _build_search_params(self, request: BtkSearchRequest) -> Dict[str, str]:
|
||||
params: Dict[str, str] = {
|
||||
"page": str(request.page),
|
||||
"limit": str(request.pageSize),
|
||||
"locale": "tr",
|
||||
}
|
||||
|
||||
if request.keywords.strip():
|
||||
params["search"] = request.keywords.strip()
|
||||
if request.decision_no.strip():
|
||||
params["filter[decision_no]"] = request.decision_no.strip()
|
||||
if request.decision_date.strip():
|
||||
params["filter[decision_date]"] = request.decision_date.strip()
|
||||
if request.publication_date.strip():
|
||||
params["date_from"] = request.publication_date.strip()
|
||||
params["date_to"] = request.publication_date.strip()
|
||||
if request.relevant_unit.strip():
|
||||
params["filter[relevant_unit]"] = request.relevant_unit.strip()
|
||||
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def _format_date(value: Optional[str]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
normalized = value.replace("Z", "+00:00")
|
||||
try:
|
||||
return datetime.fromisoformat(normalized).date().isoformat()
|
||||
except ValueError:
|
||||
return value[:10] if len(value) >= 10 else value
|
||||
|
||||
@staticmethod
|
||||
def _extract_pdf_url(file_data: Any) -> Optional[str]:
|
||||
if not isinstance(file_data, dict):
|
||||
return None
|
||||
for key in ("url", "storageUrl"):
|
||||
value = file_data.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
def _parse_decision(self, item: Dict[str, Any]) -> BtkDecisionSummary:
|
||||
data = item.get("data") if isinstance(item.get("data"), dict) else {}
|
||||
file_data = data.get("file_url") if isinstance(data.get("file_url"), dict) else {}
|
||||
pdf_url = self._extract_pdf_url(file_data)
|
||||
|
||||
return BtkDecisionSummary(
|
||||
id=str(item.get("id") or ""),
|
||||
title=str(item.get("title") or ""),
|
||||
slug=str(item.get("slug") or ""),
|
||||
decision_no=data.get("decision_no"),
|
||||
decision_date=self._format_date(data.get("decision_date")),
|
||||
publication_date=self._format_date(item.get("publishedAt")),
|
||||
relevant_unit=data.get("relevant_unit"),
|
||||
pdf_url=HttpUrl(pdf_url) if pdf_url else None,
|
||||
original_filename=file_data.get("originalFilename") or file_data.get("filename"),
|
||||
)
|
||||
|
||||
async def search_decisions(self, request: BtkSearchRequest) -> BtkSearchResult:
|
||||
params = self._build_search_params(request)
|
||||
query_string = urlencode(params, doseq=True)
|
||||
query_url = f"{self.BASE_URL}{self.API_PATH}?{query_string}"
|
||||
logger.info("BtkApiClient: searching BTK decisions with URL: %s", query_url)
|
||||
|
||||
try:
|
||||
response = await self.http_client.get(self.API_PATH, params=params)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except Exception as e:
|
||||
logger.error("BtkApiClient: error searching decisions: %s", e, exc_info=True)
|
||||
raise Exception(f"Failed to search BTK decisions: {str(e)}")
|
||||
|
||||
raw_items = payload.get("data") if isinstance(payload, dict) else []
|
||||
decisions = [
|
||||
self._parse_decision(item)
|
||||
for item in raw_items
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
meta = payload.get("meta") if isinstance(payload.get("meta"), dict) else {}
|
||||
|
||||
return BtkSearchResult(
|
||||
decisions=decisions,
|
||||
total_results=int(meta.get("total") or len(decisions)),
|
||||
page=int(meta.get("page") or request.page),
|
||||
pageSize=int(meta.get("limit") or request.pageSize),
|
||||
total_pages=int(meta.get("totalPages") or 0),
|
||||
query_url=query_url,
|
||||
)
|
||||
|
||||
def _convert_pdf_to_markdown(self, pdf_bytes: bytes) -> str:
|
||||
pdf_stream = io.BytesIO(pdf_bytes)
|
||||
result = self.markitdown.convert_stream(pdf_stream, file_extension=".pdf")
|
||||
return (result.text_content or "").strip()
|
||||
|
||||
async def get_document_markdown(self, pdf_url: str, page_number: int = 1) -> BtkDocumentMarkdown:
|
||||
if not pdf_url or not pdf_url.strip():
|
||||
return BtkDocumentMarkdown(
|
||||
source_url=HttpUrl(f"{self.BASE_URL}/kurul-kararlari"),
|
||||
markdown_chunk=None,
|
||||
current_page=max(1, page_number),
|
||||
total_pages=0,
|
||||
is_paginated=False,
|
||||
error_message="pdf_url is required.",
|
||||
)
|
||||
|
||||
pdf_url = pdf_url.strip()
|
||||
if not pdf_url.startswith(("https://www.btk.gov.tr/", "https://www.btk.tr/")):
|
||||
return BtkDocumentMarkdown(
|
||||
source_url=HttpUrl(pdf_url),
|
||||
markdown_chunk=None,
|
||||
current_page=max(1, page_number),
|
||||
total_pages=0,
|
||||
is_paginated=False,
|
||||
error_message="Invalid BTK document URL. URL must start with https://www.btk.gov.tr/ or https://www.btk.tr/.",
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.http_client.get(pdf_url)
|
||||
response.raise_for_status()
|
||||
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
if "pdf" not in content_type and not pdf_url.lower().endswith(".pdf"):
|
||||
raise Exception(f"Expected a PDF document, got content type: {content_type}")
|
||||
|
||||
markdown_content = await asyncio.to_thread(self._convert_pdf_to_markdown, response.content)
|
||||
total_pages = max(1, math.ceil(len(markdown_content) / self.DOCUMENT_MARKDOWN_CHUNK_SIZE))
|
||||
current_page = max(1, min(page_number, total_pages))
|
||||
start_index = (current_page - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE
|
||||
end_index = start_index + self.DOCUMENT_MARKDOWN_CHUNK_SIZE
|
||||
|
||||
return BtkDocumentMarkdown(
|
||||
source_url=HttpUrl(pdf_url),
|
||||
markdown_chunk=markdown_content[start_index:end_index],
|
||||
current_page=current_page,
|
||||
total_pages=total_pages,
|
||||
is_paginated=total_pages > 1,
|
||||
error_message=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("BtkApiClient: error retrieving BTK PDF %s: %s", pdf_url, e, exc_info=True)
|
||||
return BtkDocumentMarkdown(
|
||||
source_url=HttpUrl(pdf_url),
|
||||
markdown_chunk=None,
|
||||
current_page=max(1, page_number),
|
||||
total_pages=0,
|
||||
is_paginated=False,
|
||||
error_message=f"Failed to retrieve BTK document: {str(e)}",
|
||||
)
|
||||
|
||||
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("BtkApiClient: HTTP client session closed.")
|
||||
@@ -0,0 +1,58 @@
|
||||
# btk_mcp_module/models.py
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
|
||||
|
||||
class BtkSearchRequest(BaseModel):
|
||||
"""Request model for searching BTK Board decisions."""
|
||||
|
||||
keywords: str = Field("", description="Keywords searched in decision title/content metadata.")
|
||||
decision_no: str = Field("", description="BTK decision number, e.g. 2026/DK-THD/91.")
|
||||
decision_date: str = Field("", description="Decision date as YYYY-MM-DD.")
|
||||
publication_date: str = Field("", description="Publication date as YYYY-MM-DD.")
|
||||
relevant_unit: str = Field("", description="Related BTK department name.")
|
||||
page: int = Field(1, ge=1, description="Page number for results.")
|
||||
pageSize: int = Field(10, ge=1, le=50, description="Results per page.")
|
||||
|
||||
|
||||
class BtkDecisionSummary(BaseModel):
|
||||
"""Summary of a BTK Board decision from search results."""
|
||||
|
||||
id: str = Field("", description="BTK content ID.")
|
||||
title: str = Field("", description="Decision title.")
|
||||
slug: str = Field("", description="BTK content slug.")
|
||||
decision_no: Optional[str] = Field(None, description="Decision number.")
|
||||
decision_date: Optional[str] = Field(None, description="Decision date.")
|
||||
publication_date: Optional[str] = Field(None, description="Publication date.")
|
||||
relevant_unit: Optional[str] = Field(None, description="Related BTK department.")
|
||||
pdf_url: Optional[HttpUrl] = Field(None, description="Direct URL of the decision PDF.")
|
||||
original_filename: Optional[str] = Field(None, description="Original PDF filename when available.")
|
||||
|
||||
|
||||
class BtkSearchResult(BaseModel):
|
||||
"""Response model for BTK Board decision search results."""
|
||||
|
||||
decisions: List[BtkDecisionSummary] = Field(default_factory=list)
|
||||
total_results: int = Field(0, description="Total number of matching results.")
|
||||
page: int = Field(1, description="Current page.")
|
||||
pageSize: int = Field(10, description="Results per page.")
|
||||
total_pages: int = Field(0, description="Total result pages.")
|
||||
query_url: str = Field("", description="BTK API URL used for the search.")
|
||||
|
||||
|
||||
class BtkDocumentMarkdown(BaseModel):
|
||||
"""BTK decision PDF converted to paginated Markdown."""
|
||||
|
||||
source_url: HttpUrl = Field(description="Source PDF URL.")
|
||||
markdown_chunk: Optional[str] = Field(None, description="A chunk of the Markdown content.")
|
||||
current_page: int = Field(1, description="Current Markdown chunk page.")
|
||||
total_pages: int = Field(1, description="Total Markdown chunk pages.")
|
||||
is_paginated: bool = Field(False, description="True when content spans multiple chunks.")
|
||||
error_message: Optional[str] = Field(None, description="Error message, if retrieval failed.")
|
||||
|
||||
class Config:
|
||||
json_encoders = {
|
||||
HttpUrl: str
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
from fastmcp import Client
|
||||
from mcp_server_main import app
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
async def check_response_format():
|
||||
client = Client(app)
|
||||
async with client:
|
||||
result = await client.call_tool('search_bedesten_unified', {
|
||||
'phrase': 'mülkiyet',
|
||||
'court_types': ['YARGITAYKARARI'],
|
||||
'birimAdi': 'H1',
|
||||
'pageSize': 3
|
||||
})
|
||||
if result and result.content:
|
||||
data = json.loads(result.content[0].text)
|
||||
print('Response keys:', list(data.keys()))
|
||||
print('Sample response:', json.dumps(data, indent=2, ensure_ascii=False)[:500])
|
||||
|
||||
asyncio.run(check_response_format())
|
||||
@@ -1,13 +1,13 @@
|
||||
# danistay_mcp_module/client.py
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
from typing import Dict, Any, List, Optional
|
||||
import logging
|
||||
import html
|
||||
import re
|
||||
import tempfile
|
||||
import os
|
||||
import io
|
||||
from markitdown import MarkItDown
|
||||
|
||||
from .models import (
|
||||
@@ -35,8 +35,6 @@ class DanistayApiClient:
|
||||
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,
|
||||
@@ -44,7 +42,7 @@ class DanistayApiClient:
|
||||
)
|
||||
|
||||
def _prepare_keywords_for_api(self, keywords: List[str]) -> List[str]:
|
||||
return [f'"{k.strip("\"")}"' for k in keywords if k and k.strip()]
|
||||
return ['"' + k.strip('"') + '"' for k in keywords if k and k.strip()]
|
||||
|
||||
async def search_keyword_decisions(
|
||||
self,
|
||||
@@ -79,12 +77,16 @@ class DanistayApiClient:
|
||||
mevzuatNumarasi=params.mevzuatNumarasi or "",
|
||||
mevzuatAdi=params.mevzuatAdi or "",
|
||||
madde=params.madde or "",
|
||||
siralama=params.siralama,
|
||||
siralamaDirection=params.siralamaDirection,
|
||||
siralama="1",
|
||||
siralamaDirection="desc",
|
||||
pageSize=params.pageSize,
|
||||
pageNumber=params.pageNumber
|
||||
)
|
||||
final_payload = {"data": data_for_payload.model_dump(exclude_defaults=False, exclude_none=False)}
|
||||
# Create request dict and remove empty string fields to avoid API issues
|
||||
payload_dict = data_for_payload.model_dump(exclude_defaults=False, exclude_none=False)
|
||||
# Remove empty string fields that might cause API issues
|
||||
cleaned_payload = {k: v for k, v in payload_dict.items() if v != ""}
|
||||
final_payload = {"data": cleaned_payload}
|
||||
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)
|
||||
|
||||
@@ -126,33 +128,30 @@ class DanistayApiClient:
|
||||
html_input_for_markdown = processed_html
|
||||
|
||||
markdown_text = None
|
||||
temp_file_path = None
|
||||
try:
|
||||
md_converter = MarkItDown(enable_plugins=False) # Basic conversion
|
||||
# Convert HTML string to bytes and create BytesIO stream
|
||||
html_bytes = html_input_for_markdown.encode('utf-8')
|
||||
html_stream = io.BytesIO(html_bytes)
|
||||
|
||||
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)
|
||||
# Pass BytesIO stream to MarkItDown to avoid temp file creation
|
||||
md_converter = MarkItDown()
|
||||
conversion_result = md_converter.convert(html_stream)
|
||||
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:
|
||||
async def get_decision_document_as_markdown(self, 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.
|
||||
The /getDokuman endpoint for Danıştay requires arananKelime parameter.
|
||||
"""
|
||||
document_api_url = f"{self.DOCUMENT_ENDPOINT}?id={document_id}"
|
||||
# Add required arananKelime parameter - using empty string as minimum requirement
|
||||
document_api_url = f"{self.DOCUMENT_ENDPOINT}?id={id}&arananKelime="
|
||||
source_url = f"{self.BASE_URL}{document_api_url}"
|
||||
logger.info(f"DanistayApiClient: Fetching Danistay document for Markdown (ID: {document_id}) from {source_url}")
|
||||
logger.info(f"DanistayApiClient: Fetching Danistay document for Markdown (ID: {id}) from {source_url}")
|
||||
|
||||
try:
|
||||
# For direct HTML response, we might want different headers if the API is sensitive,
|
||||
@@ -164,27 +163,27 @@ class DanistayApiClient:
|
||||
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}.")
|
||||
logger.warning(f"DanistayApiClient: Received empty or non-string HTML content for ID {id}.")
|
||||
# Return with None markdown_content if HTML is effectively empty
|
||||
return DanistayDocumentMarkdown(
|
||||
document_id=document_id,
|
||||
id=id,
|
||||
markdown_content=None,
|
||||
source_url=source_url
|
||||
)
|
||||
|
||||
markdown_content = self._convert_html_to_markdown_danistay(html_content_from_api)
|
||||
markdown_content = await asyncio.to_thread(self._convert_html_to_markdown_danistay, html_content_from_api)
|
||||
|
||||
return DanistayDocumentMarkdown(
|
||||
document_id=document_id,
|
||||
id=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}")
|
||||
logger.error(f"DanistayApiClient: HTTP error fetching Danistay document (ID: {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}")
|
||||
logger.error(f"DanistayApiClient: General error processing Danistay document (ID: {id}): {e}")
|
||||
raise
|
||||
|
||||
async def close_client_session(self):
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# danistay_mcp_module/models.py
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
from pydantic import BaseModel, Field, HttpUrl, ConfigDict
|
||||
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)
|
||||
pageSize: int = Field(default=10, ge=1, le=10)
|
||||
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.
|
||||
@@ -21,11 +21,11 @@ class DanistayKeywordSearchRequestData(BaseModel):
|
||||
|
||||
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)
|
||||
andKelimeler: List[str] = Field(default_factory=list, description="AND keywords")
|
||||
orKelimeler: List[str] = Field(default_factory=list, description="OR keywords")
|
||||
notAndKelimeler: List[str] = Field(default_factory=list, description="NOT AND keywords")
|
||||
notOrKelimeler: List[str] = Field(default_factory=list, description="NOT OR keywords")
|
||||
pageSize: int = Field(default=10, ge=1, le=10)
|
||||
pageNumber: int = Field(default=1, ge=1)
|
||||
|
||||
class DanistayDetailedSearchRequestData(BaseModel): # Internal data model for detailed search payload
|
||||
@@ -51,20 +51,18 @@ class DanistayDetailedSearchRequestData(BaseModel): # Internal data model for de
|
||||
|
||||
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').")
|
||||
daire: str = Field("", description="Chamber")
|
||||
esasYil: str = Field("", description="Case year")
|
||||
esasIlkSiraNo: str = Field("", description="Start case no")
|
||||
esasSonSiraNo: str = Field("", description="End case no")
|
||||
kararYil: str = Field("", description="Decision year")
|
||||
kararIlkSiraNo: str = Field("", description="Start decision no")
|
||||
kararSonSiraNo: str = Field("", description="End decision no")
|
||||
baslangicTarihi: str = Field("", description="Start date")
|
||||
bitisTarihi: str = Field("", description="End date")
|
||||
mevzuatNumarasi: str = Field("", description="Law number")
|
||||
mevzuatAdi: str = Field("", description="Law name")
|
||||
madde: str = Field("", description="Article")
|
||||
# Add a general keyword field if detailed search also supports it
|
||||
# arananKelime: Optional[str] = Field(None, description="General keyword for detailed search.")
|
||||
|
||||
@@ -76,36 +74,34 @@ class DanistayApiDecisionEntry(BaseModel):
|
||||
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.")
|
||||
chamber: str = Field("", alias="daire", description="Chamber")
|
||||
esasNo: str = Field("", description="Case number")
|
||||
kararNo: str = Field("", description="Decision number")
|
||||
kararTarihi: str = Field("", description="Decision date")
|
||||
arananKelime: str = Field("", description="Keyword")
|
||||
# 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.")
|
||||
document_url: Optional[HttpUrl] = Field(None, description="Document URL")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True # Important for alias to work
|
||||
extra = 'ignore' # Ignore any extra fields from API not defined in model
|
||||
model_config = ConfigDict(populate_by_name=True, extra='ignore') # Important for alias to work and ignore extra fields
|
||||
|
||||
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.")
|
||||
draw: int = Field(0, description="Draw counter")
|
||||
|
||||
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.")
|
||||
data: Optional[DanistayApiResponseInnerData] = Field(None, description="Response data, can be null when no results found")
|
||||
metadata: Optional[Dict[str, Any]] = Field(None, description="Optional metadata (Meta Veri) 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.")
|
||||
id: str
|
||||
markdown_content: str = Field("", description="The decision content (Karar İçeriği) converted to Markdown.")
|
||||
source_url: HttpUrl
|
||||
|
||||
class CompactDanistaySearchResult(BaseModel):
|
||||
|
||||
+127
-23
@@ -1,13 +1,15 @@
|
||||
# emsal_mcp_module/client.py
|
||||
|
||||
import asyncio
|
||||
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
|
||||
import re
|
||||
import io
|
||||
import time
|
||||
from markitdown import MarkItDown
|
||||
|
||||
from .models import (
|
||||
@@ -21,12 +23,87 @@ logger = logging.getLogger(__name__)
|
||||
if not logger.hasHandlers():
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
|
||||
|
||||
class EmsalRateLimited(Exception):
|
||||
"""Raised when the local rate-limit bucket would block longer than allowed.
|
||||
|
||||
Carries the suggested retry-after (seconds) so callers can surface a
|
||||
structured 429-style response instead of silently blocking the
|
||||
event-loop slot for the full bucket-pause window.
|
||||
"""
|
||||
|
||||
def __init__(self, retry_after: float) -> None:
|
||||
self.retry_after = retry_after
|
||||
super().__init__(f"local bucket would block {retry_after:.1f}s")
|
||||
|
||||
|
||||
class _TokenBucket:
|
||||
"""Asyncio token bucket with explicit back-pressure.
|
||||
|
||||
The UYAP Emsal endpoint (emsal.uyap.gov.tr) rate-limits per source IP and
|
||||
returns HTTP 429 (an HTML error page, no Retry-After header) after a small
|
||||
burst of rapid requests. On the shared-egress-IP production deployment this
|
||||
is hit constantly, making unrelated searches appear to "return 0 results"
|
||||
depending only on request order. This bucket spaces requests to a safe rate
|
||||
and freezes on an actual 429 via ``penalize_until``.
|
||||
"""
|
||||
|
||||
def __init__(self, capacity: int, refill_per_s: float) -> None:
|
||||
self.capacity = float(capacity)
|
||||
self.refill_per_s = float(refill_per_s)
|
||||
self._tokens = float(capacity)
|
||||
self._last = time.monotonic()
|
||||
self._not_before = 0.0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def acquire(self, max_wait: Optional[float] = None) -> None:
|
||||
"""Acquire one token. If ``max_wait`` is set and the next wait would
|
||||
exceed it, raise :class:`EmsalRateLimited` immediately instead of
|
||||
sleeping — keeps a single rate-limited request from holding the
|
||||
worker-slot for the full bucket-pause window."""
|
||||
deadline = (time.monotonic() + max_wait) if max_wait is not None else None
|
||||
while True:
|
||||
async with self._lock:
|
||||
now = time.monotonic()
|
||||
if now < self._not_before:
|
||||
wait_s = self._not_before - now
|
||||
else:
|
||||
self._tokens = min(
|
||||
self.capacity,
|
||||
self._tokens + (now - self._last) * self.refill_per_s,
|
||||
)
|
||||
self._last = now
|
||||
if self._tokens >= 1.0:
|
||||
self._tokens -= 1.0
|
||||
return
|
||||
wait_s = (1.0 - self._tokens) / self.refill_per_s
|
||||
if deadline is not None:
|
||||
remaining = deadline - time.monotonic()
|
||||
if wait_s > remaining:
|
||||
raise EmsalRateLimited(retry_after=wait_s)
|
||||
await asyncio.sleep(wait_s)
|
||||
|
||||
def penalize_until(self, monotonic_deadline: float) -> None:
|
||||
"""Pause the bucket until ``monotonic_deadline`` (drains tokens)."""
|
||||
self._not_before = max(self._not_before, monotonic_deadline)
|
||||
self._tokens = 0.0
|
||||
self._last = time.monotonic()
|
||||
|
||||
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"
|
||||
|
||||
# UYAP Emsal rate-limits per source IP. Defaults mirror the sibling
|
||||
# Bedesten client (conservative: no burst, ~3.5s spacing). Override via env:
|
||||
# EMSAL_RATE_CAPACITY (default 1)
|
||||
# EMSAL_RATE_REFILL_S (default 3.5; seconds per token)
|
||||
# EMSAL_RATE_MAX_WAIT_S (default 8.0; max local wait before a structured 429)
|
||||
_DEFAULT_CAPACITY = int(os.getenv("EMSAL_RATE_CAPACITY", "1"))
|
||||
_DEFAULT_REFILL_S = float(os.getenv("EMSAL_RATE_REFILL_S", "3.5"))
|
||||
_DEFAULT_MAX_WAIT_S = float(os.getenv("EMSAL_RATE_MAX_WAIT_S", "8.0"))
|
||||
|
||||
def __init__(self, request_timeout: float = 30.0):
|
||||
self.http_client = httpx.AsyncClient(
|
||||
base_url=self.BASE_URL,
|
||||
@@ -38,6 +115,27 @@ class EmsalApiClient:
|
||||
timeout=request_timeout,
|
||||
verify=False # As per user's original FastAPI code
|
||||
)
|
||||
self._bucket = _TokenBucket(
|
||||
capacity=self._DEFAULT_CAPACITY,
|
||||
refill_per_s=1.0 / self._DEFAULT_REFILL_S,
|
||||
)
|
||||
|
||||
def _handle_429(self, response: httpx.Response, op: str) -> None:
|
||||
"""Apply back-pressure to the shared bucket based on Retry-After.
|
||||
|
||||
Emsal returns 429 as an HTML error page with no Retry-After header, so
|
||||
the 30s fallback almost always applies."""
|
||||
retry_after_raw = response.headers.get("Retry-After", "")
|
||||
try:
|
||||
retry_after = float(retry_after_raw)
|
||||
except (TypeError, ValueError):
|
||||
retry_after = 30.0
|
||||
# Cap penalty so a hostile/buggy server can't freeze us indefinitely.
|
||||
retry_after = max(1.0, min(retry_after, 60.0))
|
||||
self._bucket.penalize_until(time.monotonic() + retry_after + 0.5)
|
||||
logger.warning(
|
||||
f"EmsalApiClient: 429 on {op}; bucket paused {retry_after + 0.5:.1f}s"
|
||||
)
|
||||
|
||||
async def search_detailed_decisions(
|
||||
self,
|
||||
@@ -64,7 +162,11 @@ class EmsalApiClient:
|
||||
pageNumber=params.page_number
|
||||
)
|
||||
|
||||
final_payload = {"data": data_for_api_payload.model_dump(by_alias=True, exclude_none=True)}
|
||||
# Create request dict and remove empty string fields to avoid API issues
|
||||
payload_dict = data_for_api_payload.model_dump(by_alias=True, exclude_none=True)
|
||||
# Remove empty string fields that might cause API issues
|
||||
cleaned_payload = {k: v for k, v in payload_dict.items() if v != ""}
|
||||
final_payload = {"data": cleaned_payload}
|
||||
|
||||
logger.info(f"EmsalApiClient: Performing DETAILED search with payload: {final_payload}")
|
||||
return await self._execute_api_search(self.DETAILED_SEARCH_ENDPOINT, final_payload)
|
||||
@@ -72,7 +174,10 @@ class EmsalApiClient:
|
||||
async def _execute_api_search(self, endpoint: str, payload: Dict) -> EmsalApiResponse:
|
||||
"""Helper method to execute search POST request and process response for Emsal."""
|
||||
try:
|
||||
await self._bucket.acquire(max_wait=self._DEFAULT_MAX_WAIT_S)
|
||||
response = await self.http_client.post(endpoint, json=payload)
|
||||
if response.status_code == 429:
|
||||
self._handle_429(response, "search")
|
||||
response.raise_for_status()
|
||||
response_json_data = response.json()
|
||||
logger.debug(f"EmsalApiClient: Raw API response from {endpoint}: {response_json_data}")
|
||||
@@ -114,36 +219,35 @@ class EmsalApiClient:
|
||||
html_input_for_markdown = content
|
||||
|
||||
markdown_text = None
|
||||
temp_file_path = None
|
||||
try:
|
||||
md_converter = MarkItDown(enable_plugins=False)
|
||||
# Convert HTML string to bytes and create BytesIO stream
|
||||
html_bytes = html_input_for_markdown.encode('utf-8')
|
||||
html_stream = io.BytesIO(html_bytes)
|
||||
|
||||
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)
|
||||
# Pass BytesIO stream to MarkItDown to avoid temp file creation
|
||||
md_converter = MarkItDown()
|
||||
conversion_result = md_converter.convert(html_stream)
|
||||
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:
|
||||
async def get_decision_document_as_markdown(self, 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}"
|
||||
document_api_url = f"{self.DOCUMENT_ENDPOINT}?id={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}")
|
||||
logger.info(f"EmsalApiClient: Fetching Emsal document for Markdown (ID: {id}) from {source_url}")
|
||||
|
||||
try:
|
||||
await self._bucket.acquire(max_wait=self._DEFAULT_MAX_WAIT_S)
|
||||
response = await self.http_client.get(document_api_url)
|
||||
if response.status_code == 429:
|
||||
self._handle_429(response, f"document {id}")
|
||||
response.raise_for_status()
|
||||
|
||||
# Emsal /getDokuman returns JSON with HTML in 'data' field (confirmed by user example)
|
||||
@@ -151,24 +255,24 @@ class EmsalApiClient:
|
||||
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)
|
||||
logger.warning(f"EmsalApiClient: Received empty or non-string HTML in 'data' field for Emsal ID {id}.")
|
||||
return EmsalDocumentMarkdown(id=id, markdown_content=None, source_url=source_url)
|
||||
|
||||
markdown_content = self._clean_html_and_convert_to_markdown_emsal(html_content_from_api)
|
||||
markdown_content = await asyncio.to_thread(self._clean_html_and_convert_to_markdown_emsal, html_content_from_api)
|
||||
|
||||
return EmsalDocumentMarkdown(
|
||||
document_id=document_id,
|
||||
id=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}")
|
||||
logger.error(f"EmsalApiClient: HTTP error fetching Emsal document (ID: {id}): {e}")
|
||||
raise
|
||||
except ValueError as e:
|
||||
logger.error(f"EmsalApiClient: ValueError processing Emsal document response (ID: {document_id}): {e}")
|
||||
logger.error(f"EmsalApiClient: ValueError processing Emsal document response (ID: {id}): {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"EmsalApiClient: General error processing Emsal document (ID: {document_id}): {e}")
|
||||
logger.error(f"EmsalApiClient: General error processing Emsal document (ID: {id}): {e}")
|
||||
raise
|
||||
|
||||
async def close_client_session(self):
|
||||
|
||||
+33
-36
@@ -1,6 +1,6 @@
|
||||
# emsal_mcp_module/models.py
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
from pydantic import BaseModel, Field, HttpUrl, ConfigDict
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
class EmsalDetailedSearchRequestData(BaseModel):
|
||||
@@ -12,12 +12,12 @@ class EmsalDetailedSearchRequestData(BaseModel):
|
||||
"""
|
||||
arananKelime: Optional[str] = ""
|
||||
|
||||
Bam_Hukuk_Mahkemeleri: Optional[str] = Field(None, alias="Bam Hukuk Mahkemeleri")
|
||||
Hukuk_Mahkemeleri: Optional[str] = Field(None, alias="Hukuk Mahkemeleri")
|
||||
Bam_Hukuk_Mahkemeleri: str = Field("", alias="Bam Hukuk Mahkemeleri")
|
||||
Hukuk_Mahkemeleri: str = Field("", 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.")
|
||||
birimHukukMah: Optional[str] = Field("", description="Regional chambers (+ separated)")
|
||||
|
||||
esasYil: Optional[str] = ""
|
||||
esasIlkSiraNo: Optional[str] = ""
|
||||
@@ -32,68 +32,65 @@ class EmsalDetailedSearchRequestData(BaseModel):
|
||||
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
|
||||
model_config = ConfigDict(populate_by_name=True) # Enables use of alias in serialization (when dumping to dict for payload)
|
||||
|
||||
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.")
|
||||
keyword: str = Field("", description="Keyword")
|
||||
|
||||
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 '+').")
|
||||
selected_bam_civil_court: str = Field("", description="BAM Civil Court")
|
||||
selected_civil_court: str = Field("", description="Civil Court")
|
||||
selected_regional_civil_chambers: List[str] = Field(default_factory=list, description="Regional chambers")
|
||||
|
||||
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'.")
|
||||
case_year_esas: str = Field("", description="Case year")
|
||||
case_start_seq_esas: str = Field("", description="Start case no")
|
||||
case_end_seq_esas: str = Field("", description="End case 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'.")
|
||||
decision_year_karar: str = Field("", description="Decision year")
|
||||
decision_start_seq_karar: str = Field("", description="Start decision no")
|
||||
decision_end_seq_karar: str = Field("", description="End decision 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).")
|
||||
start_date: str = Field("", description="Start date (DD.MM.YYYY)")
|
||||
end_date: str = Field("", description="End date (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').")
|
||||
sort_criteria: str = Field("1", description="Sort by")
|
||||
sort_direction: str = Field("desc", description="Direction")
|
||||
|
||||
page_number: int = Field(default=1, ge=1)
|
||||
page_size: int = Field(default=10, ge=1, le=100)
|
||||
page_size: int = Field(default=10, ge=1, le=10)
|
||||
|
||||
|
||||
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İ').")
|
||||
daire: str = Field("", description="Chamber")
|
||||
esasNo: str = Field("", description="Case number")
|
||||
kararNo: str = Field("", description="Decision number")
|
||||
kararTarihi: str = Field("", description="Decision date")
|
||||
arananKelime: str = Field("", description="Keyword")
|
||||
durum: str = Field("", description="Status")
|
||||
# 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.")
|
||||
document_url: Optional[HttpUrl] = Field(None, description="Document URL")
|
||||
|
||||
class Config:
|
||||
extra = 'ignore'
|
||||
model_config = ConfigDict(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.")
|
||||
draw: int = Field(0, description="Draw counter (Çizim Sayıcısı) 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.")
|
||||
data: Optional[EmsalApiResponseInnerData] = None
|
||||
metadata: Optional[Dict[str, Any]] = Field(None, description="Optional metadata (Meta Veri) 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.")
|
||||
id: str
|
||||
markdown_content: str = Field("", description="The decision content (Karar İçeriği) converted to Markdown.")
|
||||
source_url: HttpUrl
|
||||
|
||||
class CompactEmsalSearchResult(BaseModel):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
# gib_mcp_module/__init__.py
|
||||
@@ -0,0 +1,355 @@
|
||||
# gib_mcp_module/client.py
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import io
|
||||
import logging
|
||||
import math
|
||||
from typing import Optional, Any, Dict
|
||||
from markitdown import MarkItDown
|
||||
|
||||
from .models import (
|
||||
GibSearchRequest,
|
||||
GibOzelgeSummary,
|
||||
GibSearchResult,
|
||||
GibDocumentMarkdown,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if not logger.hasHandlers():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
|
||||
class GibApiClient:
|
||||
"""
|
||||
API client for searching and retrieving GİB özelgeler (Turkish Revenue
|
||||
Administration tax rulings) via the public gib.gov.tr JSON API.
|
||||
|
||||
The endpoint is a single POST list endpoint; document retrieval is done
|
||||
by filtering the same endpoint with an exact `id`.
|
||||
"""
|
||||
|
||||
BASE_URL = "https://gib.gov.tr/api"
|
||||
LIST_PATH = "/gibportal/mevzuat/ozelge/list"
|
||||
DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000
|
||||
|
||||
# Fixed filter values required by the backend
|
||||
_REQUIRED_STATUS = 2
|
||||
_REQUIRED_DELETED = False
|
||||
_REQUIRED_KTYPE = 99 # ktype=99 selects özelge
|
||||
_SORT_FIELD = "ozelgeTarih"
|
||||
_SORT_TYPE = "DESC"
|
||||
|
||||
def __init__(self, request_timeout: float = 60.0):
|
||||
self.http_client = httpx.AsyncClient(
|
||||
base_url=self.BASE_URL,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": "tr-TR,tr;q=0.9,en;q=0.7",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Mozilla/5.0 (compatible; yargi-mcp/1.0; +https://github.com/saidsurucu/yargi-mcp)",
|
||||
},
|
||||
timeout=request_timeout,
|
||||
verify=True,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_date(value: str, end_of_day: bool = False) -> Optional[str]:
|
||||
"""
|
||||
Accept 'YYYY-MM-DD' or full ISO 8601; always return full ISO 8601.
|
||||
|
||||
GİB backend rejects date-only strings.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
v = value.strip()
|
||||
if not v:
|
||||
return None
|
||||
# Already ISO with time component
|
||||
if "T" in v:
|
||||
return v
|
||||
# Simple YYYY-MM-DD - expand to start/end of day
|
||||
suffix = "T23:59:59.999Z" if end_of_day else "T00:00:00.000Z"
|
||||
return f"{v}{suffix}"
|
||||
|
||||
def _build_search_body(self, params: GibSearchRequest) -> Dict[str, Any]:
|
||||
body: Dict[str, Any] = {
|
||||
"status": self._REQUIRED_STATUS,
|
||||
"deleted": self._REQUIRED_DELETED,
|
||||
"ktype": self._REQUIRED_KTYPE,
|
||||
}
|
||||
|
||||
keywords = params.keywords.strip()
|
||||
kanun_no = params.kanunNo.strip()
|
||||
# Frontend sets title/kanunNo/description to the SAME value; the backend
|
||||
# ORs across them. If the caller supplies both, combine them so kanun_no
|
||||
# still biases toward ruling text, while keywords remain primary.
|
||||
search_term = keywords or kanun_no
|
||||
if keywords and kanun_no and kanun_no not in keywords:
|
||||
search_term = f"{keywords} {kanun_no}"
|
||||
if search_term:
|
||||
body["title"] = search_term
|
||||
body["kanunNo"] = search_term
|
||||
body["description"] = search_term
|
||||
|
||||
if params.ozelgeNo.strip():
|
||||
body["ozelgeNo"] = params.ozelgeNo.strip()
|
||||
|
||||
if params.kanunId and params.kanunId > 0:
|
||||
body["kanunIds"] = [params.kanunId]
|
||||
|
||||
start_iso = self._normalize_date(params.ozelgeStartDate, end_of_day=False)
|
||||
end_iso = self._normalize_date(params.ozelgeEndDate, end_of_day=True)
|
||||
if start_iso:
|
||||
body["ozelgeStartDate"] = start_iso
|
||||
if end_iso:
|
||||
body["ozelgeEndDate"] = end_iso
|
||||
|
||||
return body
|
||||
|
||||
def _build_query_params(self, page_1_indexed: int, page_size: int) -> Dict[str, Any]:
|
||||
# API expects 0-indexed page
|
||||
zero_indexed = max(0, page_1_indexed - 1)
|
||||
return {
|
||||
"page": zero_indexed,
|
||||
"size": page_size,
|
||||
"sortFieldName": self._SORT_FIELD,
|
||||
"sortType": self._SORT_TYPE,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _to_summary(item: Dict[str, Any]) -> Optional[GibOzelgeSummary]:
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
raw_id = item.get("id")
|
||||
if raw_id is None:
|
||||
return None
|
||||
try:
|
||||
ozelge_id = int(raw_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return GibOzelgeSummary(
|
||||
id=ozelge_id,
|
||||
ozelgeNo=item.get("ozelgeNo"),
|
||||
ozelgeTarih=item.get("ozelgeTarih"),
|
||||
title=item.get("title"),
|
||||
kanunNo=item.get("kanunNo"),
|
||||
kanunTitle=item.get("kanunTitle"),
|
||||
siteLink=item.get("siteLink"),
|
||||
)
|
||||
|
||||
async def search_ozelge(self, params: GibSearchRequest) -> GibSearchResult:
|
||||
"""Search GİB özelgeler."""
|
||||
body = self._build_search_body(params)
|
||||
query = self._build_query_params(params.page, params.pageSize)
|
||||
logger.info(
|
||||
"GibApiClient: search page=%s size=%s body_keys=%s",
|
||||
params.page, params.pageSize, sorted(body.keys()),
|
||||
)
|
||||
|
||||
try:
|
||||
resp = await self.http_client.post(self.LIST_PATH, params=query, json=body)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error("GibApiClient: HTTP %s during search", e.response.status_code)
|
||||
return GibSearchResult(
|
||||
ozelgeler=[],
|
||||
total_results=0,
|
||||
total_pages=0,
|
||||
current_page=params.page,
|
||||
page_size=params.pageSize,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("GibApiClient: search request failed: %s", e)
|
||||
return GibSearchResult(
|
||||
ozelgeler=[],
|
||||
total_results=0,
|
||||
total_pages=0,
|
||||
current_page=params.page,
|
||||
page_size=params.pageSize,
|
||||
)
|
||||
|
||||
container = (payload or {}).get("resultContainer") or {}
|
||||
raw_items = container.get("content") or []
|
||||
|
||||
summaries = []
|
||||
for raw in raw_items:
|
||||
summary = self._to_summary(raw)
|
||||
if summary is not None:
|
||||
summaries.append(summary)
|
||||
|
||||
total_results = container.get("totalElements") or 0
|
||||
total_pages = container.get("totalPages") or 0
|
||||
try:
|
||||
total_results = int(total_results)
|
||||
except (TypeError, ValueError):
|
||||
total_results = 0
|
||||
try:
|
||||
total_pages = int(total_pages)
|
||||
except (TypeError, ValueError):
|
||||
total_pages = 0
|
||||
|
||||
return GibSearchResult(
|
||||
ozelgeler=summaries,
|
||||
total_results=total_results,
|
||||
total_pages=total_pages,
|
||||
current_page=params.page,
|
||||
page_size=params.pageSize,
|
||||
)
|
||||
|
||||
def _convert_html_to_markdown(self, html_content: str) -> Optional[str]:
|
||||
"""Convert HTML content to Markdown using MarkItDown with BytesIO."""
|
||||
if not html_content:
|
||||
return None
|
||||
try:
|
||||
html_bytes = html_content.encode("utf-8")
|
||||
html_stream = io.BytesIO(html_bytes)
|
||||
md_converter = MarkItDown(enable_plugins=False)
|
||||
result = md_converter.convert(html_stream)
|
||||
return result.text_content
|
||||
except Exception as e:
|
||||
logger.error("GibApiClient: HTML→Markdown conversion failed: %s", e)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_header_block(item: Dict[str, Any]) -> str:
|
||||
"""Build a small Markdown header block summarising the ruling metadata."""
|
||||
parts = []
|
||||
title = item.get("title")
|
||||
if title:
|
||||
parts.append(f"# {title}")
|
||||
meta_lines = []
|
||||
if item.get("ozelgeNo"):
|
||||
meta_lines.append(f"**Sayı:** {item['ozelgeNo']}")
|
||||
if item.get("ozelgeTarih"):
|
||||
meta_lines.append(f"**Tarih:** {item['ozelgeTarih']}")
|
||||
if item.get("kanunTitle"):
|
||||
kanun_no = item.get("kanunNo")
|
||||
if kanun_no:
|
||||
meta_lines.append(f"**Kanun:** {item['kanunTitle']} ({kanun_no})")
|
||||
else:
|
||||
meta_lines.append(f"**Kanun:** {item['kanunTitle']}")
|
||||
if item.get("siteLink"):
|
||||
meta_lines.append(f"**Kaynak:** {item['siteLink']}")
|
||||
if meta_lines:
|
||||
parts.append("\n".join(meta_lines))
|
||||
return "\n\n".join(parts).strip()
|
||||
|
||||
async def get_ozelge_document(
|
||||
self, ozelge_id: int, page_number: int = 1
|
||||
) -> GibDocumentMarkdown:
|
||||
"""Retrieve a single özelge and return its paginated Markdown form."""
|
||||
logger.info(
|
||||
"GibApiClient: fetching özelge id=%s page=%s", ozelge_id, page_number
|
||||
)
|
||||
|
||||
if not isinstance(ozelge_id, int) or ozelge_id <= 0:
|
||||
return GibDocumentMarkdown(
|
||||
ozelge_id=ozelge_id if isinstance(ozelge_id, int) else 0,
|
||||
current_page=page_number,
|
||||
total_pages=0,
|
||||
is_paginated=False,
|
||||
error_message="ozelge_id must be a positive integer",
|
||||
)
|
||||
|
||||
body = {
|
||||
"status": self._REQUIRED_STATUS,
|
||||
"deleted": self._REQUIRED_DELETED,
|
||||
"ktype": self._REQUIRED_KTYPE,
|
||||
"id": ozelge_id,
|
||||
}
|
||||
query = {"page": 0, "size": 1}
|
||||
|
||||
try:
|
||||
resp = await self.http_client.post(self.LIST_PATH, params=query, json=body)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
msg = f"HTTP {e.response.status_code} when fetching özelge {ozelge_id}"
|
||||
logger.error("GibApiClient: %s", msg)
|
||||
return GibDocumentMarkdown(
|
||||
ozelge_id=ozelge_id,
|
||||
current_page=page_number,
|
||||
total_pages=0,
|
||||
is_paginated=False,
|
||||
error_message=msg,
|
||||
)
|
||||
except Exception as e:
|
||||
msg = f"Request failed: {e}"
|
||||
logger.error("GibApiClient: %s", msg)
|
||||
return GibDocumentMarkdown(
|
||||
ozelge_id=ozelge_id,
|
||||
current_page=page_number,
|
||||
total_pages=0,
|
||||
is_paginated=False,
|
||||
error_message=msg,
|
||||
)
|
||||
|
||||
container = (payload or {}).get("resultContainer") or {}
|
||||
content = container.get("content") or []
|
||||
if not content:
|
||||
return GibDocumentMarkdown(
|
||||
ozelge_id=ozelge_id,
|
||||
current_page=page_number,
|
||||
total_pages=0,
|
||||
is_paginated=False,
|
||||
error_message=f"Özelge {ozelge_id} not found",
|
||||
)
|
||||
|
||||
item = content[0] if isinstance(content[0], dict) else {}
|
||||
description_html = item.get("description") or ""
|
||||
markdown_body = (await asyncio.to_thread(self._convert_html_to_markdown, description_html)) or ""
|
||||
header_block = self._build_header_block(item)
|
||||
|
||||
if header_block and markdown_body:
|
||||
full_markdown = f"{header_block}\n\n---\n\n{markdown_body}"
|
||||
else:
|
||||
full_markdown = header_block or markdown_body
|
||||
|
||||
if not full_markdown.strip():
|
||||
return GibDocumentMarkdown(
|
||||
ozelge_id=ozelge_id,
|
||||
ozelge_no=item.get("ozelgeNo"),
|
||||
title=item.get("title"),
|
||||
ozelge_tarih=item.get("ozelgeTarih"),
|
||||
kanun_title=item.get("kanunTitle"),
|
||||
kanun_no=item.get("kanunNo"),
|
||||
site_link=item.get("siteLink"),
|
||||
current_page=page_number,
|
||||
total_pages=0,
|
||||
is_paginated=False,
|
||||
error_message="Document body is empty",
|
||||
)
|
||||
|
||||
total_pages = max(
|
||||
1, math.ceil(len(full_markdown) / self.DOCUMENT_MARKDOWN_CHUNK_SIZE)
|
||||
)
|
||||
current_page_clamped = max(1, min(page_number, total_pages))
|
||||
start = (current_page_clamped - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE
|
||||
end = start + self.DOCUMENT_MARKDOWN_CHUNK_SIZE
|
||||
chunk = full_markdown[start:end]
|
||||
|
||||
return GibDocumentMarkdown(
|
||||
ozelge_id=ozelge_id,
|
||||
ozelge_no=item.get("ozelgeNo"),
|
||||
title=item.get("title"),
|
||||
ozelge_tarih=item.get("ozelgeTarih"),
|
||||
kanun_title=item.get("kanunTitle"),
|
||||
kanun_no=item.get("kanunNo"),
|
||||
site_link=item.get("siteLink"),
|
||||
markdown_chunk=chunk,
|
||||
current_page=current_page_clamped,
|
||||
total_pages=total_pages,
|
||||
is_paginated=total_pages > 1,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
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("GibApiClient: HTTP client session closed.")
|
||||
@@ -0,0 +1,64 @@
|
||||
# gib_mcp_module/models.py
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class GibSearchRequest(BaseModel):
|
||||
"""
|
||||
Request model for searching GİB özelgeler (Turkish Revenue Administration tax rulings).
|
||||
|
||||
GİB (Gelir İdaresi Başkanlığı) publishes official tax-ruling letters
|
||||
("özelge") responding to taxpayer questions on VAT, income tax,
|
||||
corporate tax, stamp duty, and other tax matters. 18,000+ rulings
|
||||
are searchable via the public gib.gov.tr API.
|
||||
"""
|
||||
keywords: str = Field("", description="Keywords searched across title, kanunNo and description (Turkish)")
|
||||
ozelgeNo: str = Field("", description="Exact özelge reference number (e.g., 'E-40247694-130-15524')")
|
||||
kanunNo: str = Field("", description="Law number filter, e.g. '3065' for KDV")
|
||||
kanunId: int = Field(0, description="Optional numeric law ID filter (0=ignore)")
|
||||
ozelgeStartDate: str = Field("", description="Start date YYYY-MM-DD or full ISO 8601")
|
||||
ozelgeEndDate: str = Field("", description="End date YYYY-MM-DD or full ISO 8601")
|
||||
page: int = Field(1, ge=1, description="Page number (1-indexed)")
|
||||
pageSize: int = Field(10, ge=1, le=50, description="Results per page (1-50)")
|
||||
|
||||
|
||||
class GibOzelgeSummary(BaseModel):
|
||||
"""Summary of a single GİB özelge from search results (no full HTML)."""
|
||||
id: int = Field(..., description="Numeric özelge ID for document retrieval")
|
||||
ozelgeNo: Optional[str] = Field(None, description="Official ruling reference number")
|
||||
ozelgeTarih: Optional[str] = Field(None, description="Ruling date (ISO datetime)")
|
||||
title: Optional[str] = Field(None, description="Subject/title of the ruling")
|
||||
kanunNo: Optional[str] = Field(None, description="Law number (e.g., '3065')")
|
||||
kanunTitle: Optional[str] = Field(None, description="Law title (e.g., 'KATMA DEĞER VERGİSİ KANUNU')")
|
||||
siteLink: Optional[str] = Field(None, description="Direct URL to the ruling on gib.gov.tr")
|
||||
|
||||
|
||||
class GibSearchResult(BaseModel):
|
||||
"""Response model for GİB özelge search results."""
|
||||
ozelgeler: List[GibOzelgeSummary] = Field(default_factory=list, description="Matching özelge summaries")
|
||||
total_results: int = Field(0, description="Total number of matching özelgeler across all pages")
|
||||
total_pages: int = Field(0, description="Total number of pages for this query")
|
||||
current_page: int = Field(1, description="Current page (1-indexed)")
|
||||
page_size: int = Field(10, description="Results per page")
|
||||
|
||||
|
||||
class GibDocumentMarkdown(BaseModel):
|
||||
"""
|
||||
GİB özelge document converted to paginated Markdown.
|
||||
|
||||
Long rulings are split into 5000-character chunks; request successive
|
||||
pages via page_number to read the full text.
|
||||
"""
|
||||
ozelge_id: int = Field(..., description="Numeric özelge ID")
|
||||
ozelge_no: Optional[str] = Field(None, description="Official ruling reference number")
|
||||
title: Optional[str] = Field(None, description="Subject/title of the ruling")
|
||||
ozelge_tarih: Optional[str] = Field(None, description="Ruling date (ISO datetime)")
|
||||
kanun_title: Optional[str] = Field(None, description="Related law title")
|
||||
kanun_no: Optional[str] = Field(None, description="Related law number")
|
||||
site_link: Optional[str] = Field(None, description="Direct URL to the ruling on gib.gov.tr")
|
||||
markdown_chunk: Optional[str] = Field(None, description="Current 5000-character Markdown chunk")
|
||||
current_page: int = Field(1, description="Current page number (1-indexed)")
|
||||
total_pages: int = Field(0, description="Total pages for the full Markdown content")
|
||||
is_paginated: bool = Field(False, description="True if split across multiple pages")
|
||||
error_message: Optional[str] = Field(None, description="Populated when retrieval failed")
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
@echo off
|
||||
echo Yargi MCP Kurulum Script'i (install.py) baslatiliyor...
|
||||
|
||||
REM Python'in PATH'de oldugunu varsayiyoruz.
|
||||
REM Kullanici sistemine gore "python" veya "py -3" veya "python3" olabilir.
|
||||
REM Oncelikle "python" deneyelim.
|
||||
python install.py
|
||||
if errorlevel 1 (
|
||||
echo "python install.py" komutu basarisiz oldu. "py -3 install.py" deneniyor...
|
||||
py -3 install.py
|
||||
if errorlevel 1 (
|
||||
echo "py -3 install.py" komutu da basarisiz oldu.
|
||||
echo Lutfen Python 3'un sisteminizde kurulu ve PATH'de oldugundan emin olun.
|
||||
)
|
||||
)
|
||||
|
||||
echo.
|
||||
pause
|
||||
-302
@@ -1,302 +0,0 @@
|
||||
# install.py
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
import platform
|
||||
from urllib.parse import urlencode, urljoin, quote
|
||||
|
||||
# --- Yapılandırma ---
|
||||
MCP_SERVER_SCRIPT_NAME = "mcp_server_main.py"
|
||||
CLAUDE_TOOL_NAME = "Yargı MCP"
|
||||
DEPENDENCIES_FOR_FASTMCP = [
|
||||
"httpx", "beautifulsoup4", "markitdown", "pydantic", "aiohttp"
|
||||
]
|
||||
|
||||
# --- Yardımcı Fonksiyonlar ---
|
||||
def print_info(message):
|
||||
print(f"[INFO] {message}")
|
||||
|
||||
def print_warning(message):
|
||||
print(f"[UYARI] {message}")
|
||||
|
||||
def print_error(message):
|
||||
print(f"[HATA] {message}")
|
||||
|
||||
def command_exists(command_parts):
|
||||
"""Bir komutun sistemde var olup olmadığını kontrol eder ve yolunu döndürür."""
|
||||
try:
|
||||
command_to_check = command_parts[0] if isinstance(command_parts, list) else command_parts
|
||||
found_path = shutil.which(command_to_check)
|
||||
if found_path:
|
||||
return found_path
|
||||
if platform.system() == "Windows" and not command_to_check.endswith(".exe"):
|
||||
# .exe olmadan da PATH'de bulunabilir (örn: pyenv shims)
|
||||
# ama yine de .exe ile de kontrol edelim
|
||||
path_with_exe = shutil.which(command_to_check + ".exe")
|
||||
if path_with_exe:
|
||||
return path_with_exe
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def run_command(command_parts, capture_output_flag=False, check_return_code=True, shell=False, cwd=None, log_output_on_success=False):
|
||||
"""Verilen komutu çalıştırır."""
|
||||
cmd_str_for_log = ' '.join(command_parts) if isinstance(command_parts, list) else command_parts
|
||||
print_info(f"Komut çalıştırılıyor: {cmd_str_for_log}")
|
||||
|
||||
kwargs = {
|
||||
"text": True,
|
||||
"shell": shell,
|
||||
"cwd": cwd,
|
||||
"encoding": 'utf-8',
|
||||
"errors": 'replace' # Handles potential decoding errors in output
|
||||
}
|
||||
|
||||
if capture_output_flag:
|
||||
kwargs["capture_output"] = True
|
||||
# Else, stdout/stderr go to console by default (unless shell redirects them)
|
||||
|
||||
try:
|
||||
process = subprocess.run(command_parts, **kwargs)
|
||||
|
||||
if capture_output_flag:
|
||||
if log_output_on_success and process.returncode == 0:
|
||||
if process.stdout: print_info(f"Stdout:\n{process.stdout.strip()}")
|
||||
if process.stderr: print_warning(f"Stderr:\n{process.stderr.strip()}")
|
||||
elif process.returncode != 0: # Always log output on error if captured
|
||||
if process.stdout: print_error(f"Hata Stdout:\n{process.stdout.strip()}")
|
||||
if process.stderr: print_error(f"Hata Stderr:\n{process.stderr.strip()}")
|
||||
|
||||
if check_return_code and process.returncode != 0:
|
||||
raise subprocess.CalledProcessError(process.returncode, cmd_str_for_log, output=process.stdout, stderr=process.stderr)
|
||||
|
||||
return process
|
||||
except subprocess.CalledProcessError as e:
|
||||
# run_command already printed details if capture_output_flag was true
|
||||
if not capture_output_flag: # If output went to console, just print a simpler error
|
||||
print_error(f"Komut hatası (return code {e.returncode}): {cmd_str_for_log}")
|
||||
raise
|
||||
except FileNotFoundError:
|
||||
print_error(f"Komut bulunamadı: {command_parts[0] if isinstance(command_parts, list) else command_parts.split()[0]}")
|
||||
raise
|
||||
except Exception as e:
|
||||
print_error(f"Komut çalıştırılırken beklenmedik hata ({cmd_str_for_log}): {type(e).__name__} - {e}")
|
||||
raise
|
||||
|
||||
def get_python_executable():
|
||||
"""Kullanılabilir Python 3 çalıştırılabilir dosyasını bulur."""
|
||||
print_info("Python 3 yorumlayıcısı aranıyor...")
|
||||
# Önce mevcut çalışan Python'u dene
|
||||
current_python = sys.executable
|
||||
if current_python:
|
||||
try:
|
||||
print_info(f"Mevcut Python deneniyor: {current_python}")
|
||||
result = run_command([current_python, "-c", "import sys; assert sys.version_info.major == 3, 'Not Python 3'"], capture_output_flag=True, log_output_on_success=False)
|
||||
if result.returncode == 0:
|
||||
print_info(f"Kullanılacak Python: {current_python}")
|
||||
return current_python
|
||||
except Exception as e:
|
||||
print_warning(f"Mevcut Python ({current_python}) kontrol edilirken sorun: {e}")
|
||||
|
||||
# PATH'deki python3 ve python komutlarını dene
|
||||
for cmd_name in ["python3", "python"]:
|
||||
found_cmd_path = command_exists(cmd_name)
|
||||
if found_cmd_path:
|
||||
try:
|
||||
print_info(f"PATH'de bulunan '{cmd_name}' deneniyor: {found_cmd_path}")
|
||||
result = run_command([found_cmd_path, "-c", "import sys; assert sys.version_info.major == 3, 'Not Python 3'; print(sys.executable)"], capture_output_flag=True, log_output_on_success=False)
|
||||
if result.returncode == 0 and result.stdout:
|
||||
resolved_path = result.stdout.strip()
|
||||
print_info(f"Kullanılacak Python: {resolved_path} ('{cmd_name}' komutu ile bulundu)")
|
||||
return resolved_path
|
||||
except Exception as e:
|
||||
print_warning(f"'{cmd_name}' ({found_cmd_path}) kontrol edilirken sorun: {e}")
|
||||
|
||||
print_error("Python 3 sisteminizde bulunamadı veya PATH'e doğru şekilde eklenmemiş.")
|
||||
print_error("Lütfen Python 3'ü (https://www.python.org/downloads/) kurun.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# --- Kurulum Fonksiyonları ---
|
||||
def install_uv(python_exe_path):
|
||||
print_info("Adım 1/3: uv kontrol ediliyor/kuruluyor...")
|
||||
uv_executable = command_exists("uv")
|
||||
if uv_executable:
|
||||
print_info(f"uv zaten kurulu: {uv_executable}")
|
||||
run_command([uv_executable, "--version"], capture_output_flag=True, log_output_on_success=True)
|
||||
return uv_executable
|
||||
|
||||
print_info("uv kurulu değil. Kurulum denenecek...")
|
||||
try:
|
||||
if platform.system() == "Windows":
|
||||
print_info("PowerShell ile uv indirme ve kurma script'i çalıştırılacak.")
|
||||
run_command([
|
||||
"powershell", "-ExecutionPolicy", "Bypass", "-NoProfile", "-NonInteractive",
|
||||
"-Command", "try { irm https://astral.sh/uv/install.ps1 | iex } catch { Write-Error $_; exit 1 }"
|
||||
], shell=False)
|
||||
else:
|
||||
print_info("curl ile uv kurulum script'i çalıştırılacak.")
|
||||
process = subprocess.run("curl -LsSf https://astral.sh/uv/install.sh | sh", shell=True, capture_output=True, text=True, encoding='utf-8', errors='replace')
|
||||
if process.stdout: print_info(f"uv install script stdout:\n{process.stdout}")
|
||||
if process.stderr: print_warning(f"uv install script stderr:\n{process.stderr}")
|
||||
if process.returncode != 0:
|
||||
raise subprocess.CalledProcessError(process.returncode, "curl ... | sh")
|
||||
|
||||
uv_executable = command_exists("uv")
|
||||
if not uv_executable: # PATH'e hemen yansımamış olabilir, bilinen yerleri kontrol et
|
||||
common_paths_uv = []
|
||||
if platform.system() == "Windows":
|
||||
cargo_uv_path = os.path.join(os.environ.get("USERPROFILE", ""), ".cargo", "bin", "uv.exe")
|
||||
localapp_uv_path = os.path.join(os.environ.get("LOCALAPPDATA", ""), "uv", "uv.exe")
|
||||
if os.path.exists(cargo_uv_path): common_paths_uv.append(cargo_uv_path)
|
||||
if os.path.exists(localapp_uv_path): common_paths_uv.append(localapp_uv_path)
|
||||
else: # macOS / Linux
|
||||
common_paths_uv.extend([
|
||||
os.path.join(os.environ.get("HOME", ""), ".cargo", "bin", "uv"),
|
||||
os.path.join(os.environ.get("HOME", ""), ".local", "bin", "uv")
|
||||
])
|
||||
for p_uv in common_paths_uv:
|
||||
if command_exists(p_uv): uv_executable = p_uv; break
|
||||
|
||||
if uv_executable and command_exists(uv_executable):
|
||||
print_info(f"uv başarıyla kuruldu/bulundu: {uv_executable}")
|
||||
run_command([uv_executable, "--version"], capture_output_flag=True, log_output_on_success=True)
|
||||
return uv_executable
|
||||
else: # Son çare pip
|
||||
print_warning("uv resmi script ile kuruldu/bulundu ancak PATH'de doğrulanamadı. pip ile deneniyor...")
|
||||
run_command([python_exe_path, "-m", "pip", "install", "uv"])
|
||||
uv_executable = command_exists("uv")
|
||||
if uv_executable:
|
||||
print_info(f"uv pip ile başarıyla kuruldu: {uv_executable}")
|
||||
run_command([uv_executable, "--version"], capture_output_flag=True, log_output_on_success=True)
|
||||
return uv_executable
|
||||
print_error("uv pip ile de kurulamadı. Lütfen manuel kurulum yapın: https://astral.sh/uv")
|
||||
return None
|
||||
except Exception as e:
|
||||
print_error(f"uv kurulumu sırasında genel bir hata oluştu: {e}")
|
||||
print_warning("Lütfen uv'yi manuel olarak kurmayı deneyin: https://astral.sh/uv")
|
||||
return None
|
||||
|
||||
def install_fastmcp_cli(python_exe_path, uv_exe_path): # uv_exe_path artık kullanılmıyor
|
||||
"""fastmcp CLI'yi kontrol eder ve gerekirse pip/pip3 ile kurar."""
|
||||
print_info("Adım 2/3: fastmcp CLI kontrol ediliyor/kuruluyor...")
|
||||
fastmcp_executable = command_exists("fastmcp")
|
||||
if fastmcp_executable:
|
||||
print_info(f"fastmcp CLI zaten kurulu: {fastmcp_executable}")
|
||||
run_command([fastmcp_executable, "version"], capture_output_flag=True, log_output_on_success=True)
|
||||
return fastmcp_executable
|
||||
|
||||
print_info("fastmcp CLI kurulu değil. pip/pip3 ile kurulum denenecek...")
|
||||
try:
|
||||
pip_cmd_to_try = [python_exe_path, "-m", "pip", "install", "fastmcp"]
|
||||
print_info(f"{' '.join(pip_cmd_to_try)} komutu deneniyor...")
|
||||
run_command(pip_cmd_to_try)
|
||||
|
||||
fastmcp_executable = command_exists("fastmcp")
|
||||
if fastmcp_executable:
|
||||
print_info(f"fastmcp CLI başarıyla kuruldu: {fastmcp_executable}")
|
||||
run_command([fastmcp_executable, "version"], capture_output_flag=True, log_output_on_success=True)
|
||||
return fastmcp_executable
|
||||
else:
|
||||
scripts_dir = os.path.dirname(python_exe_path)
|
||||
if platform.system() == "Windows" and not scripts_dir.lower().endswith("scripts"):
|
||||
scripts_dir = os.path.join(scripts_dir, "Scripts")
|
||||
potential_fastmcp_path = os.path.join(scripts_dir, "fastmcp.exe" if platform.system() == "Windows" else "fastmcp")
|
||||
if command_exists(potential_fastmcp_path):
|
||||
print_info(f"fastmcp CLI şu yolda bulundu: {potential_fastmcp_path}")
|
||||
run_command([potential_fastmcp_path, "version"], capture_output_flag=True, log_output_on_success=True)
|
||||
return potential_fastmcp_path
|
||||
else:
|
||||
print_error("fastmcp CLI kuruldu ancak PATH'de veya bilinen Python script yollarında bulunamadı.")
|
||||
print_error("Lütfen terminalinizi yeniden başlatın veya PATH'i manuel güncelleyin.")
|
||||
return None
|
||||
except Exception as e:
|
||||
print_error(f"fastmcp CLI kurulumu sırasında hata oluştu: {e}")
|
||||
return None
|
||||
|
||||
def install_tool_to_claude_desktop(fastmcp_exe_path):
|
||||
"""Yargı MCP sunucusunu Claude Desktop'a kurar."""
|
||||
print_info(f"Adım 3/3: \"{CLAUDE_TOOL_NAME}\" Claude Desktop'a kuruluyor...")
|
||||
if not os.path.exists(MCP_SERVER_SCRIPT_NAME):
|
||||
print_error(f"Ana sunucu script'i '{MCP_SERVER_SCRIPT_NAME}' bulunamadı.")
|
||||
print_error("Lütfen bu script'i ana sunucu script'inin bulunduğu dizinde çalıştırın.")
|
||||
return False
|
||||
|
||||
dependencies_cmd_part = []
|
||||
for dep in DEPENDENCIES_FOR_FASTMCP:
|
||||
dependencies_cmd_part.extend(["--with", dep])
|
||||
|
||||
install_command = [
|
||||
fastmcp_exe_path, "install", MCP_SERVER_SCRIPT_NAME,
|
||||
"--name", CLAUDE_TOOL_NAME
|
||||
] + dependencies_cmd_part
|
||||
|
||||
try:
|
||||
process = run_command(install_command, capture_output_flag=True, check_return_code=False, log_output_on_success=False)
|
||||
if process.returncode == 0:
|
||||
print_info(f"\"{CLAUDE_TOOL_NAME}\" başarıyla Claude Desktop'a kuruldu/güncellendi.")
|
||||
if process.stdout: print_info(f"fastmcp install stdout:\n{process.stdout.strip()}")
|
||||
if process.stderr: print_warning(f"fastmcp install stderr:\n{process.stderr.strip()}")
|
||||
return True
|
||||
else:
|
||||
error_output = (process.stdout or "") + (process.stderr or "")
|
||||
if "claude app not found" in error_output.lower():
|
||||
print_error("Claude Desktop uygulaması sisteminizde bulunamadı veya algılanamadı.")
|
||||
print_error("Lütfen Claude Desktop'ın kurulu ve çalışır durumda olduğundan emin olun.")
|
||||
print_error("Claude Desktop'ı https://claude.ai/download adresinden indirebilirsiniz.")
|
||||
else:
|
||||
print_error(f"Sunucu Claude Desktop'a kurulurken hata oluştu (return code {process.returncode}).")
|
||||
print_error("Lütfen fastmcp CLI'nin düzgün çalıştığından emin olun.")
|
||||
if process.stderr: print_error(f"fastmcp install stderr:\n{process.stderr.strip()}")
|
||||
if process.stdout: print_info(f"fastmcp install stdout (hata durumunda):\n{process.stdout.strip()}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print_error(f"Sunucu Claude Desktop'a kurulurken genel bir hata oluştu: {e}")
|
||||
return False
|
||||
|
||||
# --- Ana Kurulum Mantığı ---
|
||||
def main():
|
||||
print("===================================================================")
|
||||
print(" Yargi MCP Sunucusu - Python Kurulum Script'i")
|
||||
print("===================================================================")
|
||||
|
||||
if platform.system() == "Windows":
|
||||
confirm = input("Bu script, uv ve fastmcp araclarini kuracak ve Yargi MCP sunucusunu Claude Desktop'a entegre edecektir. Devam etmek istiyor musunuz? (E/H): ")
|
||||
if confirm.lower() != 'e':
|
||||
print_info("Kurulum kullanıcı tarafından iptal edildi.")
|
||||
sys.exit(0)
|
||||
|
||||
python_executable = get_python_executable()
|
||||
uv_executable_path = install_uv(python_executable) # uv hala öneriliyor fastmcp install için
|
||||
|
||||
fastmcp_executable_path = install_fastmcp_cli(python_executable, uv_executable_path) # uv_exe_path burada kullanılmıyor
|
||||
if not fastmcp_executable_path:
|
||||
print_error("fastmcp CLI kurulumu başarısız oldu. Kurulum sonlandırılıyor.")
|
||||
sys.exit(1)
|
||||
|
||||
if not install_tool_to_claude_desktop(fastmcp_executable_path):
|
||||
print_error("Claude Desktop'a kurulum başarısız oldu.")
|
||||
sys.exit(1)
|
||||
|
||||
print_info("===================================================================")
|
||||
print_info(" KURULUM BAŞARIYLA TAMAMLANDI!")
|
||||
print_info("===================================================================")
|
||||
print_info(f"- \"{CLAUDE_TOOL_NAME}\" aracı Claude Desktop'a eklenmiş olmalıdır.")
|
||||
print_info("- Değişikliklerin etkili olması için Claude Desktop'ı yeniden başlatmanız gerekebilir.")
|
||||
print_info("- Eğer uv veya fastmcp PATH'e yeni eklendiyse, terminalinizi de yeniden başlatmanız gerekebilir.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except SystemExit:
|
||||
pass # sys.exit() çağrıldığında script sonlansın
|
||||
except Exception as e:
|
||||
print_error(f"Beklenmedik bir genel hata oluştu: {e}")
|
||||
sys.exit(1)
|
||||
finally:
|
||||
if platform.system() == "Windows":
|
||||
input("Çıkmak için Enter tuşuna basın...")
|
||||
else:
|
||||
print("Kurulum script'i tamamlandı.")
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# --- Script Bilgileri ---
|
||||
echo "==================================================================="
|
||||
echo " Yargi MCP Sunucusu - Kurulum Başlatıcı (macOS/Linux)"
|
||||
echo "==================================================================="
|
||||
echo " Bu script, Yargi MCP sunucusunun kurulumu için gerekli olan"
|
||||
echo " Python script'ini (install.py) çalıştıracaktır."
|
||||
echo ""
|
||||
read -p "Devam etmek istiyor musunuz? (E/H): " continue_script
|
||||
if [[ ! "$continue_script" =~ ^[Ee]$ ]]; then
|
||||
echo "Kurulum iptal edildi."
|
||||
exit 0
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# --- Python Yorumlayıcısını Bul ve install.py'yi Çalıştır ---
|
||||
PYTHON_EXECUTABLE=""
|
||||
|
||||
# Öncelikle python3'ü dene
|
||||
if command -v python3 &>/dev/null; then
|
||||
PYTHON_EXECUTABLE="python3"
|
||||
# Sonra python'u dene (Python 3 olduğundan emin olmak için install.py içinde kontrol var)
|
||||
elif command -v python &>/dev/null; then
|
||||
PYTHON_EXECUTABLE="python"
|
||||
fi
|
||||
|
||||
if [ -z "$PYTHON_EXECUTABLE" ]; then
|
||||
echo "[HATA] Sisteminizde Python 3 bulunamadı veya PATH'e eklenmemiş."
|
||||
echo "Lütfen Python 3'ü (https://www.python.org/downloads/) kurun."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[INFO] '$PYTHON_EXECUTABLE install.py' komutu çalıştırılıyor..."
|
||||
echo "-------------------------------------------------------------------"
|
||||
|
||||
"$PYTHON_EXECUTABLE" install.py
|
||||
|
||||
# install.py script'inin çıkış kodunu kontrol et
|
||||
INSTALL_EXIT_CODE=$?
|
||||
|
||||
echo "-------------------------------------------------------------------"
|
||||
if [ $INSTALL_EXIT_CODE -eq 0 ]; then
|
||||
echo "[INFO] install.py script'i başarıyla tamamlandı."
|
||||
else
|
||||
echo "[HATA] install.py script'i bir hatayla sonlandı (Çıkış Kodu: $INSTALL_EXIT_CODE)."
|
||||
echo "[HATA] Lütfen yukarıdaki hata mesajlarını kontrol edin."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
# Pencerenin hemen kapanmaması için (özellikle çift tıklanarak çalıştırılırsa)
|
||||
read -p "Kurulum script'i tamamlandı. Çıkmak için Enter tuşuna basın..."
|
||||
|
||||
exit $INSTALL_EXIT_CODE
|
||||
@@ -0,0 +1,507 @@
|
||||
# kik_mcp_module/client_v2.py
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import httpx
|
||||
import logging
|
||||
import uuid
|
||||
import ssl
|
||||
import os
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
# Cryptography imports for AES-256-CBC encryption of document IDs
|
||||
try:
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
HAS_CRYPTOGRAPHY = True
|
||||
except ImportError:
|
||||
HAS_CRYPTOGRAPHY = False
|
||||
|
||||
from .models_v2 import (
|
||||
KikV2DecisionType, KikV2SearchPayload, KikV2SearchPayloadDk, KikV2SearchPayloadMk,
|
||||
KikV2RequestData, KikV2QueryRequest, KikV2KeyValuePair,
|
||||
KikV2SearchResponse, KikV2SearchResponseDk, KikV2SearchResponseMk,
|
||||
KikV2SearchResult, KikV2CompactDecision, KikV2DocumentMarkdown
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class KikV2ApiClient:
|
||||
"""
|
||||
New KIK v2 API Client for https://ekapv2.kik.gov.tr
|
||||
|
||||
This client uses the modern JSON-based API endpoint that provides
|
||||
better structured data compared to the legacy form-based API.
|
||||
"""
|
||||
|
||||
BASE_URL = "https://ekapv2.kik.gov.tr"
|
||||
|
||||
# Endpoint mappings for different decision types
|
||||
ENDPOINTS = {
|
||||
KikV2DecisionType.UYUSMAZLIK: "/b_ihalearaclari/api/KurulKararlari/GetKurulKararlari",
|
||||
KikV2DecisionType.DUZENLEYICI: "/b_ihalearaclari/api/KurulKararlari/GetKurulKararlariDk",
|
||||
KikV2DecisionType.MAHKEME: "/b_ihalearaclari/api/KurulKararlari/GetKurulKararlariMk"
|
||||
}
|
||||
|
||||
# AES-256-CBC encryption key for document ID encryption (reverse engineered from ekapv2.kik.gov.tr Angular app)
|
||||
# This key is used to encrypt numeric gundemMaddesiId values to 64-character hex hashes for document URLs
|
||||
DOCUMENT_ID_ENCRYPTION_KEY = bytes([
|
||||
236, 193, 164, 43, 12, 135, 121, 170, 4, 244, 123, 219, 82, 158, 124, 174,
|
||||
174, 228, 219, 174, 208, 104, 174, 120, 32, 76, 250, 4, 143, 159, 211, 176
|
||||
])
|
||||
|
||||
# AES-192-CBC key (environment.r8fact) used by the Angular HTTP interceptor to sign every
|
||||
# request. The server decrypts X-Custom-Request-Ts and rejects stale timestamps with
|
||||
# HTTP 401 "İstek zaman aşımına uğradı.", so these headers MUST be generated per-request
|
||||
# with the current timestamp (see _generate_security_headers).
|
||||
REQUEST_SIGNING_KEY = b"Qm2LtXR0aByP69vZNKef4wMJ" # UTF-8 bytes, 24 chars -> AES-192
|
||||
|
||||
@staticmethod
|
||||
def encrypt_document_id(numeric_id: str) -> str:
|
||||
"""
|
||||
Encrypt a numeric KİK gundemMaddesiId to the 64-character hex hash
|
||||
used in document URLs.
|
||||
|
||||
Algorithm: AES-256-CBC with PKCS7 padding
|
||||
Output format: IV (16 bytes hex) + Ciphertext (16 bytes hex) = 64 chars
|
||||
|
||||
Args:
|
||||
numeric_id: The numeric document ID from search results (e.g., "177280")
|
||||
|
||||
Returns:
|
||||
64-character hex string for use in document URL KararId parameter
|
||||
"""
|
||||
if not HAS_CRYPTOGRAPHY:
|
||||
raise ImportError("cryptography library required for document ID encryption")
|
||||
|
||||
# Generate random IV (16 bytes)
|
||||
iv = os.urandom(16)
|
||||
|
||||
# Create AES-CBC cipher with the encryption key
|
||||
cipher = Cipher(
|
||||
algorithms.AES(KikV2ApiClient.DOCUMENT_ID_ENCRYPTION_KEY),
|
||||
modes.CBC(iv),
|
||||
backend=default_backend()
|
||||
)
|
||||
encryptor = cipher.encryptor()
|
||||
|
||||
# Encode plaintext and apply PKCS7 padding
|
||||
plaintext = numeric_id.encode('utf-8')
|
||||
block_size = 16
|
||||
padding_len = block_size - (len(plaintext) % block_size)
|
||||
padded_plaintext = plaintext + bytes([padding_len] * padding_len)
|
||||
|
||||
# Encrypt
|
||||
ciphertext = encryptor.update(padded_plaintext) + encryptor.finalize()
|
||||
|
||||
# Return IV + ciphertext as lowercase hex (64 characters total)
|
||||
return iv.hex() + ciphertext.hex()
|
||||
|
||||
def __init__(self, request_timeout: float = 60.0):
|
||||
# Create SSL context with legacy server support
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
# Enable legacy server connect option for older SSL implementations (Python 3.12+)
|
||||
if hasattr(ssl, 'OP_LEGACY_SERVER_CONNECT'):
|
||||
ssl_context.options |= ssl.OP_LEGACY_SERVER_CONNECT
|
||||
|
||||
# Set broader cipher suite support including legacy ciphers
|
||||
ssl_context.set_ciphers('ALL:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA')
|
||||
|
||||
self.http_client = httpx.AsyncClient(
|
||||
base_url=self.BASE_URL,
|
||||
verify=ssl_context,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": "tr",
|
||||
"Content-Type": "application/json",
|
||||
"Origin": self.BASE_URL,
|
||||
"Referer": f"{self.BASE_URL}/sorgulamalar/kurul-kararlari",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36",
|
||||
"api-version": "v1",
|
||||
"sec-ch-ua": '"Not;A=Brand";v="99", "Google Chrome";v="139", "Chromium";v="139"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"macOS"'
|
||||
},
|
||||
timeout=request_timeout
|
||||
)
|
||||
|
||||
# Generate security headers (these might need to be updated based on API requirements)
|
||||
self.security_headers = self._generate_security_headers()
|
||||
|
||||
def _sign_request_value(self, plaintext: str, iv: bytes) -> str:
|
||||
"""AES-192-CBC encrypt a value with the request signing key, return base64 ciphertext."""
|
||||
cipher = Cipher(
|
||||
algorithms.AES(self.REQUEST_SIGNING_KEY),
|
||||
modes.CBC(iv),
|
||||
backend=default_backend()
|
||||
)
|
||||
encryptor = cipher.encryptor()
|
||||
data = plaintext.encode("utf-8")
|
||||
block_size = 16
|
||||
padding_len = block_size - (len(data) % block_size)
|
||||
padded = data + bytes([padding_len] * padding_len)
|
||||
ciphertext = encryptor.update(padded) + encryptor.finalize()
|
||||
return base64.b64encode(ciphertext).decode("ascii")
|
||||
|
||||
def _generate_security_headers(self) -> dict:
|
||||
"""
|
||||
Generate the custom security headers required by the KIK v2 API.
|
||||
|
||||
Mirrors the Angular HTTP interceptor on ekapv2.kik.gov.tr: a random GUID and a
|
||||
current-timestamp (epoch milliseconds) are AES-192-CBC encrypted with environment.r8fact
|
||||
using a fresh random IV. The IV is sent as -Siv, the encrypted timestamp as -Ts, and the
|
||||
encrypted GUID as -R8id. The server validates the decrypted timestamp's freshness, so these
|
||||
MUST be regenerated on every request; stale values yield HTTP 401 "İstek zaman aşımına uğradı.".
|
||||
"""
|
||||
if not HAS_CRYPTOGRAPHY:
|
||||
raise ImportError("cryptography library required for KIK v2 request signing")
|
||||
|
||||
request_guid = str(uuid.uuid4())
|
||||
iv = os.urandom(16)
|
||||
timestamp_ms = str(int(datetime.now().timestamp() * 1000))
|
||||
|
||||
return {
|
||||
"X-Custom-Request-Guid": request_guid,
|
||||
"X-Custom-Request-R8id": self._sign_request_value(request_guid, iv),
|
||||
"X-Custom-Request-Siv": base64.b64encode(iv).decode("ascii"),
|
||||
"X-Custom-Request-Ts": self._sign_request_value(timestamp_ms, iv),
|
||||
}
|
||||
|
||||
def _build_search_payload(self,
|
||||
decision_type: KikV2DecisionType,
|
||||
karar_metni: str = "",
|
||||
karar_no: str = "",
|
||||
basvuran: str = "",
|
||||
idare_adi: str = "",
|
||||
baslangic_tarihi: str = "",
|
||||
bitis_tarihi: str = ""):
|
||||
"""Build the search payload for KIK v2 API."""
|
||||
|
||||
key_value_pairs = []
|
||||
|
||||
# Add non-empty search criteria
|
||||
if karar_metni:
|
||||
key_value_pairs.append(KikV2KeyValuePair(key="KararMetni", value=karar_metni))
|
||||
|
||||
if karar_no:
|
||||
key_value_pairs.append(KikV2KeyValuePair(key="KararNo", value=karar_no))
|
||||
|
||||
if basvuran:
|
||||
key_value_pairs.append(KikV2KeyValuePair(key="BasvuranAdi", value=basvuran))
|
||||
|
||||
if idare_adi:
|
||||
key_value_pairs.append(KikV2KeyValuePair(key="IdareAdi", value=idare_adi))
|
||||
|
||||
if baslangic_tarihi:
|
||||
key_value_pairs.append(KikV2KeyValuePair(key="BaslangicTarihi", value=baslangic_tarihi))
|
||||
|
||||
if bitis_tarihi:
|
||||
key_value_pairs.append(KikV2KeyValuePair(key="BitisTarihi", value=bitis_tarihi))
|
||||
|
||||
# If no search criteria provided, use a generic search
|
||||
if not key_value_pairs:
|
||||
key_value_pairs.append(KikV2KeyValuePair(key="KararMetni", value=""))
|
||||
|
||||
query_request = KikV2QueryRequest(keyValueOfstringanyType=key_value_pairs)
|
||||
request_data = KikV2RequestData(keyValuePairs=query_request)
|
||||
|
||||
# Return appropriate payload based on decision type
|
||||
if decision_type == KikV2DecisionType.UYUSMAZLIK:
|
||||
return KikV2SearchPayload(sorgulaKurulKararlari=request_data)
|
||||
elif decision_type == KikV2DecisionType.DUZENLEYICI:
|
||||
return KikV2SearchPayloadDk(sorgulaKurulKararlariDk=request_data)
|
||||
elif decision_type == KikV2DecisionType.MAHKEME:
|
||||
return KikV2SearchPayloadMk(sorgulaKurulKararlariMk=request_data)
|
||||
else:
|
||||
raise ValueError(f"Unsupported decision type: {decision_type}")
|
||||
|
||||
async def search_decisions(self,
|
||||
decision_type: KikV2DecisionType = KikV2DecisionType.UYUSMAZLIK,
|
||||
karar_metni: str = "",
|
||||
karar_no: str = "",
|
||||
basvuran: str = "",
|
||||
idare_adi: str = "",
|
||||
baslangic_tarihi: str = "",
|
||||
bitis_tarihi: str = "") -> KikV2SearchResult:
|
||||
"""
|
||||
Search KIK decisions using the v2 API.
|
||||
|
||||
Args:
|
||||
decision_type: Type of decision to search (uyusmazlik/duzenleyici/mahkeme)
|
||||
karar_metni: Decision text search
|
||||
karar_no: Decision number (e.g., "2025/UH.II-1801")
|
||||
basvuran: Applicant name
|
||||
idare_adi: Administration name
|
||||
baslangic_tarihi: Start date (YYYY-MM-DD format)
|
||||
bitis_tarihi: End date (YYYY-MM-DD format)
|
||||
|
||||
Returns:
|
||||
KikV2SearchResult with compact decision list
|
||||
"""
|
||||
|
||||
logger.info(f"KikV2ApiClient: Searching {decision_type.value} decisions with criteria - karar_metni: '{karar_metni}', karar_no: '{karar_no}', basvuran: '{basvuran}'")
|
||||
|
||||
try:
|
||||
# Build request payload
|
||||
payload = self._build_search_payload(
|
||||
decision_type=decision_type,
|
||||
karar_metni=karar_metni,
|
||||
karar_no=karar_no,
|
||||
basvuran=basvuran,
|
||||
idare_adi=idare_adi,
|
||||
baslangic_tarihi=baslangic_tarihi,
|
||||
bitis_tarihi=bitis_tarihi
|
||||
)
|
||||
|
||||
# Update security headers for this request
|
||||
headers = {**self.http_client.headers, **self._generate_security_headers()}
|
||||
|
||||
# Get the appropriate endpoint for this decision type
|
||||
endpoint = self.ENDPOINTS[decision_type]
|
||||
|
||||
# Make API request
|
||||
response = await self.http_client.post(
|
||||
endpoint,
|
||||
json=payload.model_dump(),
|
||||
headers=headers
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
response_data = response.json()
|
||||
|
||||
logger.debug(f"KikV2ApiClient: Raw API response structure: {type(response_data)}")
|
||||
|
||||
# Parse the API response based on decision type
|
||||
if decision_type == KikV2DecisionType.UYUSMAZLIK:
|
||||
api_response = KikV2SearchResponse(**response_data)
|
||||
result_data = api_response.SorgulaKurulKararlariResponse.SorgulaKurulKararlariResult
|
||||
elif decision_type == KikV2DecisionType.DUZENLEYICI:
|
||||
api_response = KikV2SearchResponseDk(**response_data)
|
||||
result_data = api_response.SorgulaKurulKararlariDkResponse.SorgulaKurulKararlariDkResult
|
||||
elif decision_type == KikV2DecisionType.MAHKEME:
|
||||
api_response = KikV2SearchResponseMk(**response_data)
|
||||
result_data = api_response.SorgulaKurulKararlariMkResponse.SorgulaKurulKararlariMkResult
|
||||
else:
|
||||
raise ValueError(f"Unsupported decision type: {decision_type}")
|
||||
|
||||
# Check for API errors
|
||||
if result_data.hataKodu and result_data.hataKodu != "0":
|
||||
logger.warning(f"KikV2ApiClient: API returned error - Code: {result_data.hataKodu}, Message: {result_data.hataMesaji}")
|
||||
return KikV2SearchResult(
|
||||
decisions=[],
|
||||
total_records=0,
|
||||
page=1,
|
||||
error_code=result_data.hataKodu,
|
||||
error_message=result_data.hataMesaji
|
||||
)
|
||||
|
||||
# Convert to compact format
|
||||
compact_decisions = []
|
||||
total_count = 0
|
||||
|
||||
for decision_group in result_data.KurulKararTutanakDetayListesi:
|
||||
for decision_detail in decision_group.KurulKararTutanakDetayi:
|
||||
compact_decision = KikV2CompactDecision(
|
||||
kararNo=decision_detail.kararNo,
|
||||
kararTarihi=decision_detail.kararTarihi,
|
||||
basvuran=decision_detail.basvuran,
|
||||
idareAdi=decision_detail.idareAdi,
|
||||
basvuruKonusu=decision_detail.basvuruKonusu,
|
||||
gundemMaddesiId=decision_detail.gundemMaddesiId,
|
||||
decision_type=decision_type.value
|
||||
)
|
||||
compact_decisions.append(compact_decision)
|
||||
total_count += 1
|
||||
|
||||
logger.info(f"KikV2ApiClient: Found {total_count} decisions")
|
||||
|
||||
return KikV2SearchResult(
|
||||
decisions=compact_decisions,
|
||||
total_records=total_count,
|
||||
page=1,
|
||||
error_code="0",
|
||||
error_message=""
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"KikV2ApiClient: HTTP error during search: {e.response.status_code} - {e.response.text}")
|
||||
return KikV2SearchResult(
|
||||
decisions=[],
|
||||
total_records=0,
|
||||
page=1,
|
||||
error_code="HTTP_ERROR",
|
||||
error_message=f"HTTP {e.response.status_code}: {e.response.text}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"KikV2ApiClient: Unexpected error during search: {str(e)}")
|
||||
return KikV2SearchResult(
|
||||
decisions=[],
|
||||
total_records=0,
|
||||
page=1,
|
||||
error_code="UNEXPECTED_ERROR",
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
async def get_document_markdown(self, document_id: str) -> KikV2DocumentMarkdown:
|
||||
"""
|
||||
Get KİK decision document content in Markdown format.
|
||||
|
||||
This method uses a two-step process:
|
||||
1. Call GetSorgulamaUrl endpoint to get the actual document URL
|
||||
2. Use httpx to fetch the document content
|
||||
|
||||
Args:
|
||||
document_id: The gundemMaddesiId from search results
|
||||
|
||||
Returns:
|
||||
KikV2DocumentMarkdown with document content converted to Markdown
|
||||
"""
|
||||
|
||||
logger.info(f"KikV2ApiClient: Getting document for ID: {document_id}")
|
||||
|
||||
if not document_id or not document_id.strip():
|
||||
return KikV2DocumentMarkdown(
|
||||
document_id=document_id,
|
||||
kararNo="",
|
||||
markdown_content="",
|
||||
source_url="",
|
||||
error_message="Document ID is required"
|
||||
)
|
||||
|
||||
try:
|
||||
# Step 1: Get the actual document URL using GetSorgulamaUrl endpoint
|
||||
logger.info(f"KikV2ApiClient: Step 1 - Getting document URL for ID: {document_id}")
|
||||
|
||||
# Update security headers for this request
|
||||
headers = {**self.http_client.headers, **self._generate_security_headers()}
|
||||
|
||||
# Call GetSorgulamaUrl to get the real document URL
|
||||
url_payload = {"sorguSayfaTipi": 2} # As shown in curl example
|
||||
|
||||
url_response = await self.http_client.post(
|
||||
"/b_ihalearaclari/api/KurulKararlari/GetSorgulamaUrl",
|
||||
json=url_payload,
|
||||
headers=headers
|
||||
)
|
||||
|
||||
url_response.raise_for_status()
|
||||
url_data = url_response.json()
|
||||
|
||||
# Get the base document URL from API response
|
||||
base_document_url = url_data.get("sorgulamaUrl", "")
|
||||
if not base_document_url:
|
||||
return KikV2DocumentMarkdown(
|
||||
document_id=document_id,
|
||||
kararNo="",
|
||||
markdown_content="",
|
||||
source_url="",
|
||||
error_message="Could not get document URL from GetSorgulamaUrl API"
|
||||
)
|
||||
|
||||
# If document_id is numeric, encrypt it to get the KararId hash
|
||||
# The web interface uses AES-256-CBC encrypted hashes for document URLs
|
||||
karar_id = document_id
|
||||
if document_id.isdigit():
|
||||
try:
|
||||
karar_id = self.encrypt_document_id(document_id)
|
||||
logger.info(f"KikV2ApiClient: Encrypted numeric ID {document_id} to hash: {karar_id}")
|
||||
except Exception as enc_error:
|
||||
logger.warning(f"KikV2ApiClient: Could not encrypt document ID, using as-is: {enc_error}")
|
||||
|
||||
# Construct full document URL with the encrypted KararId
|
||||
document_url = f"{base_document_url}?KararId={karar_id}"
|
||||
logger.info(f"KikV2ApiClient: Step 2 - Retrieved document URL: {document_url}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"KikV2ApiClient: Error getting document URL for ID {document_id}: {str(e)}")
|
||||
# Fallback to old method if GetSorgulamaUrl fails
|
||||
# Also encrypt numeric IDs in fallback path
|
||||
karar_id = document_id
|
||||
if document_id.isdigit():
|
||||
try:
|
||||
karar_id = self.encrypt_document_id(document_id)
|
||||
logger.info(f"KikV2ApiClient: Encrypted numeric ID in fallback: {karar_id}")
|
||||
except Exception as enc_error:
|
||||
logger.warning(f"KikV2ApiClient: Could not encrypt in fallback: {enc_error}")
|
||||
document_url = f"https://ekap.kik.gov.tr/EKAP/Vatandas/KurulKararGoster.aspx?KararId={karar_id}"
|
||||
logger.info(f"KikV2ApiClient: Falling back to direct URL: {document_url}")
|
||||
|
||||
try:
|
||||
# Step 2: Use httpx to get the document content
|
||||
logger.info(f"KikV2ApiClient: Step 2 - Using httpx to retrieve document from: {document_url}")
|
||||
|
||||
# Create a separate httpx client for document retrieval with HTML headers
|
||||
doc_ssl_context = ssl.create_default_context()
|
||||
doc_ssl_context.check_hostname = False
|
||||
doc_ssl_context.verify_mode = ssl.CERT_NONE
|
||||
if hasattr(ssl, 'OP_LEGACY_SERVER_CONNECT'):
|
||||
doc_ssl_context.options |= ssl.OP_LEGACY_SERVER_CONNECT
|
||||
doc_ssl_context.set_ciphers('ALL:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA')
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
verify=doc_ssl_context,
|
||||
headers={
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "tr,en-US;q=0.5",
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"
|
||||
},
|
||||
timeout=60.0,
|
||||
follow_redirects=True
|
||||
) as doc_client:
|
||||
response = await doc_client.get(document_url)
|
||||
response.raise_for_status()
|
||||
html_content = response.text
|
||||
logger.info(f"KikV2ApiClient: Retrieved content via httpx, length: {len(html_content)}")
|
||||
|
||||
# Convert HTML to Markdown using MarkItDown with BytesIO
|
||||
try:
|
||||
from markitdown import MarkItDown
|
||||
from io import BytesIO
|
||||
|
||||
md = MarkItDown()
|
||||
html_bytes = html_content.encode('utf-8')
|
||||
html_stream = BytesIO(html_bytes)
|
||||
|
||||
# markitdown is sync; offload to thread so HTML parsing doesn't
|
||||
# block the event-loop / other in-flight MCP requests.
|
||||
result = await asyncio.to_thread(md.convert_stream, html_stream, file_extension=".html")
|
||||
markdown_content = result.text_content
|
||||
|
||||
return KikV2DocumentMarkdown(
|
||||
document_id=document_id,
|
||||
kararNo="",
|
||||
markdown_content=markdown_content,
|
||||
source_url=document_url,
|
||||
error_message=""
|
||||
)
|
||||
|
||||
except ImportError:
|
||||
return KikV2DocumentMarkdown(
|
||||
document_id=document_id,
|
||||
kararNo="",
|
||||
markdown_content="MarkItDown library not available",
|
||||
source_url=document_url,
|
||||
error_message="MarkItDown library not installed"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"KikV2ApiClient: Error retrieving document {document_id}: {str(e)}")
|
||||
return KikV2DocumentMarkdown(
|
||||
document_id=document_id,
|
||||
kararNo="",
|
||||
markdown_content="",
|
||||
source_url=document_url,
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
async def close_client_session(self):
|
||||
"""Close HTTP client session."""
|
||||
await self.http_client.aclose()
|
||||
logger.info("KikV2ApiClient: HTTP client session closed.")
|
||||
@@ -0,0 +1,147 @@
|
||||
# kik_mcp_module/models_v2.py
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
# New KIK v2 API Models
|
||||
|
||||
class KikV2DecisionType(str, Enum):
|
||||
"""KIK v2 Decision Types with corresponding endpoints."""
|
||||
UYUSMAZLIK = "uyusmazlik" # Disputes - GetKurulKararlari
|
||||
DUZENLEYICI = "duzenleyici" # Regulatory - GetKurulKararlariDk
|
||||
MAHKEME = "mahkeme" # Court - GetKurulKararlariMk
|
||||
|
||||
class KikV2SearchRequest(BaseModel):
|
||||
"""Model for KIK v2 API search request."""
|
||||
KararMetni: str = Field("", description="Decision text search query")
|
||||
KararNo: str = Field("", description="Decision number (e.g., '2025/UH.II-1801')")
|
||||
BasvuranAdi: str = Field("", description="Applicant name")
|
||||
IdareAdi: str = Field("", description="Administration name")
|
||||
BaslangicTarihi: str = Field("", description="Start date (YYYY-MM-DD)")
|
||||
BitisTarihi: str = Field("", description="End date (YYYY-MM-DD)")
|
||||
|
||||
class KikV2KeyValuePair(BaseModel):
|
||||
"""Key-value pair for KIK v2 API request."""
|
||||
key: str
|
||||
value: str
|
||||
|
||||
class KikV2QueryRequest(BaseModel):
|
||||
"""Nested query structure for KIK v2 API."""
|
||||
keyValueOfstringanyType: List[KikV2KeyValuePair]
|
||||
|
||||
class KikV2RequestData(BaseModel):
|
||||
"""Main request data structure for KIK v2 API."""
|
||||
keyValuePairs: KikV2QueryRequest
|
||||
|
||||
# Request Payloads for different decision types
|
||||
class KikV2SearchPayload(BaseModel):
|
||||
"""Complete payload for KIK v2 API search - Uyuşmazlık (Disputes)."""
|
||||
sorgulaKurulKararlari: KikV2RequestData
|
||||
|
||||
class KikV2SearchPayloadDk(BaseModel):
|
||||
"""Complete payload for KIK v2 API search - Düzenleyici (Regulatory)."""
|
||||
sorgulaKurulKararlariDk: KikV2RequestData
|
||||
|
||||
class KikV2SearchPayloadMk(BaseModel):
|
||||
"""Complete payload for KIK v2 API search - Mahkeme (Court)."""
|
||||
sorgulaKurulKararlariMk: KikV2RequestData
|
||||
|
||||
# Response Models
|
||||
|
||||
class KikV2DecisionDetail(BaseModel):
|
||||
"""Individual decision detail from KIK v2 API response."""
|
||||
resmiGazeteMukerrerSayi: str = Field("", description="Official Gazette duplicate number")
|
||||
itiraz: str = Field("", description="Objection")
|
||||
yayinlanmaTarihi: str = Field("", description="Publication date")
|
||||
idareAdi: str = Field("", description="Administration name")
|
||||
uzmanTCKN: str = Field("", description="Expert TCKN")
|
||||
resmiGazeteTarihi: str = Field("", description="Official Gazette date")
|
||||
basvuruKonusu: str = Field("", description="Application subject")
|
||||
kararTurKod: str = Field("", description="Decision type code")
|
||||
kararTurAciklama: str = Field("", description="Decision type description")
|
||||
karar: str = Field("", description="Decision text")
|
||||
kararNo: str = Field("", description="Decision number")
|
||||
resmiGazeteSayisi: str = Field("", description="Official Gazette number")
|
||||
inceleme: str = Field("", description="Review")
|
||||
basvuruTarihi: str = Field("", description="Application date")
|
||||
kararNitelikKod: str = Field("", description="Decision nature code")
|
||||
resmiGazeteMukerrer: str = Field("", description="Official Gazette duplicate")
|
||||
basvuruSayisi: str = Field("", description="Application number")
|
||||
basvuran: str = Field("", description="Applicant")
|
||||
kararNitelik: str = Field("", description="Decision nature")
|
||||
uyusmazlikKararNo: str = Field("", description="Dispute decision number")
|
||||
kurulNo: str = Field("", description="Board number")
|
||||
gundemMaddesiSiraNo: str = Field("", description="Agenda item sequence")
|
||||
kararTarihi: str = Field("", description="Decision date (ISO format)")
|
||||
dosyaBirimKodu: str = Field("", description="File unit code")
|
||||
gundemMaddesiId: str = Field("", description="Agenda item ID")
|
||||
|
||||
class KikV2DecisionGroup(BaseModel):
|
||||
"""Group of decision details."""
|
||||
KurulKararTutanakDetayi: List[KikV2DecisionDetail] = Field(alias="kurulKararTutanakDetayi")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
class KikV2SearchResultData(BaseModel):
|
||||
"""Search result data structure."""
|
||||
hataKodu: str = Field("", description="Error code")
|
||||
hataMesaji: str = Field("", description="Error message")
|
||||
KurulKararTutanakDetayListesi: List[KikV2DecisionGroup]
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
class KikV2SearchResultWrapper(BaseModel):
|
||||
"""Wrapper for search result."""
|
||||
SorgulaKurulKararlariResult: KikV2SearchResultData
|
||||
|
||||
# Base Response Models
|
||||
class KikV2SearchResponse(BaseModel):
|
||||
"""Complete KIK v2 API search response for Uyuşmazlık (Disputes)."""
|
||||
SorgulaKurulKararlariResponse: KikV2SearchResultWrapper
|
||||
|
||||
# Düzenleyici Kararlar (Regulatory Decisions) Response Models
|
||||
class KikV2SearchResultWrapperDk(BaseModel):
|
||||
"""Wrapper for regulatory decisions search result."""
|
||||
SorgulaKurulKararlariDkResult: KikV2SearchResultData
|
||||
|
||||
class KikV2SearchResponseDk(BaseModel):
|
||||
"""Complete KIK v2 API search response for Düzenleyici (Regulatory) decisions."""
|
||||
SorgulaKurulKararlariDkResponse: KikV2SearchResultWrapperDk
|
||||
|
||||
# Mahkeme Kararlar (Court Decisions) Response Models
|
||||
class KikV2SearchResultWrapperMk(BaseModel):
|
||||
"""Wrapper for court decisions search result."""
|
||||
SorgulaKurulKararlariMkResult: KikV2SearchResultData
|
||||
|
||||
class KikV2SearchResponseMk(BaseModel):
|
||||
"""Complete KIK v2 API search response for Mahkeme (Court) decisions."""
|
||||
SorgulaKurulKararlariMkResponse: KikV2SearchResultWrapperMk
|
||||
|
||||
# Simplified Models for MCP Tools
|
||||
|
||||
class KikV2CompactDecision(BaseModel):
|
||||
"""Compact decision format for MCP tool responses."""
|
||||
kararNo: str = Field("", description="Decision number")
|
||||
kararTarihi: str = Field("", description="Decision date")
|
||||
basvuran: str = Field("", description="Applicant")
|
||||
idareAdi: str = Field("", description="Administration")
|
||||
basvuruKonusu: str = Field("", description="Application subject")
|
||||
gundemMaddesiId: str = Field("", description="Document ID for retrieval")
|
||||
decision_type: str = Field("", description="Decision type (uyusmazlik/duzenleyici/mahkeme)")
|
||||
|
||||
class KikV2SearchResult(BaseModel):
|
||||
"""Compact search results for MCP tools."""
|
||||
decisions: List[KikV2CompactDecision]
|
||||
total_records: int = Field(0, description="Total number of decisions found")
|
||||
page: int = Field(1, description="Current page number")
|
||||
error_code: str = Field("", description="API error code")
|
||||
error_message: str = Field("", description="API error message")
|
||||
|
||||
class KikV2DocumentMarkdown(BaseModel):
|
||||
"""Document content in Markdown format."""
|
||||
document_id: str = Field("", description="Document ID")
|
||||
kararNo: str = Field("", description="Decision number")
|
||||
markdown_content: str = Field("", description="Decision content in Markdown")
|
||||
source_url: str = Field("", description="Source URL")
|
||||
error_message: str = Field("", description="Error message if retrieval failed")
|
||||
@@ -0,0 +1 @@
|
||||
# kvkk_mcp_module/__init__.py
|
||||
@@ -0,0 +1,373 @@
|
||||
# kvkk_mcp_module/client.py
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
from typing import List, Optional, Dict, Any
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import io
|
||||
import math
|
||||
from urllib.parse import urljoin, urlparse, parse_qs
|
||||
from markitdown import MarkItDown
|
||||
from pydantic import HttpUrl
|
||||
|
||||
from .models import (
|
||||
KvkkSearchRequest,
|
||||
KvkkDecisionSummary,
|
||||
KvkkSearchResult,
|
||||
KvkkDocumentMarkdown
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if not logger.hasHandlers():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
class KvkkApiClient:
|
||||
"""
|
||||
API client for searching and retrieving KVKK (Personal Data Protection Authority) decisions
|
||||
using Brave Search API for discovery and direct HTTP requests for content retrieval.
|
||||
"""
|
||||
|
||||
BRAVE_API_URL = "https://api.search.brave.com/res/v1/web/search"
|
||||
KVKK_BASE_URL = "https://www.kvkk.gov.tr"
|
||||
DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000 # Character limit per page
|
||||
|
||||
def __init__(self, request_timeout: float = 60.0):
|
||||
"""Initialize the KVKK API client."""
|
||||
self.brave_api_token = os.getenv("BRAVE_API_TOKEN")
|
||||
if not self.brave_api_token:
|
||||
# Fallback to provided free token
|
||||
self.brave_api_token = "BSAuaRKB-dvSDSQxIN0ft1p2k6N82Kq"
|
||||
logger.info("Using fallback Brave API token (limited free token)")
|
||||
else:
|
||||
logger.info("Using Brave API token from environment variable")
|
||||
|
||||
self.http_client = httpx.AsyncClient(
|
||||
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 _construct_search_query(self, keywords: str) -> str:
|
||||
"""Construct the search query for Brave API."""
|
||||
base_query = 'site:kvkk.gov.tr "karar özeti"'
|
||||
if keywords.strip():
|
||||
return f"{base_query} {keywords.strip()}"
|
||||
return base_query
|
||||
|
||||
def _extract_decision_id_from_url(self, url: str) -> Optional[str]:
|
||||
"""Extract decision ID from KVKK decision URL."""
|
||||
try:
|
||||
# Example URL: https://www.kvkk.gov.tr/Icerik/7288/2021-1303
|
||||
parsed_url = urlparse(url)
|
||||
path_parts = parsed_url.path.strip('/').split('/')
|
||||
|
||||
if len(path_parts) >= 3 and path_parts[0] == 'Icerik':
|
||||
# Extract the decision ID from the path
|
||||
decision_id = '/'.join(path_parts[1:]) # e.g., "7288/2021-1303"
|
||||
return decision_id
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not extract decision ID from URL {url}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _extract_decision_metadata_from_title(self, title: str) -> Dict[str, Optional[str]]:
|
||||
"""Extract decision metadata from title string."""
|
||||
metadata = {
|
||||
"decision_date": None,
|
||||
"decision_number": None
|
||||
}
|
||||
|
||||
if not title:
|
||||
return metadata
|
||||
|
||||
# Extract decision date (DD/MM/YYYY format)
|
||||
date_match = re.search(r'(\d{1,2}/\d{1,2}/\d{4})', title)
|
||||
if date_match:
|
||||
metadata["decision_date"] = date_match.group(1)
|
||||
|
||||
# Extract decision number (YYYY/XXXX format)
|
||||
number_match = re.search(r'(\d{4}/\d+)', title)
|
||||
if number_match:
|
||||
metadata["decision_number"] = number_match.group(1)
|
||||
|
||||
return metadata
|
||||
|
||||
async def search_decisions(self, params: KvkkSearchRequest) -> KvkkSearchResult:
|
||||
"""Search for KVKK decisions using Brave API."""
|
||||
|
||||
search_query = self._construct_search_query(params.keywords)
|
||||
logger.info(f"KvkkApiClient: Searching with query: {search_query}")
|
||||
|
||||
try:
|
||||
# Calculate offset for pagination
|
||||
offset = (params.page - 1) * params.pageSize
|
||||
|
||||
response = await self.http_client.get(
|
||||
self.BRAVE_API_URL,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "gzip",
|
||||
"x-subscription-token": self.brave_api_token
|
||||
},
|
||||
params={
|
||||
"q": search_query,
|
||||
"country": "TR",
|
||||
"search_lang": "tr",
|
||||
"ui_lang": "tr-TR",
|
||||
"offset": offset,
|
||||
"count": params.pageSize
|
||||
}
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Extract search results
|
||||
decisions = []
|
||||
web_results = data.get("web", {}).get("results", [])
|
||||
|
||||
for result in web_results:
|
||||
title = result.get("title", "")
|
||||
url = result.get("url", "")
|
||||
description = result.get("description", "")
|
||||
|
||||
# Extract metadata from title
|
||||
metadata = self._extract_decision_metadata_from_title(title)
|
||||
|
||||
# Extract decision ID from URL
|
||||
decision_id = self._extract_decision_id_from_url(url)
|
||||
|
||||
decision = KvkkDecisionSummary(
|
||||
title=title,
|
||||
url=HttpUrl(url) if url else None,
|
||||
description=description,
|
||||
decision_id=decision_id,
|
||||
publication_date=metadata.get("decision_date"),
|
||||
decision_number=metadata.get("decision_number")
|
||||
)
|
||||
decisions.append(decision)
|
||||
|
||||
# Get total results if available
|
||||
total_results = None
|
||||
query_info = data.get("query", {})
|
||||
if "total_results" in query_info:
|
||||
total_results = query_info["total_results"]
|
||||
|
||||
return KvkkSearchResult(
|
||||
decisions=decisions,
|
||||
total_results=total_results,
|
||||
page=params.page,
|
||||
pageSize=params.pageSize,
|
||||
query=search_query
|
||||
)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"KvkkApiClient: HTTP request error during search: {e}")
|
||||
return KvkkSearchResult(
|
||||
decisions=[],
|
||||
total_results=0,
|
||||
page=params.page,
|
||||
pageSize=params.pageSize,
|
||||
query=search_query
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"KvkkApiClient: Unexpected error during search: {e}")
|
||||
return KvkkSearchResult(
|
||||
decisions=[],
|
||||
total_results=0,
|
||||
page=params.page,
|
||||
pageSize=params.pageSize,
|
||||
query=search_query
|
||||
)
|
||||
|
||||
def _extract_decision_content_from_html(self, html: str, url: str) -> Dict[str, Any]:
|
||||
"""Extract decision content from KVKK decision page HTML."""
|
||||
try:
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
# Extract title
|
||||
title = None
|
||||
title_element = soup.find('h3', class_='blog-post-title')
|
||||
if title_element:
|
||||
title = title_element.get_text(strip=True)
|
||||
elif soup.title:
|
||||
title = soup.title.get_text(strip=True)
|
||||
|
||||
# Extract decision content from the main content div
|
||||
content_div = soup.find('div', class_='blog-post-inner')
|
||||
if not content_div:
|
||||
# Fallback to other possible content containers
|
||||
content_div = soup.find('div', style='text-align:justify;')
|
||||
if not content_div:
|
||||
logger.warning(f"Could not find decision content div in {url}")
|
||||
return {
|
||||
"title": title,
|
||||
"decision_date": None,
|
||||
"decision_number": None,
|
||||
"subject_summary": None,
|
||||
"html_content": None
|
||||
}
|
||||
|
||||
# Extract decision metadata from table
|
||||
decision_date = None
|
||||
decision_number = None
|
||||
subject_summary = None
|
||||
|
||||
table = content_div.find('table')
|
||||
if table:
|
||||
rows = table.find_all('tr')
|
||||
for row in rows:
|
||||
cells = row.find_all('td')
|
||||
if len(cells) >= 3:
|
||||
field_name = cells[0].get_text(strip=True)
|
||||
field_value = cells[2].get_text(strip=True)
|
||||
|
||||
if 'Karar Tarihi' in field_name:
|
||||
decision_date = field_value
|
||||
elif 'Karar No' in field_name:
|
||||
decision_number = field_value
|
||||
elif 'Konu Özeti' in field_name:
|
||||
subject_summary = field_value
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"decision_date": decision_date,
|
||||
"decision_number": decision_number,
|
||||
"subject_summary": subject_summary,
|
||||
"html_content": str(content_div)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting content from HTML for {url}: {e}")
|
||||
return {
|
||||
"title": None,
|
||||
"decision_date": None,
|
||||
"decision_number": None,
|
||||
"subject_summary": None,
|
||||
"html_content": None
|
||||
}
|
||||
|
||||
def _convert_html_to_markdown(self, html_content: str) -> Optional[str]:
|
||||
"""Convert HTML content to Markdown using MarkItDown with BytesIO to avoid filename length issues."""
|
||||
if not html_content:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Convert HTML string to bytes and create BytesIO stream
|
||||
html_bytes = html_content.encode('utf-8')
|
||||
html_stream = io.BytesIO(html_bytes)
|
||||
|
||||
# Pass BytesIO stream to MarkItDown to avoid temp file creation
|
||||
md_converter = MarkItDown(enable_plugins=False)
|
||||
result = md_converter.convert(html_stream)
|
||||
return result.text_content
|
||||
except Exception as e:
|
||||
logger.error(f"Error converting HTML to Markdown: {e}")
|
||||
return None
|
||||
|
||||
async def get_decision_document(self, decision_url: str, page_number: int = 1) -> KvkkDocumentMarkdown:
|
||||
"""Retrieve and convert a KVKK decision document to paginated Markdown."""
|
||||
logger.info(f"KvkkApiClient: Getting decision document from: {decision_url}, page: {page_number}")
|
||||
|
||||
try:
|
||||
# Fetch the decision page
|
||||
response = await self.http_client.get(decision_url)
|
||||
response.raise_for_status()
|
||||
|
||||
# Extract content from HTML
|
||||
extracted_data = self._extract_decision_content_from_html(response.text, decision_url)
|
||||
|
||||
# Convert HTML content to Markdown
|
||||
full_markdown_content = None
|
||||
if extracted_data["html_content"]:
|
||||
full_markdown_content = await asyncio.to_thread(self._convert_html_to_markdown, extracted_data["html_content"])
|
||||
|
||||
if not full_markdown_content:
|
||||
return KvkkDocumentMarkdown(
|
||||
source_url=HttpUrl(decision_url),
|
||||
title=extracted_data["title"],
|
||||
decision_date=extracted_data["decision_date"],
|
||||
decision_number=extracted_data["decision_number"],
|
||||
subject_summary=extracted_data["subject_summary"],
|
||||
markdown_chunk=None,
|
||||
current_page=page_number,
|
||||
total_pages=0,
|
||||
is_paginated=False,
|
||||
error_message="Could not convert document content to Markdown"
|
||||
)
|
||||
|
||||
# Calculate pagination
|
||||
content_length = len(full_markdown_content)
|
||||
total_pages = math.ceil(content_length / self.DOCUMENT_MARKDOWN_CHUNK_SIZE)
|
||||
if total_pages == 0:
|
||||
total_pages = 1
|
||||
|
||||
# Clamp page number to valid range
|
||||
current_page_clamped = max(1, min(page_number, total_pages))
|
||||
|
||||
# Extract the requested chunk
|
||||
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 KvkkDocumentMarkdown(
|
||||
source_url=HttpUrl(decision_url),
|
||||
title=extracted_data["title"],
|
||||
decision_date=extracted_data["decision_date"],
|
||||
decision_number=extracted_data["decision_number"],
|
||||
subject_summary=extracted_data["subject_summary"],
|
||||
markdown_chunk=markdown_chunk,
|
||||
current_page=current_page_clamped,
|
||||
total_pages=total_pages,
|
||||
is_paginated=(total_pages > 1),
|
||||
error_message=None
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_msg = f"HTTP error {e.response.status_code} when fetching decision document"
|
||||
logger.error(f"KvkkApiClient: {error_msg}")
|
||||
return KvkkDocumentMarkdown(
|
||||
source_url=HttpUrl(decision_url),
|
||||
title=None,
|
||||
decision_date=None,
|
||||
decision_number=None,
|
||||
subject_summary=None,
|
||||
markdown_chunk=None,
|
||||
current_page=page_number,
|
||||
total_pages=0,
|
||||
is_paginated=False,
|
||||
error_message=error_msg
|
||||
)
|
||||
except Exception as e:
|
||||
error_msg = f"Unexpected error when fetching decision document: {str(e)}"
|
||||
logger.error(f"KvkkApiClient: {error_msg}")
|
||||
return KvkkDocumentMarkdown(
|
||||
source_url=HttpUrl(decision_url),
|
||||
title=None,
|
||||
decision_date=None,
|
||||
decision_number=None,
|
||||
subject_summary=None,
|
||||
markdown_chunk=None,
|
||||
current_page=page_number,
|
||||
total_pages=0,
|
||||
is_paginated=False,
|
||||
error_message=error_msg
|
||||
)
|
||||
|
||||
async def close_client_session(self):
|
||||
"""Close the HTTP client session."""
|
||||
if hasattr(self, 'http_client') and self.http_client and not self.http_client.is_closed:
|
||||
await self.http_client.aclose()
|
||||
logger.info("KvkkApiClient: HTTP client session closed.")
|
||||
@@ -0,0 +1,49 @@
|
||||
# kvkk_mcp_module/models.py
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
from typing import List, Optional, Any
|
||||
|
||||
class KvkkSearchRequest(BaseModel):
|
||||
"""Model for KVKK (Personal Data Protection Authority) search request via Brave API."""
|
||||
keywords: str = Field(..., description="""
|
||||
Keywords to search for in KVKK decisions.
|
||||
The search will automatically include 'site:kvkk.gov.tr "karar özeti"' to target KVKK decision summaries.
|
||||
Examples: "açık rıza", "veri güvenliği", "kişisel veri işleme"
|
||||
""")
|
||||
page: int = Field(1, ge=1, le=50, description="Page number for search results (1-50).")
|
||||
pageSize: int = Field(10, ge=1, le=10, description="Number of results per page (1-10).")
|
||||
|
||||
class KvkkDecisionSummary(BaseModel):
|
||||
"""Model for a single KVKK decision summary from Brave search results."""
|
||||
title: Optional[str] = Field(None, description="Decision title from search results.")
|
||||
url: Optional[HttpUrl] = Field(None, description="URL to the KVKK decision page.")
|
||||
description: Optional[str] = Field(None, description="Brief description or snippet from search results.")
|
||||
decision_id: Optional[str] = Field(None, description="Value")
|
||||
publication_date: Optional[str] = Field(None, description="Value")
|
||||
decision_number: Optional[str] = Field(None, description="Value")
|
||||
|
||||
class KvkkSearchResult(BaseModel):
|
||||
"""Model for the overall search result for KVKK decisions."""
|
||||
decisions: List[KvkkDecisionSummary] = Field(default_factory=list, description="List of KVKK decisions found.")
|
||||
total_results: Optional[int] = Field(None, description="Value")
|
||||
page: int = Field(1, description="Current page number of results.")
|
||||
pageSize: int = Field(10, description="Number of results per page.")
|
||||
query: Optional[str] = Field(None, description="The actual search query sent to Brave API.")
|
||||
|
||||
class KvkkDocumentMarkdown(BaseModel):
|
||||
"""Model for KVKK decision document content converted to paginated Markdown."""
|
||||
source_url: HttpUrl = Field(description="URL of the original KVKK decision page.")
|
||||
title: Optional[str] = Field(None, description="Title of the KVKK decision.")
|
||||
decision_date: Optional[str] = Field(None, description="Decision date (Karar Tarihi).")
|
||||
decision_number: Optional[str] = Field(None, description="Decision number (Karar No).")
|
||||
subject_summary: Optional[str] = Field(None, description="Subject summary (Konu Özeti).")
|
||||
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.")
|
||||
error_message: Optional[str] = Field(None, description="Value")
|
||||
|
||||
class Config:
|
||||
json_encoders = {
|
||||
HttpUrl: str
|
||||
}
|
||||
+2602
-178
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Migration stub for the deprecated Yargı MCP endpoint.
|
||||
|
||||
Exposes a single tool that informs the MCP client the server has moved
|
||||
and the user must update their configuration.
|
||||
|
||||
Entrypoint variable `app` is a FastMCP instance so it works with
|
||||
Dokploy's FastMCP build pipeline (`fastmcp inspect`, `fastmcp run`).
|
||||
|
||||
Run with:
|
||||
fastmcp run migration_app.py:app --transport http --port 8000
|
||||
"""
|
||||
|
||||
from starlette.responses import JSONResponse
|
||||
from fastmcp import FastMCP
|
||||
|
||||
NEW_URL = "https://yargimcp.surucu.dev/mcp"
|
||||
|
||||
app = FastMCP(
|
||||
name="Yargı MCP (taşındı / moved)",
|
||||
instructions=(
|
||||
f"Bu Yargı MCP endpoint'i kullanımdan kaldırıldı. "
|
||||
f"Sunucu yeni adrese taşındı: {NEW_URL}. "
|
||||
f"Kullanıcıya MCP istemci ayarlarındaki sunucu URL'sini güncellemesini söyle."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.tool(
|
||||
description=(
|
||||
"DEPRECATED ENDPOINT — Yargı MCP sunucusu yeni adrese taşındı. "
|
||||
"Bu endpoint'teki eski araçlar (Yargıtay, Danıştay, Anayasa Mahkemesi, "
|
||||
"KVKK, BDDK, Rekabet Kurumu, Sayıştay, KİK, Emsal, Uyuşmazlık, GİB, "
|
||||
"Sigorta Tahkim, Bedesten vb.) artık burada çalışmıyor. "
|
||||
"Bu aracı çağırarak yeni sunucu adresini ve migrasyon talimatını al, "
|
||||
"ardından kullanıcıya MCP istemci (Claude Desktop, 5ire, ChatGPT vb.) "
|
||||
"ayarlarındaki sunucu URL'sini güncellemesi gerektiğini açıkça söyle."
|
||||
)
|
||||
)
|
||||
def migration_notice() -> dict:
|
||||
"""Yargı MCP sunucusunun yeni adresini ve migrasyon talimatını döner."""
|
||||
return {
|
||||
"status": "moved",
|
||||
"new_url": NEW_URL,
|
||||
"message": (
|
||||
f"Yargı MCP sunucusu yeni adrese taşındı: {NEW_URL}\n\n"
|
||||
f"Lütfen MCP istemcinin (Claude Desktop, 5ire, ChatGPT vb.) "
|
||||
f"ayarlarındaki sunucu URL'sini yukarıdaki yeni adresle güncelleyin. "
|
||||
f"Mevcut endpoint artık kullanım dışıdır ve sadece bu uyarıyı döner."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@app.custom_route("/health", methods=["GET"])
|
||||
async def health(request):
|
||||
"""Health check endpoint for monitoring services."""
|
||||
return JSONResponse({"status": "deprecated", "new_url": NEW_URL})
|
||||
@@ -0,0 +1,61 @@
|
||||
[project]
|
||||
name = "yargi-mcp"
|
||||
version = "0.2.2"
|
||||
description = "MCP Server For Turkish Legal Databases"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = {text = "MIT"}
|
||||
authors = [{name = "Said Surucu", email = "saidsrc@gmail.com"}]
|
||||
keywords = ["mcp", "turkish-law", "legal", "yargitay", "danistay", "bddk", "btk", "kvkk", "turkish", "law", "court", "decisions"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Legal Industry",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Topic :: Text Processing :: Markup :: Markdown",
|
||||
"Operating System :: OS Independent",
|
||||
]
|
||||
urls = {Homepage = "https://github.com/saidsurucu/yargi-mcp", Issues = "https://github.com/saidsurucu/yargi-mcp/issues"}
|
||||
dependencies = [
|
||||
"beautifulsoup4>=4.13.4",
|
||||
"httpx>=0.28.1",
|
||||
"markitdown[pdf]>=0.1.1",
|
||||
"pydantic>=2.11.4",
|
||||
"aiohttp>=3.11.18",
|
||||
"fastmcp>=2.10.5",
|
||||
"pypdf>=5.5.0",
|
||||
"fastapi>=0.115.14",
|
||||
"cryptography>=44.0.0",
|
||||
"openai>=1.0.0",
|
||||
"numpy>=1.24.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
asgi = [
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
"starlette>=0.37.0",
|
||||
]
|
||||
api = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
]
|
||||
production = [
|
||||
"gunicorn>=22.0.0",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
yargi-mcp = "mcp_server_main:main"
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["mcp_server_main", "asgi_app"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["*_mcp_module", "semantic_search"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=65.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://railway.app/railway.schema.json",
|
||||
"build": {
|
||||
"builder": "NIXPACKS",
|
||||
"buildCommand": "pip install -e .[asgi]"
|
||||
},
|
||||
"deploy": {
|
||||
"startCommand": "uvicorn asgi_app:app --host 0.0.0.0 --port $PORT",
|
||||
"healthcheckPath": "/health",
|
||||
"healthcheckTimeout": 30,
|
||||
"restartPolicyType": "ON_FAILURE",
|
||||
"restartPolicyMaxRetries": 3
|
||||
},
|
||||
"variables": {
|
||||
"ALLOWED_ORIGINS": "*",
|
||||
"LOG_LEVEL": "info"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
"""
|
||||
Redis Session Store for OAuth Authorization Codes and User Sessions
|
||||
|
||||
This module provides Redis-based storage for OAuth authorization codes and user sessions,
|
||||
enabling multi-machine deployment support by replacing in-memory storage.
|
||||
|
||||
Uses Upstash Redis via REST API for serverless-friendly operation.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Union
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from upstash_redis import Redis
|
||||
UPSTASH_AVAILABLE = True
|
||||
except ImportError:
|
||||
UPSTASH_AVAILABLE = False
|
||||
Redis = None
|
||||
|
||||
# Use standard Python exceptions for Redis connection errors
|
||||
import socket
|
||||
from requests.exceptions import ConnectionError as RequestsConnectionError, Timeout as RequestsTimeout
|
||||
|
||||
class RedisSessionStore:
|
||||
"""
|
||||
Redis-based session store for OAuth flows and user sessions.
|
||||
|
||||
Uses Upstash Redis REST API for connection-free operation suitable for
|
||||
multi-instance deployments on platforms like Fly.io.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Redis connection using environment variables."""
|
||||
if not UPSTASH_AVAILABLE:
|
||||
raise ImportError("upstash-redis package is required. Install with: pip install upstash-redis")
|
||||
|
||||
# Initialize Upstash Redis client from environment with optimized connection settings
|
||||
try:
|
||||
# Get Upstash Redis configuration
|
||||
redis_url = os.getenv("UPSTASH_REDIS_REST_URL")
|
||||
redis_token = os.getenv("UPSTASH_REDIS_REST_TOKEN")
|
||||
|
||||
if not redis_url or not redis_token:
|
||||
raise ValueError("UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN must be set")
|
||||
|
||||
logger.info(f"Connecting to Upstash Redis at {redis_url[:30]}...")
|
||||
|
||||
# Initialize with explicit configuration for better SSL handling
|
||||
self.redis = Redis(
|
||||
url=redis_url,
|
||||
token=redis_token
|
||||
)
|
||||
|
||||
logger.info("Upstash Redis client created")
|
||||
|
||||
# Skip connection test during initialization to prevent server hang
|
||||
# Connection will be tested during first actual operation
|
||||
logger.info("Redis client initialized - connection will be tested on first use")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Upstash Redis: {e}")
|
||||
raise
|
||||
|
||||
# TTL values (in seconds)
|
||||
self.oauth_code_ttl = int(os.getenv("OAUTH_CODE_TTL", "600")) # 10 minutes
|
||||
self.session_ttl = int(os.getenv("SESSION_TTL", "3600")) # 1 hour
|
||||
|
||||
def _serialize_data(self, data: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""Convert data to Redis-compatible string format."""
|
||||
serialized = {}
|
||||
for key, value in data.items():
|
||||
if isinstance(value, (dict, list)):
|
||||
serialized[key] = json.dumps(value)
|
||||
elif isinstance(value, (int, float)):
|
||||
serialized[key] = str(value)
|
||||
elif isinstance(value, bool):
|
||||
serialized[key] = "true" if value else "false"
|
||||
else:
|
||||
serialized[key] = str(value)
|
||||
return serialized
|
||||
|
||||
def _deserialize_data(self, data: Dict[str, str]) -> Dict[str, Any]:
|
||||
"""Convert Redis string data back to original types."""
|
||||
if not data:
|
||||
return {}
|
||||
|
||||
deserialized = {}
|
||||
for key, value in data.items():
|
||||
if not isinstance(value, str):
|
||||
deserialized[key] = value
|
||||
continue
|
||||
|
||||
# Try to deserialize JSON
|
||||
if value.startswith(('[', '{')):
|
||||
try:
|
||||
deserialized[key] = json.loads(value)
|
||||
continue
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Try to convert numbers
|
||||
if value.isdigit():
|
||||
deserialized[key] = int(value)
|
||||
continue
|
||||
|
||||
if value.replace('.', '').isdigit():
|
||||
try:
|
||||
deserialized[key] = float(value)
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Handle booleans
|
||||
if value in ("true", "false"):
|
||||
deserialized[key] = value == "true"
|
||||
continue
|
||||
|
||||
# Keep as string
|
||||
deserialized[key] = value
|
||||
|
||||
return deserialized
|
||||
|
||||
# OAuth Authorization Code Methods
|
||||
|
||||
def set_oauth_code(self, code: str, data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Store OAuth authorization code with automatic expiration.
|
||||
|
||||
Args:
|
||||
code: Authorization code string
|
||||
data: Code data including user_id, client_id, etc.
|
||||
|
||||
Returns:
|
||||
True if stored successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
key = f"oauth:code:{code}"
|
||||
|
||||
# Add timestamp for debugging
|
||||
data_with_timestamp = data.copy()
|
||||
data_with_timestamp.update({
|
||||
"created_at": time.time(),
|
||||
"expires_at": time.time() + self.oauth_code_ttl
|
||||
})
|
||||
|
||||
# Serialize and store - Upstash Redis doesn't support mapping parameter
|
||||
serialized_data = self._serialize_data(data_with_timestamp)
|
||||
|
||||
# Use individual hset calls for each field with retry logic
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Clear any existing data first
|
||||
self.redis.delete(key)
|
||||
|
||||
# Set all fields in a pipeline-like manner
|
||||
for field, value in serialized_data.items():
|
||||
self.redis.hset(key, field, value)
|
||||
|
||||
# Set expiration
|
||||
self.redis.expire(key, self.oauth_code_ttl)
|
||||
|
||||
logger.info(f"Stored OAuth code {code[:10]}... with TTL {self.oauth_code_ttl}s (attempt {attempt + 1})")
|
||||
return True
|
||||
|
||||
except (RequestsConnectionError, RequestsTimeout, OSError, socket.error) as e:
|
||||
logger.warning(f"Redis connection error on attempt {attempt + 1}: {e}")
|
||||
if attempt == max_retries - 1:
|
||||
raise # Re-raise on final attempt
|
||||
time.sleep(0.5 * (attempt + 1)) # Exponential backoff
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store OAuth code {code[:10]}... after {max_retries} attempts: {e}")
|
||||
return False
|
||||
|
||||
def get_oauth_code(self, code: str, delete_after_use: bool = True) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Retrieve OAuth authorization code data.
|
||||
|
||||
Args:
|
||||
code: Authorization code string
|
||||
delete_after_use: If True, delete the code after retrieval (one-time use)
|
||||
|
||||
Returns:
|
||||
Code data dictionary or None if not found/expired
|
||||
"""
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
key = f"oauth:code:{code}"
|
||||
|
||||
# Get all hash fields with retry
|
||||
data = self.redis.hgetall(key)
|
||||
|
||||
if not data:
|
||||
logger.warning(f"OAuth code {code[:10]}... not found or expired (attempt {attempt + 1})")
|
||||
return None
|
||||
|
||||
# Deserialize data
|
||||
deserialized_data = self._deserialize_data(data)
|
||||
|
||||
# Check manual expiration (in case Redis TTL failed)
|
||||
expires_at = deserialized_data.get("expires_at", 0)
|
||||
if expires_at and time.time() > expires_at:
|
||||
logger.warning(f"OAuth code {code[:10]}... manually expired")
|
||||
try:
|
||||
self.redis.delete(key)
|
||||
except Exception as del_error:
|
||||
logger.warning(f"Failed to delete expired code: {del_error}")
|
||||
return None
|
||||
|
||||
# Delete after use for security (one-time use)
|
||||
if delete_after_use:
|
||||
try:
|
||||
self.redis.delete(key)
|
||||
logger.info(f"Retrieved and deleted OAuth code {code[:10]}... (attempt {attempt + 1})")
|
||||
except Exception as del_error:
|
||||
logger.warning(f"Failed to delete code after use: {del_error}")
|
||||
# Continue anyway since we got the data
|
||||
else:
|
||||
logger.info(f"Retrieved OAuth code {code[:10]}... (not deleted, attempt {attempt + 1})")
|
||||
|
||||
return deserialized_data
|
||||
|
||||
except (RequestsConnectionError, RequestsTimeout, OSError, socket.error) as e:
|
||||
logger.warning(f"Redis connection error on retrieval attempt {attempt + 1}: {e}")
|
||||
if attempt == max_retries - 1:
|
||||
logger.error(f"Failed to retrieve OAuth code {code[:10]}... after {max_retries} attempts: {e}")
|
||||
return None
|
||||
time.sleep(0.5 * (attempt + 1)) # Exponential backoff
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve OAuth code {code[:10]}... on attempt {attempt + 1}: {e}")
|
||||
if attempt == max_retries - 1:
|
||||
return None
|
||||
time.sleep(0.5 * (attempt + 1))
|
||||
|
||||
return None
|
||||
|
||||
# User Session Methods
|
||||
|
||||
def set_session(self, session_id: str, user_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Store user session data with sliding expiration.
|
||||
|
||||
Args:
|
||||
session_id: Unique session identifier
|
||||
user_data: User session data (user_id, email, scopes, etc.)
|
||||
|
||||
Returns:
|
||||
True if stored successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
key = f"session:{session_id}"
|
||||
|
||||
# Add session metadata
|
||||
session_data = user_data.copy()
|
||||
session_data.update({
|
||||
"session_id": session_id,
|
||||
"created_at": time.time(),
|
||||
"last_accessed": time.time()
|
||||
})
|
||||
|
||||
# Serialize and store - Upstash Redis doesn't support mapping parameter
|
||||
serialized_data = self._serialize_data(session_data)
|
||||
|
||||
# Use individual hset calls for each field (Upstash compatibility)
|
||||
for field, value in serialized_data.items():
|
||||
self.redis.hset(key, field, value)
|
||||
self.redis.expire(key, self.session_ttl)
|
||||
|
||||
logger.info(f"Stored session {session_id[:10]}... with TTL {self.session_ttl}s")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store session {session_id[:10]}...: {e}")
|
||||
return False
|
||||
|
||||
def get_session(self, session_id: str, refresh_ttl: bool = True) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Retrieve user session data.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
refresh_ttl: If True, extend session TTL on access
|
||||
|
||||
Returns:
|
||||
Session data dictionary or None if not found/expired
|
||||
"""
|
||||
try:
|
||||
key = f"session:{session_id}"
|
||||
|
||||
# Get session data
|
||||
data = self.redis.hgetall(key)
|
||||
|
||||
if not data:
|
||||
logger.warning(f"Session {session_id[:10]}... not found or expired")
|
||||
return None
|
||||
|
||||
# Deserialize data
|
||||
session_data = self._deserialize_data(data)
|
||||
|
||||
# Update last accessed time and refresh TTL
|
||||
if refresh_ttl:
|
||||
session_data["last_accessed"] = time.time()
|
||||
self.redis.hset(key, "last_accessed", str(time.time()))
|
||||
self.redis.expire(key, self.session_ttl)
|
||||
logger.debug(f"Refreshed session {session_id[:10]}... TTL")
|
||||
|
||||
return session_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve session {session_id[:10]}...: {e}")
|
||||
return None
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""
|
||||
Delete user session (logout).
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
|
||||
Returns:
|
||||
True if deleted successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
key = f"session:{session_id}"
|
||||
result = self.redis.delete(key)
|
||||
|
||||
if result:
|
||||
logger.info(f"Deleted session {session_id[:10]}...")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Session {session_id[:10]}... not found for deletion")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete session {session_id[:10]}...: {e}")
|
||||
return False
|
||||
|
||||
# Health Check Methods
|
||||
|
||||
def health_check(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Perform Redis health check.
|
||||
|
||||
Returns:
|
||||
Health status dictionary
|
||||
"""
|
||||
try:
|
||||
# Test basic operations
|
||||
test_key = f"health:check:{int(time.time())}"
|
||||
test_value = {"timestamp": time.time(), "test": True}
|
||||
|
||||
# Test set - Use individual hset calls for Upstash compatibility
|
||||
serialized_test = self._serialize_data(test_value)
|
||||
for field, value in serialized_test.items():
|
||||
self.redis.hset(test_key, field, value)
|
||||
|
||||
# Test get
|
||||
retrieved = self.redis.hgetall(test_key)
|
||||
|
||||
# Test delete
|
||||
self.redis.delete(test_key)
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"redis_connected": True,
|
||||
"operations_working": bool(retrieved),
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Redis health check failed: {e}")
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"redis_connected": False,
|
||||
"error": str(e),
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get Redis usage statistics.
|
||||
|
||||
Returns:
|
||||
Statistics dictionary
|
||||
"""
|
||||
try:
|
||||
# Get basic info (not all Upstash plans support INFO command)
|
||||
stats = {
|
||||
"oauth_codes_pattern": "oauth:code:*",
|
||||
"sessions_pattern": "session:*",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
try:
|
||||
# Try to get counts (may fail on some Upstash plans)
|
||||
oauth_keys = self.redis.keys("oauth:code:*")
|
||||
session_keys = self.redis.keys("session:*")
|
||||
|
||||
stats.update({
|
||||
"active_oauth_codes": len(oauth_keys) if oauth_keys else 0,
|
||||
"active_sessions": len(session_keys) if session_keys else 0
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get detailed stats: {e}")
|
||||
stats["warning"] = "Detailed stats not available on this Redis plan"
|
||||
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Redis stats: {e}")
|
||||
return {"error": str(e), "timestamp": datetime.utcnow().isoformat()}
|
||||
|
||||
# Global instance for easy importing
|
||||
redis_store = None
|
||||
|
||||
def get_redis_store() -> Optional[RedisSessionStore]:
|
||||
"""
|
||||
Get global Redis store instance (singleton pattern).
|
||||
|
||||
Returns:
|
||||
RedisSessionStore instance or None if initialization fails
|
||||
"""
|
||||
global redis_store
|
||||
|
||||
if redis_store is None:
|
||||
try:
|
||||
logger.info("Initializing Redis store...")
|
||||
redis_store = RedisSessionStore()
|
||||
logger.info("Redis store initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Redis store: {e}")
|
||||
redis_store = None
|
||||
|
||||
return redis_store
|
||||
|
||||
def init_redis_store() -> RedisSessionStore:
|
||||
"""
|
||||
Initialize Redis store and perform health check.
|
||||
|
||||
Returns:
|
||||
RedisSessionStore instance
|
||||
|
||||
Raises:
|
||||
Exception if Redis is not available or unhealthy
|
||||
"""
|
||||
store = get_redis_store()
|
||||
|
||||
# Perform health check
|
||||
health = store.health_check()
|
||||
|
||||
if health["status"] != "healthy":
|
||||
raise Exception(f"Redis health check failed: {health}")
|
||||
|
||||
logger.info("Redis session store initialized and healthy")
|
||||
return store
|
||||
@@ -0,0 +1,404 @@
|
||||
# rekabet_mcp_module/client.py
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
from typing import List, Optional, Tuple, Dict, Any
|
||||
import logging
|
||||
import html
|
||||
import re
|
||||
import io # For io.BytesIO
|
||||
from urllib.parse import urlencode, urljoin, quote, parse_qs, urlparse
|
||||
from markitdown import MarkItDown
|
||||
import math
|
||||
|
||||
# pypdf for PDF processing (lighter alternative to PyMuPDF)
|
||||
from pypdf import PdfReader, PdfWriter # PyPDF2'nin devamı niteliğindeki pypdf
|
||||
|
||||
from .models import (
|
||||
RekabetKurumuSearchRequest,
|
||||
RekabetDecisionSummary,
|
||||
RekabetSearchResult,
|
||||
RekabetDocument,
|
||||
RekabetKararTuruGuidEnum
|
||||
)
|
||||
from pydantic import HttpUrl # Ensure HttpUrl is imported from pydantic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if not logger.hasHandlers(): # Pragma: no cover
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, # Varsayılan log seviyesi
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
# Debug betiğinde daha detaylı loglama için seviye ayrıca ayarlanabilir.
|
||||
|
||||
class RekabetKurumuApiClient:
|
||||
BASE_URL = "https://www.rekabet.gov.tr"
|
||||
SEARCH_PATH = "/tr/Kararlar"
|
||||
DECISION_LANDING_PATH_TEMPLATE = "/Karar"
|
||||
# PDF sayfa bazlı Markdown döndürüldüğü için bu sabit artık doğrudan kullanılmıyor.
|
||||
# DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000
|
||||
|
||||
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(self, params: RekabetKurumuSearchRequest) -> List[Tuple[str, str]]:
|
||||
query_params: List[Tuple[str, str]] = []
|
||||
query_params.append(("sayfaAdi", params.sayfaAdi if params.sayfaAdi is not None else ""))
|
||||
query_params.append(("YayinlanmaTarihi", params.YayinlanmaTarihi if params.YayinlanmaTarihi is not None else ""))
|
||||
query_params.append(("PdfText", params.PdfText if params.PdfText is not None else ""))
|
||||
|
||||
karar_turu_id_value = ""
|
||||
if params.KararTuruID is not None:
|
||||
karar_turu_id_value = params.KararTuruID.value if params.KararTuruID.value != "ALL" else ""
|
||||
query_params.append(("KararTuruID", karar_turu_id_value))
|
||||
|
||||
query_params.append(("KararSayisi", params.KararSayisi if params.KararSayisi is not None else ""))
|
||||
query_params.append(("KararTarihi", params.KararTarihi if params.KararTarihi is not None else ""))
|
||||
|
||||
if params.page and params.page > 1:
|
||||
query_params.append(("page", str(params.page)))
|
||||
|
||||
return query_params
|
||||
|
||||
async def search_decisions(self, params: RekabetKurumuSearchRequest) -> RekabetSearchResult:
|
||||
request_path = self.SEARCH_PATH
|
||||
final_query_params = self._build_search_query_params(params)
|
||||
logger.info(f"RekabetKurumuApiClient: Performing search. Path: {request_path}, Parameters: {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"RekabetKurumuApiClient: HTTP request error during search: {e}")
|
||||
raise
|
||||
|
||||
soup = BeautifulSoup(html_content, 'html.parser')
|
||||
processed_decisions: List[RekabetDecisionSummary] = []
|
||||
total_records: Optional[int] = None
|
||||
total_pages: Optional[int] = None
|
||||
|
||||
pagination_div = soup.find("div", class_="yazi01")
|
||||
if pagination_div:
|
||||
text_content = pagination_div.get_text(separator=" ", strip=True)
|
||||
total_match = re.search(r"Toplam\s*:\s*(\d+)", text_content)
|
||||
if total_match:
|
||||
try:
|
||||
total_records = int(total_match.group(1))
|
||||
logger.debug(f"Total records found from pagination: {total_records}")
|
||||
except ValueError:
|
||||
logger.warning(f"Could not convert 'Toplam' value to int: {total_match.group(1)}")
|
||||
else:
|
||||
logger.warning("'Toplam :' string not found in pagination section.")
|
||||
|
||||
results_per_page_assumed = 10
|
||||
if total_records is not None:
|
||||
calculated_total_pages = math.ceil(total_records / results_per_page_assumed)
|
||||
total_pages = calculated_total_pages if calculated_total_pages > 0 else (1 if total_records > 0 else 0)
|
||||
logger.debug(f"Calculated total pages: {total_pages}")
|
||||
|
||||
if total_pages is None: # Fallback if total_records couldn't be parsed
|
||||
last_page_link = pagination_div.select_one("li.PagedList-skipToLast a")
|
||||
if last_page_link and last_page_link.has_attr('href'):
|
||||
qs = parse_qs(urlparse(last_page_link['href']).query)
|
||||
if 'page' in qs and qs['page']:
|
||||
try:
|
||||
total_pages = int(qs['page'][0])
|
||||
logger.debug(f"Total pages found from 'Last >>' link: {total_pages}")
|
||||
except ValueError:
|
||||
logger.warning(f"Could not convert page value from 'Last >>' link to int: {qs['page'][0]}")
|
||||
elif total_records == 0 : total_pages = 0 # If no records, 0 pages
|
||||
elif total_records is not None and total_records > 0 : total_pages = 1 # If records exist but no last page link (e.g. single page)
|
||||
else: logger.warning("'Last >>' link not found in pagination section.")
|
||||
|
||||
decision_tables_container = soup.find("div", id="kararList")
|
||||
if not decision_tables_container:
|
||||
logger.warning("`div#kararList` (decision list container) not found. HTML structure might have changed or no decisions on this page.")
|
||||
else:
|
||||
decision_tables = decision_tables_container.find_all("table", class_="equalDivide")
|
||||
logger.info(f"Found {len(decision_tables)} 'table' elements with class='equalDivide' for parsing.")
|
||||
|
||||
if not decision_tables and total_records is not None and total_records > 0 :
|
||||
logger.warning(f"Page indicates {total_records} records but no decision tables found with class='equalDivide'.")
|
||||
|
||||
for idx, table in enumerate(decision_tables):
|
||||
logger.debug(f"Processing table {idx + 1}...")
|
||||
try:
|
||||
rows = table.find_all("tr")
|
||||
if len(rows) != 3:
|
||||
logger.warning(f"Table {idx + 1} has an unexpected number of rows ({len(rows)} instead of 3). Skipping. HTML snippet:\n{table.prettify()[:500]}")
|
||||
continue
|
||||
|
||||
# Row 1: Publication Date, Decision Number, Related Cases Link
|
||||
td_elements_r1 = rows[0].find_all("td")
|
||||
pub_date = td_elements_r1[0].get_text(strip=True) if len(td_elements_r1) > 0 else ""
|
||||
dec_num = td_elements_r1[1].get_text(strip=True) if len(td_elements_r1) > 1 else ""
|
||||
|
||||
related_cases_link_tag = td_elements_r1[2].find("a", href=True) if len(td_elements_r1) > 2 else None
|
||||
related_cases_url_str: str = ""
|
||||
karar_id_from_related: str = ""
|
||||
if related_cases_link_tag and related_cases_link_tag.has_attr('href'):
|
||||
related_cases_url_str = urljoin(self.BASE_URL, related_cases_link_tag['href'])
|
||||
qs_related = parse_qs(urlparse(related_cases_link_tag['href']).query)
|
||||
if 'kararId' in qs_related and qs_related['kararId']:
|
||||
karar_id_from_related = qs_related['kararId'][0]
|
||||
|
||||
# Row 2: Decision Date, Decision Type
|
||||
td_elements_r2 = rows[1].find_all("td")
|
||||
dec_date = td_elements_r2[0].get_text(strip=True) if len(td_elements_r2) > 0 else ""
|
||||
dec_type_text = td_elements_r2[1].get_text(strip=True) if len(td_elements_r2) > 1 else ""
|
||||
|
||||
# Row 3: Title and Main Decision Link
|
||||
title_cell = rows[2].find("td", colspan="5")
|
||||
decision_link_tag = title_cell.find("a", href=True) if title_cell else None
|
||||
|
||||
title_text: str = ""
|
||||
decision_landing_url_str: str = ""
|
||||
karar_id_from_main_link: str = ""
|
||||
|
||||
if decision_link_tag and decision_link_tag.has_attr('href'):
|
||||
title_text = decision_link_tag.get_text(strip=True)
|
||||
href_val = decision_link_tag['href']
|
||||
if href_val.startswith(self.DECISION_LANDING_PATH_TEMPLATE + "?kararId="): # Ensure it's a decision link
|
||||
decision_landing_url_str = urljoin(self.BASE_URL, href_val)
|
||||
qs_main = parse_qs(urlparse(href_val).query)
|
||||
if 'kararId' in qs_main and qs_main['kararId']:
|
||||
karar_id_from_main_link = qs_main['kararId'][0]
|
||||
else:
|
||||
logger.warning(f"Table {idx+1} decision link has unexpected format: {href_val}")
|
||||
else:
|
||||
logger.warning(f"Table {idx+1} could not find title/decision link tag.")
|
||||
|
||||
current_karar_id = karar_id_from_main_link or karar_id_from_related
|
||||
|
||||
if not current_karar_id:
|
||||
logger.warning(f"Table {idx+1} Karar ID not found. Skipping. Title (if any): {title_text}")
|
||||
continue
|
||||
|
||||
processed_decisions.append(RekabetDecisionSummary(
|
||||
publication_date=pub_date, decision_number=dec_num, decision_date=dec_date,
|
||||
decision_type_text=dec_type_text, title=title_text,
|
||||
decision_url=decision_landing_url_str,
|
||||
karar_id=current_karar_id,
|
||||
related_cases_url=related_cases_url_str
|
||||
))
|
||||
logger.debug(f"Table {idx+1} parsed successfully: Karar ID '{current_karar_id}', Title '{title_text[:50] if title_text else 'N/A'}...'")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"RekabetKurumuApiClient: Error parsing decision summary {idx+1}: {e}. Problematic Table HTML:\n{table.prettify()}", exc_info=True)
|
||||
continue
|
||||
|
||||
return RekabetSearchResult(
|
||||
decisions=processed_decisions, total_records_found=total_records,
|
||||
retrieved_page_number=params.page, total_pages=total_pages if total_pages is not None else 0
|
||||
)
|
||||
|
||||
async def _extract_pdf_url_and_landing_page_metadata(self, karar_id: str, landing_page_html: str, landing_page_url: str) -> Dict[str, Any]:
|
||||
soup = BeautifulSoup(landing_page_html, 'html.parser')
|
||||
data: Dict[str, Any] = {
|
||||
"pdf_url": None,
|
||||
"title_on_landing_page": soup.title.string.strip() if soup.title and soup.title.string else f"Rekabet Kurumu Kararı {karar_id}",
|
||||
}
|
||||
# This part needs to be robust and specific to Rekabet Kurumu's landing page structure.
|
||||
# Look for common patterns: direct links, download buttons, embedded viewers.
|
||||
pdf_anchor = soup.find("a", href=re.compile(r"\.pdf(\?|$)", re.IGNORECASE)) # Basic PDF link
|
||||
if not pdf_anchor: # Try other common patterns if the basic one fails
|
||||
# Example: Look for links with specific text or class
|
||||
pdf_anchor = soup.find("a", string=re.compile(r"karar metni|pdf indir", re.IGNORECASE))
|
||||
|
||||
if pdf_anchor and pdf_anchor.has_attr('href'):
|
||||
pdf_path = pdf_anchor['href']
|
||||
data["pdf_url"] = urljoin(landing_page_url, pdf_path)
|
||||
logger.info(f"PDF link found on landing page (<a>): {data['pdf_url']}")
|
||||
else:
|
||||
iframe_pdf = soup.find("iframe", src=re.compile(r"\.pdf(\?|$)", re.IGNORECASE))
|
||||
if iframe_pdf and iframe_pdf.has_attr('src'):
|
||||
pdf_path = iframe_pdf['src']
|
||||
data["pdf_url"] = urljoin(landing_page_url, pdf_path)
|
||||
logger.info(f"PDF link found on landing page (<iframe>): {data['pdf_url']}")
|
||||
else:
|
||||
embed_pdf = soup.find("embed", src=re.compile(r"\.pdf(\?|$)", re.IGNORECASE), type="application/pdf")
|
||||
if embed_pdf and embed_pdf.has_attr('src'):
|
||||
pdf_path = embed_pdf['src']
|
||||
data["pdf_url"] = urljoin(landing_page_url, pdf_path)
|
||||
logger.info(f"PDF link found on landing page (<embed>): {data['pdf_url']}")
|
||||
else:
|
||||
logger.warning(f"No PDF link found on landing page {landing_page_url} for kararId {karar_id} using common selectors.")
|
||||
return data
|
||||
|
||||
async def _download_pdf_bytes(self, pdf_url: str) -> Optional[bytes]:
|
||||
try:
|
||||
url_to_fetch = pdf_url if pdf_url.startswith(('http://', 'https://')) else urljoin(self.BASE_URL, pdf_url)
|
||||
logger.info(f"Downloading PDF from: {url_to_fetch}")
|
||||
response = await self.http_client.get(url_to_fetch)
|
||||
response.raise_for_status()
|
||||
pdf_bytes = await response.aread()
|
||||
logger.info(f"PDF content downloaded ({len(pdf_bytes)} bytes) from: {url_to_fetch}")
|
||||
return pdf_bytes
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"HTTP error downloading PDF from {pdf_url}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"General error downloading PDF from {pdf_url}: {e}")
|
||||
return None
|
||||
|
||||
def _extract_single_pdf_page_as_pdf_bytes(self, original_pdf_bytes: bytes, page_number_to_extract: int) -> Tuple[Optional[bytes], int]:
|
||||
total_pages_in_original_pdf = 0
|
||||
single_page_pdf_bytes: Optional[bytes] = None
|
||||
|
||||
if not original_pdf_bytes:
|
||||
logger.warning("No original PDF bytes provided for page extraction.")
|
||||
return None, 0
|
||||
|
||||
try:
|
||||
pdf_stream = io.BytesIO(original_pdf_bytes)
|
||||
reader = PdfReader(pdf_stream)
|
||||
total_pages_in_original_pdf = len(reader.pages)
|
||||
|
||||
if not (0 < page_number_to_extract <= total_pages_in_original_pdf):
|
||||
logger.warning(f"Requested page number ({page_number_to_extract}) is out of PDF page range (1-{total_pages_in_original_pdf}).")
|
||||
return None, total_pages_in_original_pdf
|
||||
|
||||
writer = PdfWriter()
|
||||
writer.add_page(reader.pages[page_number_to_extract - 1]) # pypdf is 0-indexed
|
||||
|
||||
output_pdf_stream = io.BytesIO()
|
||||
writer.write(output_pdf_stream)
|
||||
single_page_pdf_bytes = output_pdf_stream.getvalue()
|
||||
|
||||
logger.debug(f"Page {page_number_to_extract} of original PDF (total {total_pages_in_original_pdf} pages) extracted as new PDF using pypdf.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting PDF page using pypdf: {e}", exc_info=True)
|
||||
return None, total_pages_in_original_pdf
|
||||
return single_page_pdf_bytes, total_pages_in_original_pdf
|
||||
|
||||
def _convert_pdf_bytes_to_markdown(self, pdf_bytes: bytes, source_url_for_logging: str) -> Optional[str]:
|
||||
if not pdf_bytes:
|
||||
logger.warning(f"No PDF bytes provided for Markdown conversion (source: {source_url_for_logging}).")
|
||||
return None
|
||||
|
||||
pdf_stream = io.BytesIO(pdf_bytes)
|
||||
try:
|
||||
md_converter = MarkItDown(enable_plugins=False)
|
||||
conversion_result = md_converter.convert(pdf_stream)
|
||||
markdown_text = conversion_result.text_content
|
||||
|
||||
if not markdown_text:
|
||||
logger.warning(f"MarkItDown returned empty content from PDF byte stream (source: {source_url_for_logging}). PDF page might be image-based or MarkItDown could not process the PDF stream.")
|
||||
return markdown_text
|
||||
except Exception as e:
|
||||
logger.error(f"MarkItDown conversion error for PDF byte stream (source: {source_url_for_logging}): {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
async def get_decision_document(self, karar_id: str, page_number: int = 1) -> RekabetDocument:
|
||||
if not karar_id:
|
||||
return RekabetDocument(
|
||||
source_landing_page_url=HttpUrl(f"{self.BASE_URL}"),
|
||||
karar_id=karar_id or "UNKNOWN_KARAR_ID",
|
||||
error_message="karar_id is required.",
|
||||
current_page=1, total_pages=0, is_paginated=False )
|
||||
|
||||
decision_url_path = f"{self.DECISION_LANDING_PATH_TEMPLATE}?kararId={karar_id}"
|
||||
full_landing_page_url = urljoin(self.BASE_URL, decision_url_path)
|
||||
|
||||
logger.info(f"RekabetKurumuApiClient: Getting decision document: {full_landing_page_url}, Requested PDF Page: {page_number}")
|
||||
|
||||
pdf_url_to_report: Optional[HttpUrl] = None
|
||||
title_to_report: Optional[str] = f"Rekabet Kurumu Kararı {karar_id}" # Default
|
||||
error_message: Optional[str] = None
|
||||
markdown_for_requested_page: Optional[str] = None
|
||||
total_pdf_pages: int = 0
|
||||
|
||||
try:
|
||||
async with self.http_client.stream("GET", full_landing_page_url) as response:
|
||||
response.raise_for_status()
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
final_url_of_response = HttpUrl(str(response.url))
|
||||
original_pdf_bytes: Optional[bytes] = None
|
||||
|
||||
if "application/pdf" in content_type:
|
||||
logger.info(f"URL {final_url_of_response} is a direct PDF. Processing content.")
|
||||
pdf_url_to_report = final_url_of_response
|
||||
original_pdf_bytes = await response.aread()
|
||||
elif "text/html" in content_type:
|
||||
logger.info(f"URL {final_url_of_response} is an HTML landing page. Looking for PDF link.")
|
||||
landing_page_html_bytes = await response.aread()
|
||||
detected_charset = response.charset_encoding or 'utf-8'
|
||||
try: landing_page_html = landing_page_html_bytes.decode(detected_charset)
|
||||
except UnicodeDecodeError: landing_page_html = landing_page_html_bytes.decode('utf-8', errors='replace')
|
||||
|
||||
if landing_page_html.strip():
|
||||
landing_page_data = self._extract_pdf_url_and_landing_page_metadata(karar_id, landing_page_html, str(final_url_of_response))
|
||||
pdf_url_str_from_html = landing_page_data.get("pdf_url")
|
||||
if landing_page_data.get("title_on_landing_page"): title_to_report = landing_page_data.get("title_on_landing_page")
|
||||
if pdf_url_str_from_html:
|
||||
pdf_url_to_report = HttpUrl(pdf_url_str_from_html)
|
||||
original_pdf_bytes = await self._download_pdf_bytes(str(pdf_url_to_report))
|
||||
else: error_message = (error_message or "") + " PDF URL not found on HTML landing page."
|
||||
else: error_message = "Decision landing page content is empty."
|
||||
else: error_message = f"Unexpected content type ({content_type}) for URL: {final_url_of_response}"
|
||||
|
||||
if original_pdf_bytes:
|
||||
single_page_pdf_bytes, total_pdf_pages_from_extraction = self._extract_single_pdf_page_as_pdf_bytes(original_pdf_bytes, page_number)
|
||||
total_pdf_pages = total_pdf_pages_from_extraction
|
||||
|
||||
if single_page_pdf_bytes:
|
||||
markdown_for_requested_page = await asyncio.to_thread(self._convert_pdf_bytes_to_markdown, single_page_pdf_bytes, str(pdf_url_to_report or full_landing_page_url))
|
||||
if not markdown_for_requested_page:
|
||||
error_message = (error_message or "") + f"; Could not convert page {page_number} of PDF to Markdown."
|
||||
elif total_pdf_pages > 0 :
|
||||
error_message = (error_message or "") + f"; Could not extract page {page_number} from PDF (page may be out of range or extraction failed)."
|
||||
else:
|
||||
error_message = (error_message or "") + "; PDF could not be processed or page count was zero (original PDF might be invalid)."
|
||||
elif not error_message:
|
||||
error_message = "PDF content could not be downloaded or identified."
|
||||
|
||||
is_paginated = total_pdf_pages > 1
|
||||
current_page_final = page_number
|
||||
if total_pdf_pages > 0:
|
||||
current_page_final = max(1, min(page_number, total_pdf_pages))
|
||||
elif markdown_for_requested_page is None:
|
||||
current_page_final = 1
|
||||
|
||||
# If markdown is None but there was no specific error for markdown conversion (e.g. PDF not found first)
|
||||
# make sure error_message reflects that.
|
||||
if markdown_for_requested_page is None and pdf_url_to_report and not error_message:
|
||||
error_message = (error_message or "") + "; Failed to produce Markdown from PDF page."
|
||||
|
||||
|
||||
return RekabetDocument(
|
||||
source_landing_page_url=full_landing_page_url, karar_id=karar_id,
|
||||
title_on_landing_page=title_to_report, pdf_url=pdf_url_to_report,
|
||||
markdown_chunk=markdown_for_requested_page, current_page=current_page_final,
|
||||
total_pages=total_pdf_pages, is_paginated=is_paginated,
|
||||
error_message=error_message.strip("; ") if error_message else None )
|
||||
|
||||
except httpx.HTTPStatusError as e: error_msg_detail = f"HTTP Status error {e.response.status_code} while processing decision page."
|
||||
except httpx.RequestError as e: error_msg_detail = f"HTTP Request error while processing decision page: {str(e)}"
|
||||
except Exception as e: error_msg_detail = f"General error while processing decision: {str(e)}"
|
||||
|
||||
exc_info_flag = not isinstance(e, (httpx.HTTPStatusError, httpx.RequestError)) if 'e' in locals() else True
|
||||
logger.error(f"RekabetKurumuApiClient: Error processing decision {karar_id} from {full_landing_page_url}: {error_msg_detail}", exc_info=exc_info_flag)
|
||||
error_message = (error_message + "; " if error_message else "") + error_msg_detail
|
||||
|
||||
return RekabetDocument(
|
||||
source_landing_page_url=full_landing_page_url, karar_id=karar_id,
|
||||
title_on_landing_page=title_to_report, pdf_url=pdf_url_to_report,
|
||||
markdown_chunk=None, current_page=page_number, total_pages=0, is_paginated=False,
|
||||
error_message=error_message.strip("; ") if error_message else "An unexpected error occurred." )
|
||||
|
||||
async def close_client_session(self): # Pragma: no cover
|
||||
if hasattr(self, 'http_client') and self.http_client and not self.http_client.is_closed:
|
||||
await self.http_client.aclose()
|
||||
logger.info("RekabetKurumuApiClient: HTTP client session closed.")
|
||||
@@ -0,0 +1,71 @@
|
||||
# rekabet_mcp_module/models.py
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
from typing import List, Optional, Any
|
||||
from enum import Enum
|
||||
|
||||
# Enum for decision type GUIDs (used by the client and expected by the website)
|
||||
class RekabetKararTuruGuidEnum(str, Enum):
|
||||
TUMU = "ALL" # Represents "All" or "Select Decision Type"
|
||||
BIRLESME_DEVRALMA = "2fff0979-9f9d-42d7-8c2e-a30705889542" # Merger and Acquisition
|
||||
DIGER = "dda8feaf-c919-405c-9da1-823f22b45ad9" # Other
|
||||
MENFI_TESPIT_MUAFIYET = "95ccd210-5304-49c5-b9e0-8ee53c50d4e8" # Negative Clearance and Exemption
|
||||
OZELLESTIRME = "e1f14505-842b-4af5-95d1-312d6de1a541" # Privatization
|
||||
REKABET_IHLALI = "720614bf-efd1-4dca-9785-b98eb65f2677" # Competition Infringement
|
||||
|
||||
# Enum for user-friendly decision type names (for server tool parameters)
|
||||
# These correspond to the display names on the website's select dropdown.
|
||||
class RekabetKararTuruAdiEnum(str, Enum):
|
||||
TUMU = "Tümü" # Corresponds to the empty value "" for GUID, meaning "All"
|
||||
BIRLESME_VE_DEVRALMA = "Birleşme ve Devralma"
|
||||
DIGER = "Diğer"
|
||||
MENFI_TESPIT_VE_MUAFIYET = "Menfi Tespit ve Muafiyet"
|
||||
OZELLESTIRME = "Özelleştirme"
|
||||
REKABET_IHLALI = "Rekabet İhlali"
|
||||
|
||||
class RekabetKurumuSearchRequest(BaseModel):
|
||||
"""Model for Rekabet Kurumu (Turkish Competition Authority) search request."""
|
||||
sayfaAdi: str = Field("", description="Title")
|
||||
YayinlanmaTarihi: str = Field("", description="Date")
|
||||
PdfText: str = Field("", description="Text")
|
||||
KararTuruID: RekabetKararTuruGuidEnum = Field(RekabetKararTuruGuidEnum.TUMU, description="Type")
|
||||
KararSayisi: str = Field("", description="No")
|
||||
KararTarihi: str = Field("", description="Date")
|
||||
page: int = Field(1, ge=1, description="Page")
|
||||
|
||||
class RekabetDecisionSummary(BaseModel):
|
||||
"""Model for a single Rekabet Kurumu decision summary from search results."""
|
||||
publication_date: str = Field("", description="Pub date")
|
||||
decision_number: str = Field("", description="Number")
|
||||
decision_date: str = Field("", description="Date")
|
||||
decision_type_text: str = Field("", description="Type")
|
||||
title: str = Field("", description="Title")
|
||||
decision_url: str = Field("", description="URL")
|
||||
karar_id: str = Field("", description="ID")
|
||||
related_cases_url: str = Field("", description="Cases URL")
|
||||
|
||||
class RekabetSearchResult(BaseModel):
|
||||
"""Model for the overall search result for Rekabet Kurumu decisions."""
|
||||
decisions: List[RekabetDecisionSummary]
|
||||
total_records_found: int = Field(0, description="Total")
|
||||
retrieved_page_number: int = Field(description="Page")
|
||||
total_pages: int = Field(0, description="Pages")
|
||||
|
||||
class RekabetDocument(BaseModel):
|
||||
"""
|
||||
Model for a Rekabet Kurumu decision document.
|
||||
Contains metadata from the landing page, a link to the PDF,
|
||||
and the PDF's content converted to paginated Markdown.
|
||||
"""
|
||||
source_landing_page_url: HttpUrl = Field(description="Source URL")
|
||||
karar_id: str = Field(description="ID")
|
||||
|
||||
title_on_landing_page: Optional[str] = Field(None, description="Title")
|
||||
pdf_url: Optional[HttpUrl] = Field(None, description="PDF URL")
|
||||
|
||||
markdown_chunk: Optional[str] = Field(None, description="Content")
|
||||
current_page: int = Field(1, description="Page")
|
||||
total_pages: int = Field(1, description="Total pages")
|
||||
is_paginated: bool = Field(False, description="Paginated")
|
||||
|
||||
error_message: Optional[str] = Field(None, description="Error")
|
||||
@@ -1,6 +0,0 @@
|
||||
fastmcp
|
||||
httpx
|
||||
beautifulsoup4
|
||||
markitdown
|
||||
pydantic
|
||||
aiohttp
|
||||
@@ -0,0 +1,56 @@
|
||||
# sayistay_mcp_module/__init__.py
|
||||
|
||||
"""
|
||||
Sayıştay (Turkish Court of Accounts) MCP Module
|
||||
|
||||
This module provides access to three types of Sayıştay decisions:
|
||||
- Genel Kurul (General Assembly) decisions
|
||||
- Temyiz Kurulu (Appeals Board) decisions
|
||||
- Daire (Chamber) decisions
|
||||
|
||||
The module handles ASP.NET WebForms authentication with CSRF tokens
|
||||
and DataTables-based pagination for comprehensive decision search.
|
||||
"""
|
||||
|
||||
from .client import SayistayApiClient
|
||||
from .models import (
|
||||
# Genel Kurul models
|
||||
GenelKurulSearchRequest,
|
||||
GenelKurulSearchResponse,
|
||||
GenelKurulDecision,
|
||||
|
||||
# Temyiz Kurulu models
|
||||
TemyizKuruluSearchRequest,
|
||||
TemyizKuruluSearchResponse,
|
||||
TemyizKuruluDecision,
|
||||
|
||||
# Daire models
|
||||
DaireSearchRequest,
|
||||
DaireSearchResponse,
|
||||
DaireDecision,
|
||||
|
||||
# Document models
|
||||
SayistayDocumentMarkdown
|
||||
)
|
||||
from .enums import (
|
||||
DaireEnum,
|
||||
KamuIdaresiTuruEnum,
|
||||
WebKararKonusuEnum
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SayistayApiClient",
|
||||
"GenelKurulSearchRequest",
|
||||
"GenelKurulSearchResponse",
|
||||
"GenelKurulDecision",
|
||||
"TemyizKuruluSearchRequest",
|
||||
"TemyizKuruluSearchResponse",
|
||||
"TemyizKuruluDecision",
|
||||
"DaireSearchRequest",
|
||||
"DaireSearchResponse",
|
||||
"DaireDecision",
|
||||
"SayistayDocumentMarkdown",
|
||||
"DaireEnum",
|
||||
"KamuIdaresiTuruEnum",
|
||||
"WebKararKonusuEnum"
|
||||
]
|
||||
@@ -0,0 +1,716 @@
|
||||
# sayistay_mcp_module/client.py
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import re
|
||||
from bs4 import BeautifulSoup
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
import logging
|
||||
import html
|
||||
import io
|
||||
from urllib.parse import urlencode, urljoin
|
||||
from markitdown import MarkItDown
|
||||
|
||||
from .models import (
|
||||
GenelKurulSearchRequest, GenelKurulSearchResponse, GenelKurulDecision,
|
||||
TemyizKuruluSearchRequest, TemyizKuruluSearchResponse, TemyizKuruluDecision,
|
||||
DaireSearchRequest, DaireSearchResponse, DaireDecision,
|
||||
SayistayDocumentMarkdown
|
||||
)
|
||||
from .enums import DaireEnum, KamuIdaresiTuruEnum, WebKararKonusuEnum, WEB_KARAR_KONUSU_MAPPING
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if not logger.hasHandlers():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
class SayistayApiClient:
|
||||
"""
|
||||
API Client for Sayıştay (Turkish Court of Accounts) decision search system.
|
||||
|
||||
Handles three types of decisions:
|
||||
- Genel Kurul (General Assembly): Precedent-setting interpretive decisions
|
||||
- Temyiz Kurulu (Appeals Board): Appeals against chamber decisions
|
||||
- Daire (Chamber): First-instance audit findings and sanctions
|
||||
|
||||
Features:
|
||||
- ASP.NET WebForms session management with CSRF tokens
|
||||
- DataTables-based pagination and filtering
|
||||
- Automatic session refresh on expiration
|
||||
- Document retrieval with Markdown conversion
|
||||
"""
|
||||
|
||||
BASE_URL = "https://www.sayistay.gov.tr"
|
||||
|
||||
# Search endpoints for each decision type
|
||||
GENEL_KURUL_ENDPOINT = "/KararlarGenelKurul/DataTablesList"
|
||||
TEMYIZ_KURULU_ENDPOINT = "/KararlarTemyiz/DataTablesList"
|
||||
DAIRE_ENDPOINT = "/KararlarDaire/DataTablesList"
|
||||
|
||||
# Marker present in the upstream WAF block page (also returns HTTP 418).
|
||||
# Verified 2026-05-03 against real Chrome — the block targets POSTs to
|
||||
# the DataTablesList endpoints regardless of headers/cookies/CSRF, so
|
||||
# we surface a specific error instead of the generic "I'm a teapot".
|
||||
_WAF_BLOCK_MARKER = "Bilgi Güvenliği Politikaları Gereği Kısıtlanmıştır"
|
||||
|
||||
# Page endpoints for session initialization and document access
|
||||
GENEL_KURUL_PAGE = "/KararlarGenelKurul"
|
||||
TEMYIZ_KURULU_PAGE = "/KararlarTemyiz"
|
||||
DAIRE_PAGE = "/KararlarDaire"
|
||||
|
||||
def __init__(self, request_timeout: float = 60.0):
|
||||
self.request_timeout = request_timeout
|
||||
self.session_cookies: Dict[str, str] = {}
|
||||
self.csrf_tokens: Dict[str, str] = {} # Store tokens for each endpoint
|
||||
|
||||
self.http_client = httpx.AsyncClient(
|
||||
base_url=self.BASE_URL,
|
||||
headers={
|
||||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-origin"
|
||||
},
|
||||
timeout=request_timeout,
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
async def _initialize_session_for_endpoint(self, endpoint_type: str) -> bool:
|
||||
"""
|
||||
Initialize session and obtain CSRF token for specific endpoint.
|
||||
|
||||
Args:
|
||||
endpoint_type: One of 'genel_kurul', 'temyiz_kurulu', 'daire'
|
||||
|
||||
Returns:
|
||||
True if session initialized successfully, False otherwise
|
||||
"""
|
||||
page_mapping = {
|
||||
'genel_kurul': self.GENEL_KURUL_PAGE,
|
||||
'temyiz_kurulu': self.TEMYIZ_KURULU_PAGE,
|
||||
'daire': self.DAIRE_PAGE
|
||||
}
|
||||
|
||||
if endpoint_type not in page_mapping:
|
||||
logger.error(f"Invalid endpoint type: {endpoint_type}")
|
||||
return False
|
||||
|
||||
page_url = page_mapping[endpoint_type]
|
||||
logger.info(f"Initializing session for {endpoint_type} endpoint: {page_url}")
|
||||
|
||||
try:
|
||||
response = await self.http_client.get(page_url)
|
||||
response.raise_for_status()
|
||||
|
||||
# Extract session cookies
|
||||
for cookie_name, cookie_value in response.cookies.items():
|
||||
self.session_cookies[cookie_name] = cookie_value
|
||||
logger.debug(f"Stored session cookie: {cookie_name}")
|
||||
|
||||
# Extract CSRF token from form
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
csrf_input = soup.find('input', {'name': '__RequestVerificationToken'})
|
||||
|
||||
if csrf_input and csrf_input.get('value'):
|
||||
self.csrf_tokens[endpoint_type] = csrf_input['value']
|
||||
logger.info(f"Extracted CSRF token for {endpoint_type}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"CSRF token not found in {endpoint_type} page")
|
||||
return False
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"HTTP error during session initialization for {endpoint_type}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing session for {endpoint_type}: {e}")
|
||||
return False
|
||||
|
||||
def _enum_to_form_value(self, enum_value: str, enum_type: str) -> str:
|
||||
"""Convert enum values to form values expected by the API."""
|
||||
if enum_value == "ALL":
|
||||
if enum_type == "daire":
|
||||
return "Tüm Daireler"
|
||||
elif enum_type == "kamu_idaresi":
|
||||
return "Tüm Kurumlar"
|
||||
elif enum_type == "web_karar_konusu":
|
||||
return "Tüm Konular"
|
||||
|
||||
# Apply web_karar_konusu mapping
|
||||
if enum_type == "web_karar_konusu":
|
||||
return WEB_KARAR_KONUSU_MAPPING.get(enum_value, enum_value)
|
||||
|
||||
return enum_value
|
||||
|
||||
def _raise_if_waf_blocked(self, response: httpx.Response, endpoint_label: str) -> None:
|
||||
"""
|
||||
Sayıştay's upstream WAF returns HTTP 418 with a Turkish HTML block
|
||||
page for POSTs to the DataTablesList endpoints. This affects every
|
||||
client (verified with real Chrome on 2026-05-03), so there is no
|
||||
client-side workaround. Detect it and raise a clear error.
|
||||
"""
|
||||
if response.status_code == 418 or self._WAF_BLOCK_MARKER in response.text:
|
||||
raise RuntimeError(
|
||||
f"Sayıştay upstream WAF blocked the {endpoint_label} request "
|
||||
f"(HTTP {response.status_code} from {response.request.url}). "
|
||||
"This is a server-side restriction at sayistay.gov.tr — affects "
|
||||
"all clients including a real browser — and cannot be worked "
|
||||
"around from yargi-mcp. Try again later or contact Sayıştay if "
|
||||
"the block persists."
|
||||
)
|
||||
|
||||
def _build_datatables_params(self, start: int, length: int, draw: int = 1) -> List[Tuple[str, str]]:
|
||||
"""Build standard DataTables parameters for all endpoints."""
|
||||
params = [
|
||||
("draw", str(draw)),
|
||||
("start", str(start)),
|
||||
("length", str(length)),
|
||||
("search[value]", ""),
|
||||
("search[regex]", "false")
|
||||
]
|
||||
return params
|
||||
|
||||
def _build_genel_kurul_form_data(self, params: GenelKurulSearchRequest, draw: int = 1) -> List[Tuple[str, str]]:
|
||||
"""Build form data for Genel Kurul search request."""
|
||||
form_data = self._build_datatables_params(params.start, params.length, draw)
|
||||
|
||||
# Add DataTables column definitions (from actual request)
|
||||
column_defs = [
|
||||
("columns[0][data]", "KARARNO"),
|
||||
("columns[0][name]", ""),
|
||||
("columns[0][searchable]", "true"),
|
||||
("columns[0][orderable]", "false"),
|
||||
("columns[0][search][value]", ""),
|
||||
("columns[0][search][regex]", "false"),
|
||||
|
||||
("columns[1][data]", "KARARNO"),
|
||||
("columns[1][name]", ""),
|
||||
("columns[1][searchable]", "true"),
|
||||
("columns[1][orderable]", "true"),
|
||||
("columns[1][search][value]", ""),
|
||||
("columns[1][search][regex]", "false"),
|
||||
|
||||
("columns[2][data]", "KARARTARIH"),
|
||||
("columns[2][name]", ""),
|
||||
("columns[2][searchable]", "true"),
|
||||
("columns[2][orderable]", "true"),
|
||||
("columns[2][search][value]", ""),
|
||||
("columns[2][search][regex]", "false"),
|
||||
|
||||
("columns[3][data]", "KARAROZETI"),
|
||||
("columns[3][name]", ""),
|
||||
("columns[3][searchable]", "true"),
|
||||
("columns[3][orderable]", "false"),
|
||||
("columns[3][search][value]", ""),
|
||||
("columns[3][search][regex]", "false"),
|
||||
|
||||
("columns[4][data]", ""),
|
||||
("columns[4][name]", ""),
|
||||
("columns[4][searchable]", "true"),
|
||||
("columns[4][orderable]", "false"),
|
||||
("columns[4][search][value]", ""),
|
||||
("columns[4][search][regex]", "false"),
|
||||
|
||||
("order[0][column]", "2"),
|
||||
("order[0][dir]", "desc")
|
||||
]
|
||||
form_data.extend(column_defs)
|
||||
|
||||
# Add search parameters
|
||||
form_data.extend([
|
||||
("KararlarGenelKurulAra.KARARNO", params.karar_no or ""),
|
||||
("__Invariant[]", "KararlarGenelKurulAra.KARARNO"),
|
||||
("__Invariant[]", "KararlarGenelKurulAra.KARAREK"),
|
||||
("KararlarGenelKurulAra.KARAREK", params.karar_ek or ""),
|
||||
("KararlarGenelKurulAra.KARARTARIHBaslangic", params.karar_tarih_baslangic or "Başlangıç Tarihi"),
|
||||
("KararlarGenelKurulAra.KARARTARIHBitis", params.karar_tarih_bitis or "Bitiş Tarihi"),
|
||||
("KararlarGenelKurulAra.KARARTAMAMI", params.karar_tamami or ""),
|
||||
("__RequestVerificationToken", self.csrf_tokens.get('genel_kurul', ''))
|
||||
])
|
||||
|
||||
return form_data
|
||||
|
||||
def _build_temyiz_kurulu_form_data(self, params: TemyizKuruluSearchRequest, draw: int = 1) -> List[Tuple[str, str]]:
|
||||
"""Build form data for Temyiz Kurulu search request."""
|
||||
form_data = self._build_datatables_params(params.start, params.length, draw)
|
||||
|
||||
# Add DataTables column definitions (from actual request)
|
||||
column_defs = [
|
||||
("columns[0][data]", "TEMYIZTUTANAKTARIHI"),
|
||||
("columns[0][name]", ""),
|
||||
("columns[0][searchable]", "true"),
|
||||
("columns[0][orderable]", "false"),
|
||||
("columns[0][search][value]", ""),
|
||||
("columns[0][search][regex]", "false"),
|
||||
|
||||
("columns[1][data]", "TEMYIZTUTANAKTARIHI"),
|
||||
("columns[1][name]", ""),
|
||||
("columns[1][searchable]", "true"),
|
||||
("columns[1][orderable]", "true"),
|
||||
("columns[1][search][value]", ""),
|
||||
("columns[1][search][regex]", "false"),
|
||||
|
||||
("columns[2][data]", "ILAMDAIRESI"),
|
||||
("columns[2][name]", ""),
|
||||
("columns[2][searchable]", "true"),
|
||||
("columns[2][orderable]", "true"),
|
||||
("columns[2][search][value]", ""),
|
||||
("columns[2][search][regex]", "false"),
|
||||
|
||||
("columns[3][data]", "TEMYIZKARAR"),
|
||||
("columns[3][name]", ""),
|
||||
("columns[3][searchable]", "true"),
|
||||
("columns[3][orderable]", "false"),
|
||||
("columns[3][search][value]", ""),
|
||||
("columns[3][search][regex]", "false"),
|
||||
|
||||
("columns[4][data]", ""),
|
||||
("columns[4][name]", ""),
|
||||
("columns[4][searchable]", "true"),
|
||||
("columns[4][orderable]", "false"),
|
||||
("columns[4][search][value]", ""),
|
||||
("columns[4][search][regex]", "false"),
|
||||
|
||||
("order[0][column]", "1"),
|
||||
("order[0][dir]", "desc")
|
||||
]
|
||||
form_data.extend(column_defs)
|
||||
|
||||
# Add search parameters
|
||||
daire_value = self._enum_to_form_value(params.ilam_dairesi, "daire")
|
||||
kamu_idaresi_value = self._enum_to_form_value(params.kamu_idaresi_turu, "kamu_idaresi")
|
||||
web_karar_konusu_value = self._enum_to_form_value(params.web_karar_konusu, "web_karar_konusu")
|
||||
|
||||
form_data.extend([
|
||||
("KararlarTemyizAra.ILAMDAIRESI", daire_value),
|
||||
("KararlarTemyizAra.YILI", params.yili or ""),
|
||||
("KararlarTemyizAra.KARARTRHBaslangic", params.karar_tarih_baslangic or ""),
|
||||
("KararlarTemyizAra.KARARTRHBitis", params.karar_tarih_bitis or ""),
|
||||
("KararlarTemyizAra.KAMUIDARESITURU", kamu_idaresi_value if kamu_idaresi_value != "Tüm Kurumlar" else ""),
|
||||
("KararlarTemyizAra.ILAMNO", params.ilam_no or ""),
|
||||
("KararlarTemyizAra.DOSYANO", params.dosya_no or ""),
|
||||
("KararlarTemyizAra.TEMYIZTUTANAKNO", params.temyiz_tutanak_no or ""),
|
||||
("__Invariant", "KararlarTemyizAra.TEMYIZTUTANAKNO"),
|
||||
("KararlarTemyizAra.TEMYIZKARAR", params.temyiz_karar or ""),
|
||||
("KararlarTemyizAra.WEBKARARKONUSU", web_karar_konusu_value if web_karar_konusu_value != "Tüm Konular" else ""),
|
||||
("__RequestVerificationToken", self.csrf_tokens.get('temyiz_kurulu', ''))
|
||||
])
|
||||
|
||||
return form_data
|
||||
|
||||
def _build_daire_form_data(self, params: DaireSearchRequest, draw: int = 1) -> List[Tuple[str, str]]:
|
||||
"""Build form data for Daire search request."""
|
||||
form_data = self._build_datatables_params(params.start, params.length, draw)
|
||||
|
||||
# Add DataTables column definitions (from actual request)
|
||||
column_defs = [
|
||||
("columns[0][data]", "YARGILAMADAIRESI"),
|
||||
("columns[0][name]", ""),
|
||||
("columns[0][searchable]", "true"),
|
||||
("columns[0][orderable]", "false"),
|
||||
("columns[0][search][value]", ""),
|
||||
("columns[0][search][regex]", "false"),
|
||||
|
||||
("columns[1][data]", "KARARTRH"),
|
||||
("columns[1][name]", ""),
|
||||
("columns[1][searchable]", "true"),
|
||||
("columns[1][orderable]", "true"),
|
||||
("columns[1][search][value]", ""),
|
||||
("columns[1][search][regex]", "false"),
|
||||
|
||||
("columns[2][data]", "KARARNO"),
|
||||
("columns[2][name]", ""),
|
||||
("columns[2][searchable]", "true"),
|
||||
("columns[2][orderable]", "true"),
|
||||
("columns[2][search][value]", ""),
|
||||
("columns[2][search][regex]", "false"),
|
||||
|
||||
("columns[3][data]", "YARGILAMADAIRESI"),
|
||||
("columns[3][name]", ""),
|
||||
("columns[3][searchable]", "true"),
|
||||
("columns[3][orderable]", "true"),
|
||||
("columns[3][search][value]", ""),
|
||||
("columns[3][search][regex]", "false"),
|
||||
|
||||
("columns[4][data]", "WEBKARARMETNI"),
|
||||
("columns[4][name]", ""),
|
||||
("columns[4][searchable]", "true"),
|
||||
("columns[4][orderable]", "false"),
|
||||
("columns[4][search][value]", ""),
|
||||
("columns[4][search][regex]", "false"),
|
||||
|
||||
("columns[5][data]", ""),
|
||||
("columns[5][name]", ""),
|
||||
("columns[5][searchable]", "true"),
|
||||
("columns[5][orderable]", "false"),
|
||||
("columns[5][search][value]", ""),
|
||||
("columns[5][search][regex]", "false"),
|
||||
|
||||
("order[0][column]", "2"),
|
||||
("order[0][dir]", "desc")
|
||||
]
|
||||
form_data.extend(column_defs)
|
||||
|
||||
# Add search parameters
|
||||
daire_value = self._enum_to_form_value(params.yargilama_dairesi, "daire")
|
||||
kamu_idaresi_value = self._enum_to_form_value(params.kamu_idaresi_turu, "kamu_idaresi")
|
||||
web_karar_konusu_value = self._enum_to_form_value(params.web_karar_konusu, "web_karar_konusu")
|
||||
|
||||
form_data.extend([
|
||||
("KararlarDaireAra.YARGILAMADAIRESI", daire_value),
|
||||
("KararlarDaireAra.KARARTRHBaslangic", params.karar_tarih_baslangic or ""),
|
||||
("KararlarDaireAra.KARARTRHBitis", params.karar_tarih_bitis or ""),
|
||||
("KararlarDaireAra.ILAMNO", params.ilam_no or ""),
|
||||
("KararlarDaireAra.KAMUIDARESITURU", kamu_idaresi_value if kamu_idaresi_value != "Tüm Kurumlar" else ""),
|
||||
("KararlarDaireAra.HESAPYILI", params.hesap_yili or ""),
|
||||
("KararlarDaireAra.WEBKARARKONUSU", web_karar_konusu_value if web_karar_konusu_value != "Tüm Konular" else ""),
|
||||
("KararlarDaireAra.WEBKARARMETNI", params.web_karar_metni or ""),
|
||||
("__RequestVerificationToken", self.csrf_tokens.get('daire', ''))
|
||||
])
|
||||
|
||||
return form_data
|
||||
|
||||
async def search_genel_kurul_decisions(self, params: GenelKurulSearchRequest) -> GenelKurulSearchResponse:
|
||||
"""
|
||||
Search Sayıştay Genel Kurul (General Assembly) decisions.
|
||||
|
||||
Args:
|
||||
params: Search parameters for Genel Kurul decisions
|
||||
|
||||
Returns:
|
||||
GenelKurulSearchResponse with matching decisions
|
||||
"""
|
||||
# Initialize session if needed
|
||||
if 'genel_kurul' not in self.csrf_tokens:
|
||||
if not await self._initialize_session_for_endpoint('genel_kurul'):
|
||||
raise Exception("Failed to initialize session for Genel Kurul endpoint")
|
||||
|
||||
form_data = self._build_genel_kurul_form_data(params)
|
||||
encoded_data = urlencode(form_data, encoding='utf-8')
|
||||
|
||||
logger.info(f"Searching Genel Kurul decisions with parameters: {params.model_dump(exclude_none=True)}")
|
||||
|
||||
try:
|
||||
# Update headers with cookies
|
||||
headers = self.http_client.headers.copy()
|
||||
if self.session_cookies:
|
||||
cookie_header = "; ".join([f"{k}={v}" for k, v in self.session_cookies.items()])
|
||||
headers["Cookie"] = cookie_header
|
||||
|
||||
response = await self.http_client.post(
|
||||
self.GENEL_KURUL_ENDPOINT,
|
||||
data=encoded_data,
|
||||
headers=headers
|
||||
)
|
||||
self._raise_if_waf_blocked(response, "Genel Kurul")
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
|
||||
# Parse response
|
||||
decisions = []
|
||||
for item in response_json.get('data', []):
|
||||
decisions.append(GenelKurulDecision(
|
||||
id=item['Id'],
|
||||
karar_no=item['KARARNO'],
|
||||
karar_tarih=item['KARARTARIH'],
|
||||
karar_ozeti=item['KARAROZETI']
|
||||
))
|
||||
|
||||
return GenelKurulSearchResponse(
|
||||
decisions=decisions,
|
||||
total_records=response_json.get('recordsTotal', 0),
|
||||
total_filtered=response_json.get('recordsFiltered', 0),
|
||||
draw=response_json.get('draw', 1)
|
||||
)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"HTTP error during Genel Kurul search: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing Genel Kurul search: {e}")
|
||||
raise
|
||||
|
||||
async def search_temyiz_kurulu_decisions(self, params: TemyizKuruluSearchRequest) -> TemyizKuruluSearchResponse:
|
||||
"""
|
||||
Search Sayıştay Temyiz Kurulu (Appeals Board) decisions.
|
||||
|
||||
Args:
|
||||
params: Search parameters for Temyiz Kurulu decisions
|
||||
|
||||
Returns:
|
||||
TemyizKuruluSearchResponse with matching decisions
|
||||
"""
|
||||
# Initialize session if needed
|
||||
if 'temyiz_kurulu' not in self.csrf_tokens:
|
||||
if not await self._initialize_session_for_endpoint('temyiz_kurulu'):
|
||||
raise Exception("Failed to initialize session for Temyiz Kurulu endpoint")
|
||||
|
||||
form_data = self._build_temyiz_kurulu_form_data(params)
|
||||
encoded_data = urlencode(form_data, encoding='utf-8')
|
||||
|
||||
logger.info(f"Searching Temyiz Kurulu decisions with parameters: {params.model_dump(exclude_none=True)}")
|
||||
|
||||
try:
|
||||
# Update headers with cookies
|
||||
headers = self.http_client.headers.copy()
|
||||
if self.session_cookies:
|
||||
cookie_header = "; ".join([f"{k}={v}" for k, v in self.session_cookies.items()])
|
||||
headers["Cookie"] = cookie_header
|
||||
|
||||
response = await self.http_client.post(
|
||||
self.TEMYIZ_KURULU_ENDPOINT,
|
||||
data=encoded_data,
|
||||
headers=headers
|
||||
)
|
||||
self._raise_if_waf_blocked(response, "Temyiz Kurulu")
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
|
||||
# Parse response
|
||||
decisions = []
|
||||
for item in response_json.get('data', []):
|
||||
decisions.append(TemyizKuruluDecision(
|
||||
id=item['Id'],
|
||||
temyiz_tutanak_tarihi=item['TEMYIZTUTANAKTARIHI'],
|
||||
ilam_dairesi=item['ILAMDAIRESI'],
|
||||
temyiz_karar=item['TEMYIZKARAR']
|
||||
))
|
||||
|
||||
return TemyizKuruluSearchResponse(
|
||||
decisions=decisions,
|
||||
total_records=response_json.get('recordsTotal', 0),
|
||||
total_filtered=response_json.get('recordsFiltered', 0),
|
||||
draw=response_json.get('draw', 1)
|
||||
)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"HTTP error during Temyiz Kurulu search: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing Temyiz Kurulu search: {e}")
|
||||
raise
|
||||
|
||||
async def search_daire_decisions(self, params: DaireSearchRequest) -> DaireSearchResponse:
|
||||
"""
|
||||
Search Sayıştay Daire (Chamber) decisions.
|
||||
|
||||
Args:
|
||||
params: Search parameters for Daire decisions
|
||||
|
||||
Returns:
|
||||
DaireSearchResponse with matching decisions
|
||||
"""
|
||||
# Initialize session if needed
|
||||
if 'daire' not in self.csrf_tokens:
|
||||
if not await self._initialize_session_for_endpoint('daire'):
|
||||
raise Exception("Failed to initialize session for Daire endpoint")
|
||||
|
||||
form_data = self._build_daire_form_data(params)
|
||||
encoded_data = urlencode(form_data, encoding='utf-8')
|
||||
|
||||
logger.info(f"Searching Daire decisions with parameters: {params.model_dump(exclude_none=True)}")
|
||||
|
||||
try:
|
||||
# Update headers with cookies
|
||||
headers = self.http_client.headers.copy()
|
||||
if self.session_cookies:
|
||||
cookie_header = "; ".join([f"{k}={v}" for k, v in self.session_cookies.items()])
|
||||
headers["Cookie"] = cookie_header
|
||||
|
||||
response = await self.http_client.post(
|
||||
self.DAIRE_ENDPOINT,
|
||||
data=encoded_data,
|
||||
headers=headers
|
||||
)
|
||||
self._raise_if_waf_blocked(response, "Daire")
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
|
||||
# Parse response
|
||||
decisions = []
|
||||
for item in response_json.get('data', []):
|
||||
decisions.append(DaireDecision(
|
||||
id=item['Id'],
|
||||
yargilama_dairesi=item['YARGILAMADAIRESI'],
|
||||
karar_tarih=item['KARARTRH'],
|
||||
karar_no=item['KARARNO'],
|
||||
ilam_no=item.get('ILAMNO'), # Use get() to handle None values
|
||||
madde_no=item['MADDENO'],
|
||||
kamu_idaresi_turu=item['KAMUIDARESITURU'],
|
||||
hesap_yili=item['HESAPYILI'],
|
||||
web_karar_konusu=item['WEBKARARKONUSU'],
|
||||
web_karar_metni=item['WEBKARARMETNI']
|
||||
))
|
||||
|
||||
return DaireSearchResponse(
|
||||
decisions=decisions,
|
||||
total_records=response_json.get('recordsTotal', 0),
|
||||
total_filtered=response_json.get('recordsFiltered', 0),
|
||||
draw=response_json.get('draw', 1)
|
||||
)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"HTTP error during Daire search: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing Daire search: {e}")
|
||||
raise
|
||||
|
||||
def _convert_html_to_markdown(self, html_content: str) -> Optional[str]:
|
||||
"""Convert HTML content to Markdown using MarkItDown with BytesIO to avoid filename length issues."""
|
||||
if not html_content:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Convert HTML string to bytes and create BytesIO stream
|
||||
html_bytes = html_content.encode('utf-8')
|
||||
html_stream = io.BytesIO(html_bytes)
|
||||
|
||||
# Pass BytesIO stream to MarkItDown to avoid temp file creation
|
||||
md_converter = MarkItDown()
|
||||
result = md_converter.convert(html_stream)
|
||||
markdown_content = result.text_content
|
||||
|
||||
logger.info("Successfully converted HTML to Markdown")
|
||||
return markdown_content
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error converting HTML to Markdown: {e}")
|
||||
return f"Error converting HTML content: {str(e)}"
|
||||
|
||||
async def get_document_as_markdown(self, decision_id: str, decision_type: str) -> SayistayDocumentMarkdown:
|
||||
"""
|
||||
Retrieve full text of a Sayıştay decision and convert to Markdown.
|
||||
|
||||
Args:
|
||||
decision_id: Unique decision identifier
|
||||
decision_type: Type of decision ('genel_kurul', 'temyiz_kurulu', 'daire')
|
||||
|
||||
Returns:
|
||||
SayistayDocumentMarkdown with converted content
|
||||
"""
|
||||
logger.info(f"Retrieving document for {decision_type} decision ID: {decision_id}")
|
||||
|
||||
# Validate decision_id
|
||||
if not decision_id or not decision_id.strip():
|
||||
return SayistayDocumentMarkdown(
|
||||
decision_id=decision_id,
|
||||
decision_type=decision_type,
|
||||
source_url="",
|
||||
markdown_content=None,
|
||||
error_message="Decision ID cannot be empty"
|
||||
)
|
||||
|
||||
# Map decision type to URL path
|
||||
url_path_mapping = {
|
||||
'genel_kurul': 'KararlarGenelKurul',
|
||||
'temyiz_kurulu': 'KararlarTemyiz',
|
||||
'daire': 'KararlarDaire'
|
||||
}
|
||||
|
||||
if decision_type not in url_path_mapping:
|
||||
return SayistayDocumentMarkdown(
|
||||
decision_id=decision_id,
|
||||
decision_type=decision_type,
|
||||
source_url="",
|
||||
markdown_content=None,
|
||||
error_message=f"Invalid decision type: {decision_type}. Must be one of: {list(url_path_mapping.keys())}"
|
||||
)
|
||||
|
||||
# Build document URL
|
||||
url_path = url_path_mapping[decision_type]
|
||||
document_url = f"{self.BASE_URL}/{url_path}/Detay/{decision_id}/"
|
||||
|
||||
try:
|
||||
# Make HTTP GET request to document URL
|
||||
headers = {
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;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/137.0.0.0 Safari/537.36",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "same-origin"
|
||||
}
|
||||
|
||||
# Include session cookies if available
|
||||
if self.session_cookies:
|
||||
cookie_header = "; ".join([f"{k}={v}" for k, v in self.session_cookies.items()])
|
||||
headers["Cookie"] = cookie_header
|
||||
|
||||
response = await self.http_client.get(document_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
html_content = response.text
|
||||
|
||||
if not html_content or not html_content.strip():
|
||||
logger.warning(f"Received empty HTML content from {document_url}")
|
||||
return SayistayDocumentMarkdown(
|
||||
decision_id=decision_id,
|
||||
decision_type=decision_type,
|
||||
source_url=document_url,
|
||||
markdown_content=None,
|
||||
error_message="Document content is empty"
|
||||
)
|
||||
|
||||
# Convert HTML to Markdown using existing method
|
||||
markdown_content = await asyncio.to_thread(self._convert_html_to_markdown, html_content)
|
||||
|
||||
if markdown_content and "Error converting HTML content" not in markdown_content:
|
||||
logger.info(f"Successfully retrieved and converted document {decision_id} to Markdown")
|
||||
return SayistayDocumentMarkdown(
|
||||
decision_id=decision_id,
|
||||
decision_type=decision_type,
|
||||
source_url=document_url,
|
||||
markdown_content=markdown_content,
|
||||
retrieval_date=None # Could add datetime.now().isoformat() if needed
|
||||
)
|
||||
else:
|
||||
return SayistayDocumentMarkdown(
|
||||
decision_id=decision_id,
|
||||
decision_type=decision_type,
|
||||
source_url=document_url,
|
||||
markdown_content=None,
|
||||
error_message=f"Failed to convert HTML to Markdown: {markdown_content}"
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_msg = f"HTTP error {e.response.status_code} when fetching document: {e}"
|
||||
logger.error(f"HTTP error fetching document {decision_id}: {error_msg}")
|
||||
return SayistayDocumentMarkdown(
|
||||
decision_id=decision_id,
|
||||
decision_type=decision_type,
|
||||
source_url=document_url,
|
||||
markdown_content=None,
|
||||
error_message=error_msg
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
error_msg = f"Network error when fetching document: {e}"
|
||||
logger.error(f"Network error fetching document {decision_id}: {error_msg}")
|
||||
return SayistayDocumentMarkdown(
|
||||
decision_id=decision_id,
|
||||
decision_type=decision_type,
|
||||
source_url=document_url,
|
||||
markdown_content=None,
|
||||
error_message=error_msg
|
||||
)
|
||||
except Exception as e:
|
||||
error_msg = f"Unexpected error when fetching document: {e}"
|
||||
logger.error(f"Unexpected error fetching document {decision_id}: {error_msg}")
|
||||
return SayistayDocumentMarkdown(
|
||||
decision_id=decision_id,
|
||||
decision_type=decision_type,
|
||||
source_url=document_url,
|
||||
markdown_content=None,
|
||||
error_message=error_msg
|
||||
)
|
||||
|
||||
async def close_client_session(self):
|
||||
"""Close HTTP client session."""
|
||||
if hasattr(self, 'http_client') and self.http_client and not self.http_client.is_closed:
|
||||
await self.http_client.aclose()
|
||||
logger.info("SayistayApiClient: HTTP client session closed.")
|
||||
@@ -0,0 +1,61 @@
|
||||
# sayistay_mcp_module/enums.py
|
||||
|
||||
from typing import Literal
|
||||
|
||||
# Chamber/Daire options for Temyiz Kurulu and Daire endpoints (1-8 + All)
|
||||
DaireEnum = Literal[
|
||||
"ALL", # All chambers/departments
|
||||
"1", # 1. Daire
|
||||
"2", # 2. Daire
|
||||
"3", # 3. Daire
|
||||
"4", # 4. Daire
|
||||
"5", # 5. Daire
|
||||
"6", # 6. Daire
|
||||
"7", # 7. Daire
|
||||
"8" # 8. Daire
|
||||
]
|
||||
|
||||
# Public Administration Types (Kamu İdaresi Türü)
|
||||
KamuIdaresiTuruEnum = Literal[
|
||||
"ALL", # All institutions
|
||||
"Genel Bütçe Kapsamındaki İdareler", # General Budget Administrations
|
||||
"Yüksek Öğretim Kurumları", # Higher Education Institutions
|
||||
"Diğer Özel Bütçeli İdareler", # Other Special Budget Administrations
|
||||
"Düzenleyici ve Denetleyici Kurumlar", # Regulatory and Supervisory Institutions
|
||||
"Sosyal Güvenlik Kurumları", # Social Security Institutions
|
||||
"Özel İdareler", # Special Administrations
|
||||
"Belediyeler ve Bağlı İdareler", # Municipalities and Affiliated Administrations
|
||||
"Diğer" # Other
|
||||
]
|
||||
|
||||
# Decision Subject Categories (Web Karar Konusu) - Shortened for token efficiency
|
||||
WebKararKonusuEnum = Literal[
|
||||
"ALL", # All subjects
|
||||
"Harcırah Mevzuatı", # Travel Allowance Legislation
|
||||
"İhale Mevzuatı", # Procurement Legislation
|
||||
"İş Mevzuatı", # Labor Legislation
|
||||
"Personel Mevzuatı", # Personnel Legislation
|
||||
"Sorumluluk ve Yargılama Usulleri", # Liability and Trial Procedures
|
||||
"Vergi Resmi Harç ve Diğer Gelirler", # Tax, Official Fee and Other Revenue
|
||||
"Çeşitli Konular" # Various Topics
|
||||
]
|
||||
|
||||
# Mapping from shortened enum values to full API values
|
||||
WEB_KARAR_KONUSU_MAPPING = {
|
||||
"ALL": "ALL",
|
||||
"Harcırah Mevzuatı": "Harcırah Mevzuatı ile İlgili Kararlar",
|
||||
"İhale Mevzuatı": "İhale Mevzuatı ile İlgili Kararlar",
|
||||
"İş Mevzuatı": "İş Mevzuatı ile İlgili Kararlar",
|
||||
"Personel Mevzuatı": "Personel Mevzuatı ile İlgili Kararlar",
|
||||
"Sorumluluk ve Yargılama Usulleri": "Sorumluluk ve Yargılama Usulleri ile İlgili Kararlar",
|
||||
"Vergi Resmi Harç ve Diğer Gelirler": "Vergi Resmi Harç ve Diğer Gelirlerle İlgili Kararlar",
|
||||
"Çeşitli Konular": "Çeşitli Konuları İlgilendiren Kararlar"
|
||||
}
|
||||
|
||||
# Year ranges for different endpoints
|
||||
GENEL_KURUL_YEARS = [str(year) for year in range(2006, 2025)] # 2006-2024
|
||||
TEMYIZ_KURULU_YEARS = [str(year) for year in range(1993, 2023)] # 1993-2022
|
||||
DAIRE_YEARS = [str(year) for year in range(2012, 2026)] # 2012-2025
|
||||
|
||||
# Account years for Temyiz Kurulu and Daire endpoints
|
||||
HESAP_YILLARI = [str(year) for year in range(1993, 2024)] # 1993-2023
|
||||
@@ -0,0 +1,220 @@
|
||||
# sayistay_mcp_module/models.py
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Union, Dict, Any, Literal
|
||||
from enum import Enum
|
||||
from .enums import DaireEnum, KamuIdaresiTuruEnum, WebKararKonusuEnum
|
||||
|
||||
# --- Unified Enums ---
|
||||
class SayistayDecisionTypeEnum(str, Enum):
|
||||
GENEL_KURUL = "genel_kurul"
|
||||
TEMYIZ_KURULU = "temyiz_kurulu"
|
||||
DAIRE = "daire"
|
||||
|
||||
# ============================================================================
|
||||
# Genel Kurul (General Assembly) Models
|
||||
# ============================================================================
|
||||
|
||||
class GenelKurulSearchRequest(BaseModel):
|
||||
"""
|
||||
Search request for Sayıştay Genel Kurul (General Assembly) decisions.
|
||||
|
||||
Genel Kurul decisions are precedent-setting rulings made by the full assembly
|
||||
of the Turkish Court of Accounts, typically addressing interpretation of
|
||||
audit and accountability regulations.
|
||||
"""
|
||||
karar_no: str = Field("", description="Decision no")
|
||||
karar_ek: str = Field("", description="Appendix no")
|
||||
|
||||
karar_tarih_baslangic: str = Field("", description="Start year (YYYY)")
|
||||
|
||||
karar_tarih_bitis: str = Field("", description="End year")
|
||||
|
||||
karar_tamami: str = Field("", description="Value")
|
||||
|
||||
# DataTables pagination
|
||||
start: int = Field(0, description="Starting record for pagination (0-based)")
|
||||
length: int = Field(10, description="Number of records per page (1-10)")
|
||||
|
||||
class GenelKurulDecision(BaseModel):
|
||||
"""Single Genel Kurul decision entry from search results."""
|
||||
id: int = Field(..., description="Unique decision ID")
|
||||
karar_no: str = Field(..., description="Decision number (e.g., '5415/1')")
|
||||
karar_tarih: str = Field(..., description="Decision date in DD.MM.YYYY format")
|
||||
karar_ozeti: str = Field(..., description="Decision summary/abstract")
|
||||
|
||||
class GenelKurulSearchResponse(BaseModel):
|
||||
"""Response from Genel Kurul search endpoint."""
|
||||
decisions: List[GenelKurulDecision] = Field(default_factory=list, description="List of matching decisions")
|
||||
total_records: int = Field(0, description="Total number of matching records")
|
||||
total_filtered: int = Field(0, description="Number of records after filtering")
|
||||
draw: int = Field(1, description="DataTables draw counter")
|
||||
|
||||
# ============================================================================
|
||||
# Temyiz Kurulu (Appeals Board) Models
|
||||
# ============================================================================
|
||||
|
||||
class TemyizKuruluSearchRequest(BaseModel):
|
||||
"""
|
||||
Search request for Sayıştay Temyiz Kurulu (Appeals Board) decisions.
|
||||
|
||||
Temyiz Kurulu reviews appeals against audit chamber decisions,
|
||||
providing higher-level review of audit findings and sanctions.
|
||||
"""
|
||||
ilam_dairesi: DaireEnum = Field("ALL", description="Value")
|
||||
|
||||
yili: str = Field("", description="Value")
|
||||
|
||||
karar_tarih_baslangic: str = Field("", description="Value")
|
||||
|
||||
karar_tarih_bitis: str = Field("", description="End year")
|
||||
|
||||
kamu_idaresi_turu: KamuIdaresiTuruEnum = Field("ALL", description="Value")
|
||||
|
||||
ilam_no: str = Field("", description="Audit report number (İlam No, max 50 chars)")
|
||||
dosya_no: str = Field("", description="File number for the case")
|
||||
temyiz_tutanak_no: str = Field("", description="Appeals board meeting minutes number")
|
||||
|
||||
temyiz_karar: str = Field("", description="Value")
|
||||
|
||||
web_karar_konusu: WebKararKonusuEnum = Field("ALL", description="Value")
|
||||
|
||||
# DataTables pagination
|
||||
start: int = Field(0, description="Starting record for pagination (0-based)")
|
||||
length: int = Field(10, description="Number of records per page (1-10)")
|
||||
|
||||
class TemyizKuruluDecision(BaseModel):
|
||||
"""Single Temyiz Kurulu decision entry from search results."""
|
||||
id: int = Field(..., description="Unique decision ID")
|
||||
temyiz_tutanak_tarihi: str = Field(..., description="Appeals board meeting date in DD.MM.YYYY format")
|
||||
ilam_dairesi: int = Field(..., description="Chamber number (1-8)")
|
||||
temyiz_karar: str = Field(..., description="Appeals decision summary and reasoning")
|
||||
|
||||
class TemyizKuruluSearchResponse(BaseModel):
|
||||
"""Response from Temyiz Kurulu search endpoint."""
|
||||
decisions: List[TemyizKuruluDecision] = Field(default_factory=list, description="List of matching appeals decisions")
|
||||
total_records: int = Field(0, description="Total number of matching records")
|
||||
total_filtered: int = Field(0, description="Number of records after filtering")
|
||||
draw: int = Field(1, description="DataTables draw counter")
|
||||
|
||||
# ============================================================================
|
||||
# Daire (Chamber) Models
|
||||
# ============================================================================
|
||||
|
||||
class DaireSearchRequest(BaseModel):
|
||||
"""
|
||||
Search request for Sayıştay Daire (Chamber) decisions.
|
||||
|
||||
Daire decisions are first-instance audit findings and sanctions
|
||||
issued by individual audit chambers before potential appeals.
|
||||
"""
|
||||
yargilama_dairesi: DaireEnum = Field("ALL", description="Value")
|
||||
|
||||
karar_tarih_baslangic: str = Field("", description="Value")
|
||||
|
||||
karar_tarih_bitis: str = Field("", description="End year")
|
||||
|
||||
ilam_no: str = Field("", description="Audit report number (İlam No, max 50 chars)")
|
||||
|
||||
kamu_idaresi_turu: KamuIdaresiTuruEnum = Field("ALL", description="Value")
|
||||
|
||||
hesap_yili: str = Field("", description="Value")
|
||||
|
||||
web_karar_konusu: WebKararKonusuEnum = Field("ALL", description="Value")
|
||||
|
||||
web_karar_metni: str = Field("", description="Value")
|
||||
|
||||
# DataTables pagination
|
||||
start: int = Field(0, description="Starting record for pagination (0-based)")
|
||||
length: int = Field(10, description="Number of records per page (1-10)")
|
||||
|
||||
class DaireDecision(BaseModel):
|
||||
"""Single Daire decision entry from search results."""
|
||||
id: int = Field(..., description="Unique decision ID")
|
||||
yargilama_dairesi: int = Field(..., description="Chamber number (1-8)")
|
||||
karar_tarih: str = Field(..., description="Decision date in DD.MM.YYYY format")
|
||||
karar_no: str = Field(..., description="Decision number")
|
||||
ilam_no: str = Field("", description="Audit report number (may be null)")
|
||||
madde_no: int = Field(..., description="Article/item number within the decision")
|
||||
kamu_idaresi_turu: str = Field(..., description="Public administration type")
|
||||
hesap_yili: int = Field(..., description="Account year being audited")
|
||||
web_karar_konusu: str = Field(..., description="Decision subject category")
|
||||
web_karar_metni: str = Field(..., description="Decision text/summary")
|
||||
|
||||
class DaireSearchResponse(BaseModel):
|
||||
"""Response from Daire search endpoint."""
|
||||
decisions: List[DaireDecision] = Field(default_factory=list, description="List of matching chamber decisions")
|
||||
total_records: int = Field(0, description="Total number of matching records")
|
||||
total_filtered: int = Field(0, description="Number of records after filtering")
|
||||
draw: int = Field(1, description="DataTables draw counter")
|
||||
|
||||
# ============================================================================
|
||||
# Document Models
|
||||
# ============================================================================
|
||||
|
||||
class SayistayDocumentMarkdown(BaseModel):
|
||||
"""
|
||||
Sayıştay decision document converted to Markdown format.
|
||||
|
||||
Used for retrieving full text of decisions from any of the three
|
||||
decision types (Genel Kurul, Temyiz Kurulu, Daire).
|
||||
"""
|
||||
decision_id: str = Field(..., description="Unique decision identifier")
|
||||
decision_type: str = Field(..., description="Value")
|
||||
source_url: str = Field(..., description="Original URL where the document was retrieved")
|
||||
markdown_content: Optional[str] = Field(None, description="Full decision text converted to Markdown format")
|
||||
retrieval_date: Optional[str] = Field(None, description="Date when document was retrieved (ISO format)")
|
||||
error_message: Optional[str] = Field(None, description="Error message if document retrieval failed")
|
||||
|
||||
# ============================================================================
|
||||
# Unified Models
|
||||
# ============================================================================
|
||||
|
||||
class SayistayUnifiedSearchRequest(BaseModel):
|
||||
"""Unified search request for all Sayıştay decision types."""
|
||||
decision_type: Literal["genel_kurul", "temyiz_kurulu", "daire"] = Field(..., description="Decision type: genel_kurul, temyiz_kurulu, or daire")
|
||||
|
||||
# Common pagination parameters
|
||||
start: int = Field(0, ge=0, description="Starting record for pagination (0-based)")
|
||||
length: int = Field(10, ge=1, le=100, description="Number of records per page (1-100)")
|
||||
|
||||
# Common search parameters
|
||||
karar_tarih_baslangic: str = Field("", description="Start date (DD.MM.YYYY format)")
|
||||
karar_tarih_bitis: str = Field("", description="End date (DD.MM.YYYY format)")
|
||||
kamu_idaresi_turu: KamuIdaresiTuruEnum = Field("ALL", description="Public administration type filter")
|
||||
ilam_no: str = Field("", description="Audit report number (İlam No, max 50 chars)")
|
||||
web_karar_konusu: WebKararKonusuEnum = Field("ALL", description="Decision subject category filter")
|
||||
|
||||
# Genel Kurul specific parameters (ignored for other types)
|
||||
karar_no: str = Field("", description="Decision number (genel_kurul only)")
|
||||
karar_ek: str = Field("", description="Decision appendix number (genel_kurul only)")
|
||||
karar_tamami: str = Field("", description="Full text search (genel_kurul only)")
|
||||
|
||||
# Temyiz Kurulu specific parameters (ignored for other types)
|
||||
ilam_dairesi: DaireEnum = Field("ALL", description="Audit chamber selection (temyiz_kurulu only)")
|
||||
yili: str = Field("", description="Year (YYYY format, temyiz_kurulu only)")
|
||||
dosya_no: str = Field("", description="File number (temyiz_kurulu only)")
|
||||
temyiz_tutanak_no: str = Field("", description="Appeals board meeting minutes number (temyiz_kurulu only)")
|
||||
temyiz_karar: str = Field("", description="Appeals decision text search (temyiz_kurulu only)")
|
||||
|
||||
# Daire specific parameters (ignored for other types)
|
||||
yargilama_dairesi: DaireEnum = Field("ALL", description="Chamber selection (daire only)")
|
||||
hesap_yili: str = Field("", description="Account year (daire only)")
|
||||
web_karar_metni: str = Field("", description="Decision text search (daire only)")
|
||||
|
||||
class SayistayUnifiedSearchResult(BaseModel):
|
||||
"""Unified search result containing decisions from any Sayıştay decision type."""
|
||||
decision_type: Literal["genel_kurul", "temyiz_kurulu", "daire"] = Field(..., description="Type of decisions returned")
|
||||
decisions: List[Dict[str, Any]] = Field(default_factory=list, description="Decision list (structure varies by type)")
|
||||
total_records: int = Field(0, description="Total number of records found")
|
||||
total_filtered: int = Field(0, description="Number of records after filtering")
|
||||
draw: int = Field(1, description="DataTables draw counter")
|
||||
|
||||
class SayistayUnifiedDocumentMarkdown(BaseModel):
|
||||
"""Unified document model for all Sayıştay decision types."""
|
||||
decision_type: Literal["genel_kurul", "temyiz_kurulu", "daire"] = Field(..., description="Type of document")
|
||||
decision_id: str = Field(..., description="Decision ID")
|
||||
source_url: str = Field(..., description="Source URL of the document")
|
||||
document_data: Dict[str, Any] = Field(default_factory=dict, description="Document content and metadata")
|
||||
markdown_content: Optional[str] = Field(None, description="Markdown content")
|
||||
error_message: Optional[str] = Field(None, description="Error message if retrieval failed")
|
||||
@@ -0,0 +1,133 @@
|
||||
# sayistay_mcp_module/unified_client.py
|
||||
# Unified client for all three Sayıştay decision types
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .models import (
|
||||
SayistayUnifiedSearchRequest,
|
||||
SayistayUnifiedSearchResult,
|
||||
SayistayUnifiedDocumentMarkdown,
|
||||
GenelKurulSearchRequest,
|
||||
TemyizKuruluSearchRequest,
|
||||
DaireSearchRequest
|
||||
)
|
||||
from .client import SayistayApiClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SayistayUnifiedClient:
|
||||
"""Unified client that handles all three Sayıştay decision types."""
|
||||
|
||||
def __init__(self, request_timeout: float = 60.0):
|
||||
self.client = SayistayApiClient(request_timeout)
|
||||
|
||||
async def search_unified(self, params: SayistayUnifiedSearchRequest) -> SayistayUnifiedSearchResult:
|
||||
"""Unified search that routes to appropriate search method based on decision_type."""
|
||||
|
||||
if params.decision_type == "genel_kurul":
|
||||
# Convert to genel kurul request
|
||||
genel_kurul_params = GenelKurulSearchRequest(
|
||||
karar_no=params.karar_no,
|
||||
karar_ek=params.karar_ek,
|
||||
karar_tarih_baslangic=params.karar_tarih_baslangic,
|
||||
karar_tarih_bitis=params.karar_tarih_bitis,
|
||||
karar_tamami=params.karar_tamami,
|
||||
start=params.start,
|
||||
length=params.length
|
||||
)
|
||||
|
||||
result = await self.client.search_genel_kurul_decisions(genel_kurul_params)
|
||||
|
||||
# Convert to unified format
|
||||
decisions_list = [decision.model_dump() for decision in result.decisions]
|
||||
|
||||
return SayistayUnifiedSearchResult(
|
||||
decision_type="genel_kurul",
|
||||
decisions=decisions_list,
|
||||
total_records=result.total_records,
|
||||
total_filtered=result.total_filtered,
|
||||
draw=result.draw
|
||||
)
|
||||
|
||||
elif params.decision_type == "temyiz_kurulu":
|
||||
# Convert to temyiz kurulu request
|
||||
temyiz_params = TemyizKuruluSearchRequest(
|
||||
ilam_dairesi=params.ilam_dairesi,
|
||||
yili=params.yili,
|
||||
karar_tarih_baslangic=params.karar_tarih_baslangic,
|
||||
karar_tarih_bitis=params.karar_tarih_bitis,
|
||||
kamu_idaresi_turu=params.kamu_idaresi_turu,
|
||||
ilam_no=params.ilam_no,
|
||||
dosya_no=params.dosya_no,
|
||||
temyiz_tutanak_no=params.temyiz_tutanak_no,
|
||||
temyiz_karar=params.temyiz_karar,
|
||||
web_karar_konusu=params.web_karar_konusu,
|
||||
start=params.start,
|
||||
length=params.length
|
||||
)
|
||||
|
||||
result = await self.client.search_temyiz_kurulu_decisions(temyiz_params)
|
||||
|
||||
# Convert to unified format
|
||||
decisions_list = [decision.model_dump() for decision in result.decisions]
|
||||
|
||||
return SayistayUnifiedSearchResult(
|
||||
decision_type="temyiz_kurulu",
|
||||
decisions=decisions_list,
|
||||
total_records=result.total_records,
|
||||
total_filtered=result.total_filtered,
|
||||
draw=result.draw
|
||||
)
|
||||
|
||||
elif params.decision_type == "daire":
|
||||
# Convert to daire request
|
||||
daire_params = DaireSearchRequest(
|
||||
yargilama_dairesi=params.yargilama_dairesi,
|
||||
karar_tarih_baslangic=params.karar_tarih_baslangic,
|
||||
karar_tarih_bitis=params.karar_tarih_bitis,
|
||||
ilam_no=params.ilam_no,
|
||||
kamu_idaresi_turu=params.kamu_idaresi_turu,
|
||||
hesap_yili=params.hesap_yili,
|
||||
web_karar_konusu=params.web_karar_konusu,
|
||||
web_karar_metni=params.web_karar_metni,
|
||||
start=params.start,
|
||||
length=params.length
|
||||
)
|
||||
|
||||
result = await self.client.search_daire_decisions(daire_params)
|
||||
|
||||
# Convert to unified format
|
||||
decisions_list = [decision.model_dump() for decision in result.decisions]
|
||||
|
||||
return SayistayUnifiedSearchResult(
|
||||
decision_type="daire",
|
||||
decisions=decisions_list,
|
||||
total_records=result.total_records,
|
||||
total_filtered=result.total_filtered,
|
||||
draw=result.draw
|
||||
)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported decision type: {params.decision_type}")
|
||||
|
||||
async def get_document_unified(self, decision_id: str, decision_type: str) -> SayistayUnifiedDocumentMarkdown:
|
||||
"""Unified document retrieval for all Sayıştay decision types."""
|
||||
|
||||
# Use existing client method (decision_type is already a string)
|
||||
result = await self.client.get_document_as_markdown(decision_id, decision_type)
|
||||
|
||||
return SayistayUnifiedDocumentMarkdown(
|
||||
decision_type=decision_type,
|
||||
decision_id=result.decision_id,
|
||||
source_url=result.source_url,
|
||||
document_data=result.model_dump(),
|
||||
markdown_content=result.markdown_content,
|
||||
error_message=result.error_message
|
||||
)
|
||||
|
||||
async def close_client_session(self):
|
||||
"""Close the underlying client session."""
|
||||
if hasattr(self.client, 'close_client_session'):
|
||||
await self.client.close_client_session()
|
||||
@@ -0,0 +1,23 @@
|
||||
# semantic_search/__init__.py
|
||||
|
||||
from .embedder import (
|
||||
OpenRouterEmbedder,
|
||||
LocalEmbedder,
|
||||
get_embedder,
|
||||
is_openrouter_available,
|
||||
is_local_embedding_configured,
|
||||
is_semantic_search_available,
|
||||
)
|
||||
from .vector_store import VectorStore
|
||||
from .processor import DocumentProcessor
|
||||
|
||||
__all__ = [
|
||||
'OpenRouterEmbedder',
|
||||
'LocalEmbedder',
|
||||
'get_embedder',
|
||||
'is_openrouter_available',
|
||||
'is_local_embedding_configured',
|
||||
'is_semantic_search_available',
|
||||
'VectorStore',
|
||||
'DocumentProcessor',
|
||||
]
|
||||
@@ -0,0 +1,348 @@
|
||||
# semantic_search/embedder.py
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# OpenRouter defaults (preserve backward compatibility)
|
||||
DEFAULT_MODEL = "google/gemini-embedding-001"
|
||||
DEFAULT_DIMENSION = 3072
|
||||
|
||||
# Local provider defaults — Ollama with nomic-embed-text out of the box.
|
||||
# Override via LOCAL_EMBEDDING_BASE_URL / LOCAL_EMBEDDING_MODEL /
|
||||
# LOCAL_EMBEDDING_DIMENSION when using a different server or model.
|
||||
# For Turkish, intfloat/multilingual-e5-large (1024 dims, prompt_style=e5)
|
||||
# served via HuggingFace TEI is the recommended setup — see README.
|
||||
LOCAL_DEFAULT_BASE_URL = "http://localhost:11434/v1"
|
||||
LOCAL_DEFAULT_MODEL = "nomic-embed-text"
|
||||
LOCAL_DEFAULT_DIMENSION = 768
|
||||
|
||||
# Prompt-template styles. Embedding models are trained with specific
|
||||
# prefixes — using the wrong style silently degrades retrieval quality.
|
||||
# - "gemini": "task: {task} | query: {text}" / "title: {title} | text: {text}"
|
||||
# (matches google/gemini-embedding-001, the OpenRouter default)
|
||||
# - "e5": "query: {text}" / "passage: {text}"
|
||||
# (matches intfloat/multilingual-e5-* models — best for Turkish)
|
||||
# - "raw": no prefix; pass text through as-is
|
||||
PROMPT_STYLES = ("gemini", "e5", "raw")
|
||||
DEFAULT_PROMPT_STYLE = "gemini"
|
||||
|
||||
|
||||
def _format_query(prompt_style: str, query: str, task: str) -> str:
|
||||
if prompt_style == "e5":
|
||||
return f"query: {query}"
|
||||
if prompt_style == "raw":
|
||||
return query
|
||||
# gemini (default)
|
||||
return f"task: {task} | query: {query}"
|
||||
|
||||
|
||||
def _format_document(prompt_style: str, doc: str, title: str) -> str:
|
||||
if prompt_style == "e5":
|
||||
return f"passage: {doc}"
|
||||
if prompt_style == "raw":
|
||||
return doc
|
||||
# gemini (default)
|
||||
return f"title: {title} | text: {doc}"
|
||||
|
||||
|
||||
def _resolve_prompt_style(explicit: Optional[str], default: str) -> str:
|
||||
style = (explicit or os.getenv("EMBEDDING_PROMPT_STYLE") or default).strip().lower()
|
||||
if style not in PROMPT_STYLES:
|
||||
raise ValueError(
|
||||
f"Unknown EMBEDDING_PROMPT_STYLE {style!r}; expected one of {PROMPT_STYLES}"
|
||||
)
|
||||
return style
|
||||
|
||||
|
||||
def is_openrouter_available() -> bool:
|
||||
"""Check if OpenRouter API key is available."""
|
||||
return bool(os.getenv("OPENROUTER_API_KEY"))
|
||||
|
||||
|
||||
def is_local_embedding_configured() -> bool:
|
||||
"""Check if the user opted into a local embedding endpoint."""
|
||||
return os.getenv("EMBEDDING_PROVIDER", "").strip().lower() == "local"
|
||||
|
||||
|
||||
def is_semantic_search_available() -> bool:
|
||||
"""Returns True if any embedding provider is configured."""
|
||||
return is_local_embedding_configured() or is_openrouter_available()
|
||||
|
||||
|
||||
def _coerce_dimension(value, env_name: str, default: int) -> int:
|
||||
"""Parse a dimension value (int or str) with clear error messages."""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ValueError(
|
||||
f"{env_name} must be an integer, got {value!r}"
|
||||
) from e
|
||||
if parsed <= 0:
|
||||
raise ValueError(f"Embedding dimension must be positive, got {parsed}")
|
||||
return parsed
|
||||
|
||||
|
||||
class _BaseOpenAICompatibleEmbedder:
|
||||
"""
|
||||
Shared encode/similarity logic for embedders backed by the OpenAI Python
|
||||
SDK. Subclasses configure ``client``, ``model``, ``dimension``, and
|
||||
optionally ``_extra_headers`` (e.g. OpenRouter ranking headers).
|
||||
"""
|
||||
|
||||
# Subclasses may override; sent on every embeddings.create call when set.
|
||||
_extra_headers: Dict[str, str] = {}
|
||||
|
||||
# Set by subclasses
|
||||
client = None
|
||||
model: str = ""
|
||||
dimension: int = 0
|
||||
prompt_style: str = DEFAULT_PROMPT_STYLE
|
||||
|
||||
def encode_query(self, query: str, task: str = "search result") -> np.ndarray:
|
||||
"""
|
||||
Encode a search query. Prefix is selected by ``self.prompt_style``.
|
||||
|
||||
Args:
|
||||
query: The search query text
|
||||
task: Task hint used by the gemini-style prefix; ignored for
|
||||
e5/raw styles.
|
||||
|
||||
Returns:
|
||||
Numpy array of embeddings (``self.dimension`` elements).
|
||||
"""
|
||||
text = _format_query(self.prompt_style, query, task)
|
||||
|
||||
try:
|
||||
response = self.client.embeddings.create(
|
||||
model=self.model,
|
||||
input=text,
|
||||
encoding_format="float",
|
||||
extra_headers=self._extra_headers or None,
|
||||
)
|
||||
|
||||
embedding = np.array(response.data[0].embedding, dtype=np.float32)
|
||||
|
||||
# L2 normalize for cosine similarity
|
||||
norm = np.linalg.norm(embedding)
|
||||
if norm > 0:
|
||||
embedding = embedding / norm
|
||||
|
||||
logger.debug(f"Encoded query: {query[:50]}... -> shape: {embedding.shape}")
|
||||
return embedding
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to encode query: {e}")
|
||||
raise
|
||||
|
||||
def encode_documents(self, documents: List[str], titles: Optional[List[str]] = None) -> np.ndarray:
|
||||
"""
|
||||
Encode multiple documents with a batch API call.
|
||||
|
||||
Args:
|
||||
documents: List of document texts
|
||||
titles: Optional list of document titles
|
||||
|
||||
Returns:
|
||||
Numpy array of embeddings (N x ``self.dimension``).
|
||||
"""
|
||||
if not documents:
|
||||
return np.array([])
|
||||
|
||||
texts = []
|
||||
for i, doc in enumerate(documents):
|
||||
title = titles[i] if titles and i < len(titles) else "none"
|
||||
texts.append(_format_document(self.prompt_style, doc, title))
|
||||
|
||||
try:
|
||||
response = self.client.embeddings.create(
|
||||
model=self.model,
|
||||
input=texts,
|
||||
encoding_format="float",
|
||||
extra_headers=self._extra_headers or None,
|
||||
)
|
||||
|
||||
embeddings = np.array(
|
||||
[d.embedding for d in sorted(response.data, key=lambda x: x.index)],
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
# L2 normalize each embedding for cosine similarity
|
||||
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
|
||||
embeddings = embeddings / (norms + 1e-8)
|
||||
|
||||
logger.info(f"Encoded {len(documents)} documents -> shape: {embeddings.shape}")
|
||||
return embeddings
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to encode documents: {e}")
|
||||
raise
|
||||
|
||||
def compute_similarity(self, query_embedding: np.ndarray, document_embeddings: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Compute cosine similarity between query and documents.
|
||||
|
||||
Args:
|
||||
query_embedding: Query embedding (``self.dimension``,)
|
||||
document_embeddings: Document embeddings (N x ``self.dimension``)
|
||||
|
||||
Returns:
|
||||
Similarity scores (N,)
|
||||
"""
|
||||
if len(query_embedding.shape) == 1:
|
||||
query_embedding = query_embedding.reshape(1, -1)
|
||||
|
||||
# Embeddings are already L2-normalized.
|
||||
similarities = np.dot(document_embeddings, query_embedding.T).squeeze()
|
||||
return similarities
|
||||
|
||||
|
||||
class OpenRouterEmbedder(_BaseOpenAICompatibleEmbedder):
|
||||
"""
|
||||
Embedder using OpenRouter's embedding API.
|
||||
|
||||
The model and dimension are configurable so users can pick any OpenRouter
|
||||
embedding model (e.g. when one becomes paid). Configuration precedence:
|
||||
explicit constructor args > environment variables > defaults.
|
||||
|
||||
Environment variables:
|
||||
OPENROUTER_API_KEY (required): OpenRouter credential
|
||||
OPENROUTER_EMBEDDING_MODEL (optional): override the embedding model id
|
||||
OPENROUTER_EMBEDDING_DIMENSION (optional): override the vector size
|
||||
|
||||
Defaults preserve backward compatibility: ``google/gemini-embedding-001``
|
||||
at 3072 dimensions.
|
||||
"""
|
||||
|
||||
_extra_headers = {
|
||||
"HTTP-Referer": "https://yargimcp.com",
|
||||
"X-Title": "Yargi MCP Server",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: Optional[str] = None,
|
||||
dimension: Optional[int] = None,
|
||||
prompt_style: Optional[str] = None,
|
||||
):
|
||||
api_key = os.getenv("OPENROUTER_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError("OPENROUTER_API_KEY environment variable is not set")
|
||||
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError:
|
||||
raise ImportError("openai package is required. Install with: pip install openai")
|
||||
|
||||
self.client = OpenAI(
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_key=api_key,
|
||||
)
|
||||
self.model = model or os.getenv("OPENROUTER_EMBEDDING_MODEL") or DEFAULT_MODEL
|
||||
self.dimension = _coerce_dimension(
|
||||
dimension if dimension is not None else os.getenv("OPENROUTER_EMBEDDING_DIMENSION"),
|
||||
"OPENROUTER_EMBEDDING_DIMENSION",
|
||||
DEFAULT_DIMENSION,
|
||||
)
|
||||
# Default to gemini-style prefix for OpenRouter — matches the default
|
||||
# google/gemini-embedding-001 model. Override via constructor or
|
||||
# EMBEDDING_PROMPT_STYLE env var when picking a different model.
|
||||
self.prompt_style = _resolve_prompt_style(prompt_style, "gemini")
|
||||
|
||||
logger.info(
|
||||
f"OpenRouter Embedder initialized with model: {self.model} "
|
||||
f"(dimension={self.dimension}, prompt_style={self.prompt_style})"
|
||||
)
|
||||
|
||||
|
||||
class LocalEmbedder(_BaseOpenAICompatibleEmbedder):
|
||||
"""
|
||||
Embedder for a local OpenAI-compatible embedding server — Ollama,
|
||||
llama.cpp, vLLM, LM Studio, etc. Zero new Python dependencies; just
|
||||
point the existing OpenAI SDK at a local base URL.
|
||||
|
||||
Environment variables:
|
||||
EMBEDDING_PROVIDER=local (selects this provider)
|
||||
LOCAL_EMBEDDING_BASE_URL (default: http://localhost:11434/v1)
|
||||
LOCAL_EMBEDDING_MODEL (default: nomic-embed-text)
|
||||
LOCAL_EMBEDDING_DIMENSION (default: 768)
|
||||
LOCAL_EMBEDDING_API_KEY (optional; ignored by most local servers)
|
||||
|
||||
Setup (Ollama):
|
||||
$ ollama serve
|
||||
$ ollama pull nomic-embed-text # or bge-m3 for better Turkish
|
||||
|
||||
The dimension MUST match the model's actual output size (e.g. 768 for
|
||||
nomic-embed-text, 1024 for bge-m3, 1024 for mxbai-embed-large).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
dimension: Optional[int] = None,
|
||||
api_key: Optional[str] = None,
|
||||
prompt_style: Optional[str] = None,
|
||||
):
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError:
|
||||
raise ImportError("openai package is required. Install with: pip install openai")
|
||||
|
||||
self.base_url = (
|
||||
base_url
|
||||
or os.getenv("LOCAL_EMBEDDING_BASE_URL")
|
||||
or LOCAL_DEFAULT_BASE_URL
|
||||
)
|
||||
# Most local servers don't validate the key — use a placeholder so
|
||||
# the OpenAI SDK doesn't error on the missing-key check.
|
||||
effective_key = (
|
||||
api_key
|
||||
or os.getenv("LOCAL_EMBEDDING_API_KEY")
|
||||
or "no-key-needed"
|
||||
)
|
||||
|
||||
self.client = OpenAI(base_url=self.base_url, api_key=effective_key)
|
||||
self.model = model or os.getenv("LOCAL_EMBEDDING_MODEL") or LOCAL_DEFAULT_MODEL
|
||||
self.dimension = _coerce_dimension(
|
||||
dimension if dimension is not None else os.getenv("LOCAL_EMBEDDING_DIMENSION"),
|
||||
"LOCAL_EMBEDDING_DIMENSION",
|
||||
LOCAL_DEFAULT_DIMENSION,
|
||||
)
|
||||
# Default to e5 prefix for local — the recommended Turkish setup
|
||||
# (multilingual-e5-large). Override via EMBEDDING_PROMPT_STYLE when
|
||||
# using a different model family (e.g. nomic, bge).
|
||||
self.prompt_style = _resolve_prompt_style(prompt_style, "e5")
|
||||
|
||||
logger.info(
|
||||
f"Local Embedder initialized: model={self.model} "
|
||||
f"base_url={self.base_url} dimension={self.dimension} "
|
||||
f"prompt_style={self.prompt_style}"
|
||||
)
|
||||
|
||||
|
||||
def get_embedder():
|
||||
"""
|
||||
Factory that picks the embedder based on EMBEDDING_PROVIDER.
|
||||
|
||||
- ``EMBEDDING_PROVIDER=local`` -> ``LocalEmbedder``
|
||||
- otherwise -> ``OpenRouterEmbedder`` (requires OPENROUTER_API_KEY)
|
||||
|
||||
Raises:
|
||||
ValueError: If no provider is configured (neither local nor OpenRouter).
|
||||
"""
|
||||
if is_local_embedding_configured():
|
||||
return LocalEmbedder()
|
||||
if is_openrouter_available():
|
||||
return OpenRouterEmbedder()
|
||||
raise ValueError(
|
||||
"No embedding provider configured. Set OPENROUTER_API_KEY for hosted "
|
||||
"embeddings, or EMBEDDING_PROVIDER=local (with LOCAL_EMBEDDING_* "
|
||||
"env vars) for a local OpenAI-compatible server like Ollama."
|
||||
)
|
||||
@@ -0,0 +1,305 @@
|
||||
# semantic_search/processor.py
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class DocumentChunk:
|
||||
"""Represents a chunk of a document."""
|
||||
chunk_id: str
|
||||
document_id: str
|
||||
text: str
|
||||
metadata: Dict[str, Any]
|
||||
chunk_index: int
|
||||
total_chunks: int
|
||||
|
||||
class DocumentProcessor:
|
||||
"""
|
||||
Processes legal documents for semantic search.
|
||||
Handles chunking, cleaning, and metadata extraction.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
chunk_size: int = 1000,
|
||||
chunk_overlap: int = 200,
|
||||
min_chunk_size: int = 100):
|
||||
"""
|
||||
Initialize document processor.
|
||||
|
||||
Args:
|
||||
chunk_size: Target size for each chunk in characters
|
||||
chunk_overlap: Number of overlapping characters between chunks
|
||||
min_chunk_size: Minimum chunk size to keep
|
||||
"""
|
||||
self.chunk_size = chunk_size
|
||||
self.chunk_overlap = chunk_overlap
|
||||
self.min_chunk_size = min_chunk_size
|
||||
|
||||
logger.info(f"Initialized DocumentProcessor (chunk_size={chunk_size}, overlap={chunk_overlap})")
|
||||
|
||||
def process_document(self,
|
||||
document_id: str,
|
||||
text: str,
|
||||
metadata: Optional[Dict[str, Any]] = None) -> List[DocumentChunk]:
|
||||
"""
|
||||
Process a single document into chunks.
|
||||
|
||||
Args:
|
||||
document_id: Unique document identifier
|
||||
text: Document text content
|
||||
metadata: Optional document metadata
|
||||
|
||||
Returns:
|
||||
List of document chunks
|
||||
"""
|
||||
if not text or len(text.strip()) < self.min_chunk_size:
|
||||
logger.warning(f"Document {document_id} too short to process")
|
||||
return []
|
||||
|
||||
# Clean text
|
||||
cleaned_text = self._clean_text(text)
|
||||
|
||||
# Extract metadata from text if not provided
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
|
||||
# Add extracted metadata
|
||||
extracted_metadata = self._extract_metadata(cleaned_text)
|
||||
metadata.update(extracted_metadata)
|
||||
|
||||
# Create chunks
|
||||
chunks = self._create_chunks(cleaned_text)
|
||||
|
||||
# Create DocumentChunk objects
|
||||
document_chunks = []
|
||||
for i, chunk_text in enumerate(chunks):
|
||||
chunk_id = self._generate_chunk_id(document_id, i)
|
||||
|
||||
chunk = DocumentChunk(
|
||||
chunk_id=chunk_id,
|
||||
document_id=document_id,
|
||||
text=chunk_text,
|
||||
metadata={
|
||||
**metadata,
|
||||
'chunk_index': i,
|
||||
'total_chunks': len(chunks)
|
||||
},
|
||||
chunk_index=i,
|
||||
total_chunks=len(chunks)
|
||||
)
|
||||
document_chunks.append(chunk)
|
||||
|
||||
logger.info(f"Processed document {document_id} into {len(chunks)} chunks")
|
||||
return document_chunks
|
||||
|
||||
def _clean_text(self, text: str) -> str:
|
||||
"""
|
||||
Clean and normalize text for processing.
|
||||
|
||||
Args:
|
||||
text: Raw text
|
||||
|
||||
Returns:
|
||||
Cleaned text
|
||||
"""
|
||||
# Remove excessive whitespace
|
||||
text = re.sub(r'\s+', ' ', text)
|
||||
|
||||
# Remove special characters but keep Turkish characters
|
||||
# Keep: letters, numbers, spaces, and common punctuation
|
||||
text = re.sub(r'[^\w\s\.\,\;\:\!\?\-\(\)\"\'ÇĞIİÖŞÜçğıiöşü]', ' ', text)
|
||||
|
||||
# Remove multiple spaces
|
||||
text = re.sub(r' +', ' ', text)
|
||||
|
||||
# Trim
|
||||
text = text.strip()
|
||||
|
||||
return text
|
||||
|
||||
def _extract_metadata(self, text: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract metadata from legal document text.
|
||||
|
||||
Args:
|
||||
text: Document text
|
||||
|
||||
Returns:
|
||||
Extracted metadata
|
||||
"""
|
||||
metadata = {}
|
||||
|
||||
# Extract case numbers (Esas/Karar)
|
||||
esas_pattern = r'E(?:sas)?[\s\.\:]*(\d{4})[\/\-](\d+)'
|
||||
karar_pattern = r'K(?:arar)?[\s\.\:]*(\d{4})[\/\-](\d+)'
|
||||
|
||||
esas_match = re.search(esas_pattern, text[:500]) # Look in first 500 chars
|
||||
if esas_match:
|
||||
metadata['esas_no'] = f"E.{esas_match.group(1)}/{esas_match.group(2)}"
|
||||
|
||||
karar_match = re.search(karar_pattern, text[:500])
|
||||
if karar_match:
|
||||
metadata['karar_no'] = f"K.{karar_match.group(1)}/{karar_match.group(2)}"
|
||||
|
||||
# Extract dates (DD.MM.YYYY or DD/MM/YYYY format)
|
||||
date_pattern = r'(\d{1,2})[\.\/](\d{1,2})[\.\/](\d{4})'
|
||||
dates = re.findall(date_pattern, text[:1000]) # Look in first 1000 chars
|
||||
if dates:
|
||||
# Take the first date as decision date
|
||||
day, month, year = dates[0]
|
||||
metadata['karar_tarihi'] = f"{year}-{month.zfill(2)}-{day.zfill(2)}"
|
||||
|
||||
# Extract court/chamber name
|
||||
chamber_patterns = [
|
||||
r'(\d+)\.\s*Hukuk\s+Dairesi',
|
||||
r'(\d+)\.\s*Ceza\s+Dairesi',
|
||||
r'Hukuk\s+Genel\s+Kurulu',
|
||||
r'Ceza\s+Genel\s+Kurulu',
|
||||
r'(\d+)\.\s*Daire'
|
||||
]
|
||||
|
||||
for pattern in chamber_patterns:
|
||||
match = re.search(pattern, text[:500], re.IGNORECASE)
|
||||
if match:
|
||||
metadata['chamber'] = match.group(0)
|
||||
break
|
||||
|
||||
return metadata
|
||||
|
||||
def _create_chunks(self, text: str) -> List[str]:
|
||||
"""
|
||||
Create overlapping chunks from text.
|
||||
|
||||
Args:
|
||||
text: Cleaned document text
|
||||
|
||||
Returns:
|
||||
List of text chunks
|
||||
"""
|
||||
chunks = []
|
||||
|
||||
# Split by sentences for better semantic coherence
|
||||
sentences = self._split_sentences(text)
|
||||
|
||||
current_chunk = []
|
||||
current_size = 0
|
||||
|
||||
for sentence in sentences:
|
||||
sentence_size = len(sentence)
|
||||
|
||||
# If adding this sentence exceeds chunk size
|
||||
if current_size + sentence_size > self.chunk_size and current_chunk:
|
||||
# Save current chunk
|
||||
chunk_text = ' '.join(current_chunk)
|
||||
chunks.append(chunk_text)
|
||||
|
||||
# Create overlap for next chunk
|
||||
overlap_size = 0
|
||||
overlap_sentences = []
|
||||
|
||||
# Add sentences from the end until we reach overlap size
|
||||
for sent in reversed(current_chunk):
|
||||
overlap_size += len(sent)
|
||||
overlap_sentences.insert(0, sent)
|
||||
if overlap_size >= self.chunk_overlap:
|
||||
break
|
||||
|
||||
# Start new chunk with overlap
|
||||
current_chunk = overlap_sentences
|
||||
current_size = sum(len(s) for s in current_chunk)
|
||||
|
||||
# Add sentence to current chunk
|
||||
current_chunk.append(sentence)
|
||||
current_size += sentence_size
|
||||
|
||||
# Add final chunk if not empty
|
||||
if current_chunk:
|
||||
chunk_text = ' '.join(current_chunk)
|
||||
if len(chunk_text) >= self.min_chunk_size:
|
||||
chunks.append(chunk_text)
|
||||
|
||||
return chunks
|
||||
|
||||
def _split_sentences(self, text: str) -> List[str]:
|
||||
"""
|
||||
Split text into sentences.
|
||||
|
||||
Args:
|
||||
text: Text to split
|
||||
|
||||
Returns:
|
||||
List of sentences
|
||||
"""
|
||||
# Simple sentence splitting for Turkish text
|
||||
# Split on period, question mark, exclamation, but not on abbreviations
|
||||
|
||||
# Common Turkish abbreviations to preserve
|
||||
abbreviations = ['Dr', 'Prof', 'Av', 'Md', 'Yrd', 'Doç', 'No', 'S', 'vs', 'vb', 'bkz']
|
||||
|
||||
# Replace abbreviations temporarily
|
||||
temp_text = text
|
||||
replacements = {}
|
||||
for i, abbr in enumerate(abbreviations):
|
||||
placeholder = f"__ABBR{i}__"
|
||||
temp_text = temp_text.replace(f"{abbr}.", placeholder)
|
||||
replacements[placeholder] = f"{abbr}."
|
||||
|
||||
# Split sentences
|
||||
sentence_endings = re.compile(r'[.!?]+')
|
||||
sentences = sentence_endings.split(temp_text)
|
||||
|
||||
# Restore abbreviations and clean
|
||||
cleaned_sentences = []
|
||||
for sentence in sentences:
|
||||
# Restore abbreviations
|
||||
for placeholder, original in replacements.items():
|
||||
sentence = sentence.replace(placeholder, original)
|
||||
|
||||
# Clean and add if not empty
|
||||
sentence = sentence.strip()
|
||||
if sentence and len(sentence) > 10: # Minimum sentence length
|
||||
cleaned_sentences.append(sentence)
|
||||
|
||||
return cleaned_sentences
|
||||
|
||||
def _generate_chunk_id(self, document_id: str, chunk_index: int) -> str:
|
||||
"""
|
||||
Generate unique chunk ID.
|
||||
|
||||
Args:
|
||||
document_id: Parent document ID
|
||||
chunk_index: Index of chunk in document
|
||||
|
||||
Returns:
|
||||
Unique chunk ID
|
||||
"""
|
||||
chunk_string = f"{document_id}_chunk_{chunk_index}"
|
||||
chunk_hash = hashlib.md5(chunk_string.encode()).hexdigest()[:8]
|
||||
return f"{document_id}_c{chunk_index}_{chunk_hash}"
|
||||
|
||||
def combine_chunks(self, chunks: List[DocumentChunk]) -> str:
|
||||
"""
|
||||
Combine chunks back into full document text.
|
||||
|
||||
Args:
|
||||
chunks: List of document chunks
|
||||
|
||||
Returns:
|
||||
Combined text
|
||||
"""
|
||||
if not chunks:
|
||||
return ""
|
||||
|
||||
# Sort by chunk index
|
||||
sorted_chunks = sorted(chunks, key=lambda x: x.chunk_index)
|
||||
|
||||
# For overlapping chunks, we need to be careful about duplication
|
||||
# Simple approach: just concatenate with space
|
||||
combined = " ".join([chunk.text for chunk in sorted_chunks])
|
||||
|
||||
return combined
|
||||
@@ -0,0 +1,235 @@
|
||||
# semantic_search/vector_store.py
|
||||
|
||||
import logging
|
||||
import numpy as np
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class Document:
|
||||
"""Represents a document with its embedding and metadata."""
|
||||
id: str
|
||||
text: str
|
||||
embedding: np.ndarray
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary (excluding embedding for serialization)."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'text': self.text,
|
||||
'metadata': self.metadata
|
||||
}
|
||||
|
||||
class VectorStore:
|
||||
"""
|
||||
In-memory vector storage with similarity search capabilities.
|
||||
Future versions can use Faiss, ChromaDB, or other vector databases.
|
||||
"""
|
||||
|
||||
def __init__(self, dimension: int = 768):
|
||||
"""
|
||||
Initialize vector store.
|
||||
|
||||
Args:
|
||||
dimension: Embedding dimension size
|
||||
"""
|
||||
self.dimension = dimension
|
||||
self.documents: List[Document] = []
|
||||
self.embeddings: Optional[np.ndarray] = None
|
||||
self.index_built = False
|
||||
|
||||
logger.info(f"Initialized VectorStore with dimension: {dimension}")
|
||||
|
||||
def add_documents(self,
|
||||
ids: List[str],
|
||||
texts: List[str],
|
||||
embeddings: np.ndarray,
|
||||
metadata: Optional[List[Dict[str, Any]]] = None) -> int:
|
||||
"""
|
||||
Add documents to the vector store.
|
||||
|
||||
Args:
|
||||
ids: Document IDs
|
||||
texts: Document texts
|
||||
embeddings: Document embeddings (N x dimension)
|
||||
metadata: Optional metadata for each document
|
||||
|
||||
Returns:
|
||||
Number of documents added
|
||||
"""
|
||||
if len(ids) != len(texts) or len(ids) != embeddings.shape[0]:
|
||||
raise ValueError("Mismatched lengths for ids, texts, and embeddings")
|
||||
|
||||
if metadata and len(metadata) != len(ids):
|
||||
raise ValueError("Metadata length doesn't match document count")
|
||||
|
||||
# Add documents
|
||||
for i in range(len(ids)):
|
||||
doc = Document(
|
||||
id=ids[i],
|
||||
text=texts[i],
|
||||
embedding=embeddings[i],
|
||||
metadata=metadata[i] if metadata else {}
|
||||
)
|
||||
self.documents.append(doc)
|
||||
|
||||
# Rebuild index
|
||||
self._build_index()
|
||||
|
||||
logger.info(f"Added {len(ids)} documents to vector store. Total: {len(self.documents)}")
|
||||
return len(ids)
|
||||
|
||||
def _build_index(self):
|
||||
"""Build or rebuild the embedding index."""
|
||||
if not self.documents:
|
||||
self.embeddings = None
|
||||
self.index_built = False
|
||||
return
|
||||
|
||||
# Stack all embeddings into a single array
|
||||
self.embeddings = np.vstack([doc.embedding for doc in self.documents])
|
||||
self.index_built = True
|
||||
|
||||
logger.debug(f"Built index with shape: {self.embeddings.shape}")
|
||||
|
||||
def search(self,
|
||||
query_embedding: np.ndarray,
|
||||
top_k: int = 10,
|
||||
threshold: Optional[float] = None) -> List[Tuple[Document, float]]:
|
||||
"""
|
||||
Search for similar documents using cosine similarity.
|
||||
|
||||
Args:
|
||||
query_embedding: Query embedding vector
|
||||
top_k: Number of results to return
|
||||
threshold: Optional similarity threshold (0-1)
|
||||
|
||||
Returns:
|
||||
List of (Document, similarity_score) tuples
|
||||
"""
|
||||
if not self.index_built or self.embeddings is None:
|
||||
logger.warning("No documents in vector store")
|
||||
return []
|
||||
|
||||
# Ensure query is 2D
|
||||
if len(query_embedding.shape) == 1:
|
||||
query_embedding = query_embedding.reshape(1, -1)
|
||||
|
||||
# Compute cosine similarities (assuming normalized embeddings)
|
||||
similarities = np.dot(self.embeddings, query_embedding.T).squeeze()
|
||||
|
||||
# Apply threshold if specified
|
||||
if threshold is not None:
|
||||
valid_indices = np.where(similarities >= threshold)[0]
|
||||
if len(valid_indices) == 0:
|
||||
logger.info(f"No documents above threshold {threshold}")
|
||||
return []
|
||||
similarities = similarities[valid_indices]
|
||||
valid_docs = [self.documents[i] for i in valid_indices]
|
||||
else:
|
||||
valid_docs = self.documents
|
||||
|
||||
# Get top-k indices
|
||||
top_k = min(top_k, len(valid_docs))
|
||||
if top_k == 0:
|
||||
return []
|
||||
|
||||
# Use argpartition for efficiency with large arrays
|
||||
if len(similarities) > top_k:
|
||||
top_indices = np.argpartition(similarities, -top_k)[-top_k:]
|
||||
top_indices = top_indices[np.argsort(similarities[top_indices])[::-1]]
|
||||
else:
|
||||
top_indices = np.argsort(similarities)[::-1]
|
||||
|
||||
# Create results
|
||||
results = []
|
||||
for idx in top_indices:
|
||||
doc = valid_docs[idx] if threshold else self.documents[idx]
|
||||
score = float(similarities[idx])
|
||||
results.append((doc, score))
|
||||
|
||||
logger.info(f"Search returned {len(results)} results (top_k={top_k})")
|
||||
return results
|
||||
|
||||
def hybrid_search(self,
|
||||
query_embedding: np.ndarray,
|
||||
keyword_scores: Dict[str, float],
|
||||
top_k: int = 10,
|
||||
alpha: float = 0.5) -> List[Tuple[Document, float]]:
|
||||
"""
|
||||
Hybrid search combining vector similarity and keyword scores.
|
||||
|
||||
Args:
|
||||
query_embedding: Query embedding vector
|
||||
keyword_scores: Document ID to keyword relevance score mapping
|
||||
top_k: Number of results to return
|
||||
alpha: Weight for vector similarity (1-alpha for keyword score)
|
||||
|
||||
Returns:
|
||||
List of (Document, combined_score) tuples
|
||||
"""
|
||||
if not self.index_built:
|
||||
logger.warning("No documents in vector store")
|
||||
return []
|
||||
|
||||
# Get vector similarities
|
||||
vector_results = self.search(query_embedding, top_k=len(self.documents))
|
||||
|
||||
# Combine scores
|
||||
combined_scores = []
|
||||
for doc, vector_score in vector_results:
|
||||
keyword_score = keyword_scores.get(doc.id, 0.0)
|
||||
# Normalize keyword score to 0-1 range if needed
|
||||
if keyword_score > 1.0:
|
||||
keyword_score = keyword_score / max(keyword_scores.values())
|
||||
|
||||
combined_score = alpha * vector_score + (1 - alpha) * keyword_score
|
||||
combined_scores.append((doc, combined_score))
|
||||
|
||||
# Sort by combined score and return top-k
|
||||
combined_scores.sort(key=lambda x: x[1], reverse=True)
|
||||
results = combined_scores[:top_k]
|
||||
|
||||
logger.info(f"Hybrid search returned {len(results)} results")
|
||||
return results
|
||||
|
||||
def clear(self):
|
||||
"""Clear all documents from the store."""
|
||||
self.documents = []
|
||||
self.embeddings = None
|
||||
self.index_built = False
|
||||
logger.info("Cleared vector store")
|
||||
|
||||
def size(self) -> int:
|
||||
"""Get number of documents in store."""
|
||||
return len(self.documents)
|
||||
|
||||
def get_by_id(self, doc_id: str) -> Optional[Document]:
|
||||
"""Get document by ID."""
|
||||
for doc in self.documents:
|
||||
if doc.id == doc_id:
|
||||
return doc
|
||||
return None
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get statistics about the vector store."""
|
||||
stats = {
|
||||
'num_documents': len(self.documents),
|
||||
'dimension': self.dimension,
|
||||
'index_built': self.index_built,
|
||||
'memory_usage_mb': 0
|
||||
}
|
||||
|
||||
if self.embeddings is not None:
|
||||
# Estimate memory usage
|
||||
memory_bytes = self.embeddings.nbytes
|
||||
for doc in self.documents:
|
||||
memory_bytes += len(doc.text.encode('utf-8'))
|
||||
memory_bytes += len(json.dumps(doc.metadata).encode('utf-8'))
|
||||
stats['memory_usage_mb'] = memory_bytes / (1024 * 1024)
|
||||
|
||||
return stats
|
||||
@@ -0,0 +1,21 @@
|
||||
# sigorta_tahkim_mcp_module/__init__.py
|
||||
|
||||
from .client import SigortaTahkimApiClient
|
||||
from .models import (
|
||||
SigortaTahkimSearchRequest,
|
||||
SigortaTahkimDecisionSummary,
|
||||
SigortaTahkimSearchResult,
|
||||
SigortaTahkimDocumentMarkdown,
|
||||
SigortaTahkimSearchWithinMatch,
|
||||
SigortaTahkimSearchWithinResult
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SigortaTahkimApiClient",
|
||||
"SigortaTahkimSearchRequest",
|
||||
"SigortaTahkimDecisionSummary",
|
||||
"SigortaTahkimSearchResult",
|
||||
"SigortaTahkimDocumentMarkdown",
|
||||
"SigortaTahkimSearchWithinMatch",
|
||||
"SigortaTahkimSearchWithinResult"
|
||||
]
|
||||
@@ -0,0 +1,345 @@
|
||||
# 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)}")
|
||||
@@ -0,0 +1,59 @@
|
||||
# sigorta_tahkim_mcp_module/models.py
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List
|
||||
|
||||
|
||||
class SigortaTahkimSearchRequest(BaseModel):
|
||||
"""Request model for searching Sigorta Tahkim Komisyonu decisions via Tavily API."""
|
||||
keywords: str = Field(..., description="Search keywords in Turkish")
|
||||
page: int = Field(1, ge=1, description="Page number (1-indexed)")
|
||||
pageSize: int = Field(10, ge=1, le=50, description="Results per page (1-50)")
|
||||
|
||||
|
||||
class SigortaTahkimDecisionSummary(BaseModel):
|
||||
"""Summary of a Sigorta Tahkim decision from search results."""
|
||||
title: str = Field(..., description="Decision title or journal issue info")
|
||||
document_id: str = Field(..., description="Journal issue number (e.g., '64')")
|
||||
content: str = Field(..., description="Decision summary/excerpt")
|
||||
url: str = Field("", description="Source URL")
|
||||
|
||||
|
||||
class SigortaTahkimSearchResult(BaseModel):
|
||||
"""Response model for Sigorta Tahkim decision search results."""
|
||||
decisions: List[SigortaTahkimDecisionSummary] = Field(
|
||||
default_factory=list,
|
||||
description="List of matching decisions"
|
||||
)
|
||||
total_results: int = Field(0, description="Total number of results")
|
||||
page: int = Field(1, description="Current page number")
|
||||
pageSize: int = Field(10, description="Results per page")
|
||||
|
||||
|
||||
class SigortaTahkimDocumentMarkdown(BaseModel):
|
||||
"""Sigorta Tahkim journal issue converted to Markdown format."""
|
||||
document_id: str = Field(..., description="Journal issue number")
|
||||
markdown_content: str = Field("", description="Document content in Markdown")
|
||||
page_number: int = Field(1, description="Current page number")
|
||||
total_pages: int = Field(1, description="Total number of pages")
|
||||
source_url: str = Field("", description="PDF source URL")
|
||||
|
||||
|
||||
class SigortaTahkimSearchWithinMatch(BaseModel):
|
||||
"""A single matching decision from search within a journal issue."""
|
||||
decision_header: str = Field(..., description="Decision header (date and K-number)")
|
||||
relevance_score: int = Field(0, description="Number of keyword matches")
|
||||
excerpt: str = Field("", description="Matching excerpt with context")
|
||||
body_length: int = Field(0, description="Full decision body length in chars")
|
||||
|
||||
|
||||
class SigortaTahkimSearchWithinResult(BaseModel):
|
||||
"""Response model for search within a journal issue."""
|
||||
issue_number: str = Field(..., description="Journal issue number searched")
|
||||
keyword: str = Field("", description="Search keyword used")
|
||||
total_decisions: int = Field(0, description="Total decisions in issue")
|
||||
matching_decisions: int = Field(0, description="Number of matching decisions")
|
||||
matches: List[SigortaTahkimSearchWithinMatch] = Field(
|
||||
default_factory=list,
|
||||
description="List of matching decisions sorted by relevance"
|
||||
)
|
||||
+143
-206
@@ -1,242 +1,179 @@
|
||||
# uyusmazlik_mcp_module/client.py
|
||||
#
|
||||
# Client for the rebuilt Uyuşmazlık Mahkemesi search site
|
||||
# (https://kararlar.uyusmazlik.gov.tr). The site is an ASP.NET WebForms app:
|
||||
# searching is a form postback against "/" that returns an HTML page with a
|
||||
# GridView of results, and each decision is a PDF served from /Uploads/.
|
||||
#
|
||||
# The previous AJAX endpoint (/Arama/Search) was retired and now returns 404.
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
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
|
||||
}
|
||||
# ASP.NET hidden fields that must be round-tripped on every postback.
|
||||
_HIDDEN_FIELDS = ("__VIEWSTATE", "__VIEWSTATEGENERATOR", "__EVENTVALIDATION")
|
||||
|
||||
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.
|
||||
SEARCH_PATH = "/"
|
||||
|
||||
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",
|
||||
self.request_timeout = request_timeout
|
||||
# A persistent cookie-aware client so ASP.NET session/viewstate are kept.
|
||||
self.http_client = httpx.AsyncClient(
|
||||
base_url=self.BASE_URL,
|
||||
headers={
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"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",
|
||||
"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
|
||||
},
|
||||
timeout=request_timeout,
|
||||
verify=False,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
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:
|
||||
@staticmethod
|
||||
def _extract_hidden_fields(html_content: str) -> Dict[str, str]:
|
||||
soup = BeautifulSoup(html_content, "html.parser")
|
||||
fields: Dict[str, str] = {}
|
||||
for name in _HIDDEN_FIELDS:
|
||||
tag = soup.find("input", attrs={"name": name})
|
||||
fields[name] = tag["value"] if tag and tag.has_attr("value") else ""
|
||||
return fields
|
||||
|
||||
@staticmethod
|
||||
def _parse_results(html_content: str, base_url: str) -> UyusmazlikSearchResponse:
|
||||
soup = BeautifulSoup(html_content, "html.parser")
|
||||
|
||||
decisions: List[UyusmazlikApiDecisionEntry] = []
|
||||
grid = soup.find("table", id="GridView1")
|
||||
if grid:
|
||||
rows = grid.find_all("tr")
|
||||
for row in rows[1:]: # skip header row
|
||||
cells = row.find_all("td")
|
||||
if len(cells) < 4:
|
||||
continue
|
||||
# The İşlemler cell holds the PDF "Görüntüle" link. Pager rows also
|
||||
# contain <a> tags (javascript:__doPostBack ...), so require a real
|
||||
# document link and skip everything else.
|
||||
link_tag = cells[3].find(
|
||||
"a", href=lambda h: h and not h.strip().lower().startswith("javascript:")
|
||||
)
|
||||
if not link_tag:
|
||||
continue
|
||||
href = link_tag["href"].strip()
|
||||
if "uploads" not in href.lower() and not href.lower().endswith(".pdf"):
|
||||
continue
|
||||
document_url = urljoin(base_url + "/", href)
|
||||
decisions.append(UyusmazlikApiDecisionEntry(
|
||||
esas_sayisi=cells[0].get_text(strip=True) or None,
|
||||
karar_sayisi=cells[1].get_text(strip=True) or None,
|
||||
karar_tarihi=cells[2].get_text(strip=True) or None,
|
||||
document_url=document_url,
|
||||
))
|
||||
|
||||
# Try to read a "N kayıt/sonuç/karar bulundu" style count if present.
|
||||
total_records: Optional[int] = None
|
||||
count_match = re.search(r'(\d+)\s*(?:adet\s*)?(?:kayıt|sonuç|karar)\b', html_content, re.IGNORECASE)
|
||||
if count_match:
|
||||
total_records = int(count_match.group(1))
|
||||
|
||||
return UyusmazlikSearchResponse(decisions=decisions, total_records_found=total_records)
|
||||
|
||||
async def search_decisions(self, params: UyusmazlikSearchRequest) -> UyusmazlikSearchResponse:
|
||||
# 1. Load the landing page to obtain a fresh viewstate + session cookie.
|
||||
landing = await self.http_client.get(self.SEARCH_PATH)
|
||||
landing.raise_for_status()
|
||||
form_data = self._extract_hidden_fields(landing.text)
|
||||
|
||||
# 2. Submit the search form.
|
||||
form_data.update({
|
||||
"txtSearch": params.icerik or "",
|
||||
"rblSearchScope": params.search_scope,
|
||||
"btnSearch": "Ara",
|
||||
})
|
||||
if params.case_sensitive:
|
||||
form_data["chkCaseSensitive"] = "on"
|
||||
|
||||
logger.info("UyusmazlikApiClient: search icerik=%r scope=%s page=%s",
|
||||
params.icerik, params.search_scope, params.page_number)
|
||||
response = await self.http_client.post(
|
||||
self.SEARCH_PATH,
|
||||
data=form_data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
html_content = response.text
|
||||
|
||||
# 3. Navigate the GridView pager if a later page is requested.
|
||||
if params.page_number > 1:
|
||||
page_fields = self._extract_hidden_fields(html_content)
|
||||
page_fields.update({
|
||||
"txtSearch": params.icerik or "",
|
||||
"rblSearchScope": params.search_scope,
|
||||
"__EVENTTARGET": "GridView1",
|
||||
"__EVENTARGUMENT": f"Page${params.page_number}",
|
||||
})
|
||||
if params.case_sensitive:
|
||||
page_fields["chkCaseSensitive"] = "on"
|
||||
page_response = await self.http_client.post(
|
||||
self.SEARCH_PATH,
|
||||
data=page_fields,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
page_response.raise_for_status()
|
||||
html_content = page_response.text
|
||||
|
||||
return self._parse_results(html_content, self.BASE_URL)
|
||||
|
||||
def _convert_pdf_to_markdown(self, pdf_bytes: bytes) -> Optional[str]:
|
||||
try:
|
||||
pdf_stream = io.BytesIO(pdf_bytes)
|
||||
conversion_result = MarkItDown().convert(pdf_stream, file_extension=".pdf")
|
||||
return conversion_result.text_content
|
||||
except Exception as e:
|
||||
logger.error("UyusmazlikApiClient: PDF to Markdown conversion error: %s", e)
|
||||
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}")
|
||||
"""Fetch an Uyuşmazlık decision PDF and return its content as Markdown."""
|
||||
logger.info("UyusmazlikApiClient: Fetching document PDF from %s", 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)
|
||||
response = await self.http_client.get(
|
||||
document_url,
|
||||
headers={"Accept": "application/pdf,*/*"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
markdown_content = await asyncio.to_thread(self._convert_pdf_to_markdown, response.content)
|
||||
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}")
|
||||
except httpx.HTTPError as e:
|
||||
logger.error("UyusmazlikApiClient: HTTP error fetching document from %s: %s", document_url, e)
|
||||
raise
|
||||
|
||||
async def close_client_session(self):
|
||||
|
||||
logger.info("UyusmazlikApiClient: No persistent client session from __init__ to close.")
|
||||
if hasattr(self, "http_client") and self.http_client and not self.http_client.is_closed:
|
||||
await self.http_client.aclose()
|
||||
logger.info("UyusmazlikApiClient: HTTP client session closed.")
|
||||
|
||||
@@ -1,86 +1,41 @@
|
||||
# uyusmazlik_mcp_module/models.py
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
from typing import List, Optional
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Literal
|
||||
|
||||
# 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ü"
|
||||
# The Uyuşmazlık Mahkemesi search site was rebuilt as an ASP.NET WebForms app.
|
||||
# It now offers only a single free-text search with a scope selector; the old
|
||||
# Bölüm / Uyuşmazlık Türü / Karar Sonucu / Esas-Karar year filters no longer exist.
|
||||
|
||||
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ığı"
|
||||
UyusmazlikSearchScope = Literal["All", "EsasNo", "KararNo"]
|
||||
|
||||
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)."
|
||||
class UyusmazlikSearchRequest(BaseModel):
|
||||
"""Model for the Uyuşmazlık Mahkemesi search request."""
|
||||
icerik: str = Field("", description="Search text (txtSearch).")
|
||||
search_scope: UyusmazlikSearchScope = Field(
|
||||
"All",
|
||||
description="Search scope: 'All' (full text), 'EsasNo' (by case number), 'KararNo' (by decision number).",
|
||||
)
|
||||
case_sensitive: bool = Field(False, description="Whether the search is case sensitive (chkCaseSensitive).")
|
||||
page_number: int = Field(1, ge=1, description="Result page number (GridView pager).")
|
||||
|
||||
# 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.")
|
||||
"""A single decision row parsed from the Uyuşmazlık GridView results."""
|
||||
esas_sayisi: Optional[str] = Field(None, description="Case number (Esas No).")
|
||||
karar_sayisi: Optional[str] = Field(None, description="Decision number (Karar No).")
|
||||
karar_tarihi: Optional[str] = Field(None, description="Decision date (DD/MM/YYYY).")
|
||||
document_url: HttpUrl = Field(..., description="Full URL to the decision PDF document.")
|
||||
|
||||
class UyusmazlikSearchResponse(BaseModel): # This is what the MCP tool will return
|
||||
"""Response model for Uyuşmazlık Mahkemesi search results for the MCP tool."""
|
||||
|
||||
class UyusmazlikSearchResponse(BaseModel):
|
||||
"""Response model for Uyuşmazlık Mahkemesi search results."""
|
||||
decisions: List[UyusmazlikApiDecisionEntry]
|
||||
total_records_found: Optional[int] = Field(None, description="Total number of records found for the query, if available.")
|
||||
total_records_found: Optional[int] = Field(None, description="Total number of records found, if reported.")
|
||||
|
||||
|
||||
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.")
|
||||
"""Model for an Uyuşmazlık decision document, containing Markdown content."""
|
||||
source_url: HttpUrl
|
||||
markdown_content: Optional[str] = Field(None, description="The decision PDF content converted to Markdown.")
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# yargitay_mcp_module/client.py
|
||||
|
||||
import asyncio
|
||||
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
|
||||
import io
|
||||
from markitdown import MarkItDown
|
||||
|
||||
from .models import (
|
||||
@@ -66,6 +66,19 @@ class YargitayOfficialApiClient:
|
||||
response.raise_for_status() # Raise an exception for HTTP 4xx or 5xx status codes
|
||||
response_json_data = response.json()
|
||||
|
||||
logger.debug(f"YargitayOfficialApiClient: Raw API response: {response_json_data}")
|
||||
|
||||
# Handle None or empty data response from API
|
||||
if response_json_data is None:
|
||||
logger.warning("YargitayOfficialApiClient: API returned None response")
|
||||
response_json_data = {"data": {"data": [], "recordsTotal": 0, "recordsFiltered": 0}}
|
||||
elif not isinstance(response_json_data, dict):
|
||||
logger.warning(f"YargitayOfficialApiClient: API returned unexpected response type: {type(response_json_data)}")
|
||||
response_json_data = {"data": {"data": [], "recordsTotal": 0, "recordsFiltered": 0}}
|
||||
elif response_json_data.get("data") is None:
|
||||
logger.warning("YargitayOfficialApiClient: API response data field is None")
|
||||
response_json_data["data"] = {"data": [], "recordsTotal": 0, "recordsFiltered": 0}
|
||||
|
||||
# Validate and parse the response using Pydantic models
|
||||
api_response = YargitayApiSearchResponse(**response_json_data)
|
||||
|
||||
@@ -108,37 +121,32 @@ class YargitayOfficialApiClient:
|
||||
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
|
||||
# Convert HTML string to bytes and create BytesIO stream
|
||||
html_bytes = html_to_convert.encode('utf-8')
|
||||
html_stream = io.BytesIO(html_bytes)
|
||||
|
||||
# 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)
|
||||
# Pass BytesIO stream to MarkItDown to avoid temp file creation
|
||||
md_converter = MarkItDown()
|
||||
conversion_result = md_converter.convert(html_stream)
|
||||
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:
|
||||
async def get_decision_document_as_markdown(self, 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}"
|
||||
document_api_url = f"{self.DOCUMENT_ENDPOINT}?id={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})")
|
||||
logger.info(f"YargitayOfficialApiClient: Fetching document for Markdown conversion (ID: {id})")
|
||||
|
||||
try:
|
||||
response = await self.http_client.get(document_api_url)
|
||||
@@ -149,24 +157,24 @@ class YargitayOfficialApiClient:
|
||||
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}).")
|
||||
logger.error(f"YargitayOfficialApiClient: 'data' field in API response is not a string or not found (ID: {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)
|
||||
markdown_content = await asyncio.to_thread(self._convert_html_to_markdown, html_content_from_api)
|
||||
|
||||
return YargitayDocumentMarkdown(
|
||||
document_id=document_id,
|
||||
id=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}")
|
||||
logger.error(f"YargitayOfficialApiClient: HTTP error fetching document for Markdown (ID: {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}")
|
||||
logger.error(f"YargitayOfficialApiClient: Error processing document response for Markdown (ID: {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}")
|
||||
logger.error(f"YargitayOfficialApiClient: General error fetching/processing document for Markdown (ID: {id}): {e}")
|
||||
raise
|
||||
|
||||
async def close_client_session(self):
|
||||
|
||||
@@ -1,7 +1,32 @@
|
||||
# yargitay_mcp_module/models.py
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field, HttpUrl, ConfigDict
|
||||
from typing import List, Optional, Dict, Any, Literal
|
||||
|
||||
# Yargıtay Chamber/Board Options
|
||||
YargitayBirimEnum = Literal[
|
||||
"ALL", # "ALL" for all chambers
|
||||
# Hukuk (Civil) Chambers
|
||||
"Hukuk Genel Kurulu",
|
||||
"1. Hukuk Dairesi", "2. Hukuk Dairesi", "3. Hukuk Dairesi", "4. Hukuk Dairesi",
|
||||
"5. Hukuk Dairesi", "6. Hukuk Dairesi", "7. Hukuk Dairesi", "8. Hukuk Dairesi",
|
||||
"9. Hukuk Dairesi", "10. Hukuk Dairesi", "11. Hukuk Dairesi", "12. Hukuk Dairesi",
|
||||
"13. Hukuk Dairesi", "14. Hukuk Dairesi", "15. Hukuk Dairesi", "16. Hukuk Dairesi",
|
||||
"17. Hukuk Dairesi", "18. Hukuk Dairesi", "19. Hukuk Dairesi", "20. Hukuk Dairesi",
|
||||
"21. Hukuk Dairesi", "22. Hukuk Dairesi", "23. Hukuk Dairesi",
|
||||
"Hukuk Daireleri Başkanlar Kurulu",
|
||||
# Ceza (Criminal) Chambers
|
||||
"Ceza Genel Kurulu",
|
||||
"1. Ceza Dairesi", "2. Ceza Dairesi", "3. Ceza Dairesi", "4. Ceza Dairesi",
|
||||
"5. Ceza Dairesi", "6. Ceza Dairesi", "7. Ceza Dairesi", "8. Ceza Dairesi",
|
||||
"9. Ceza Dairesi", "10. Ceza Dairesi", "11. Ceza Dairesi", "12. Ceza Dairesi",
|
||||
"13. Ceza Dairesi", "14. Ceza Dairesi", "15. Ceza Dairesi", "16. Ceza Dairesi",
|
||||
"17. Ceza Dairesi", "18. Ceza Dairesi", "19. Ceza Dairesi", "20. Ceza Dairesi",
|
||||
"21. Ceza Dairesi", "22. Ceza Dairesi", "23. Ceza Dairesi",
|
||||
"Ceza Daireleri Başkanlar Kurulu",
|
||||
# General Assembly
|
||||
"Büyük Genel Kurulu"
|
||||
]
|
||||
|
||||
class YargitayDetailedSearchRequest(BaseModel):
|
||||
"""
|
||||
@@ -9,68 +34,70 @@ class YargitayDetailedSearchRequest(BaseModel):
|
||||
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.")
|
||||
arananKelime: Optional[str] = Field("", description="Turkish keywords (supports +word -word \"phrase\" operators)")
|
||||
# Department/Board selection - Complete Court of Cassation chamber hierarchy
|
||||
birimYrgKurulDaire: Optional[str] = Field("ALL", description="Chamber (ALL or specific chamber name)")
|
||||
|
||||
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'.")
|
||||
esasYil: Optional[str] = Field("", description="Case year (YYYY)")
|
||||
esasIlkSiraNo: Optional[str] = Field("", description="Start case no")
|
||||
esasSonSiraNo: Optional[str] = Field("", description="End case 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'.")
|
||||
kararYil: Optional[str] = Field("", description="Decision year (YYYY)")
|
||||
kararIlkSiraNo: Optional[str] = Field("", description="Start decision no")
|
||||
kararSonSiraNo: Optional[str] = Field("", description="End decision 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).")
|
||||
baslangicTarihi: Optional[str] = Field("", description="Start date (DD.MM.YYYY)")
|
||||
bitisTarihi: Optional[str] = Field("", description="End date (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.")
|
||||
pageSize: int = Field(10, ge=1, le=10, description="Results per page (1-100)")
|
||||
pageNumber: int = Field(1, ge=1, description="Page number (1-indexed)")
|
||||
|
||||
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.")
|
||||
daire: Optional[str] = Field(None, description="Chamber")
|
||||
esasNo: Optional[str] = Field(None, alias="esasNo", description="Case no")
|
||||
kararNo: Optional[str] = Field(None, alias="kararNo", description="Decision no")
|
||||
kararTarihi: Optional[str] = Field(None, alias="kararTarihi", description="Date")
|
||||
# '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.")
|
||||
document_url: Optional[HttpUrl] = Field(None, description="Document URL")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True # To allow populating by alias from API response
|
||||
model_config = ConfigDict(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]
|
||||
data: List[YargitayApiDecisionEntry] = Field(default_factory=list)
|
||||
# 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)
|
||||
recordsTotal: int = Field(default=0) # Total number of records matching the query
|
||||
recordsFiltered: int = Field(default=0) # 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
|
||||
data: Optional[YargitayApiResponseInnerData] = Field(default_factory=lambda: 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.")
|
||||
id: str = Field(..., description="Document ID")
|
||||
markdown_content: Optional[str] = Field(None, description="Content")
|
||||
source_url: HttpUrl = Field(..., description="Source URL")
|
||||
|
||||
class CleanYargitayDecisionEntry(BaseModel):
|
||||
"""Clean decision entry without arananKelime field to reduce token usage."""
|
||||
id: str
|
||||
daire: Optional[str] = Field(None, description="Chamber")
|
||||
esasNo: Optional[str] = Field(None, description="Case no")
|
||||
kararNo: Optional[str] = Field(None, description="Decision no")
|
||||
kararTarihi: Optional[str] = Field(None, description="Date")
|
||||
document_url: Optional[HttpUrl] = Field(None, description="Document URL")
|
||||
|
||||
class CompactYargitaySearchResult(BaseModel):
|
||||
"""A more compact search result model for the MCP tool to return."""
|
||||
decisions: List[YargitayApiDecisionEntry]
|
||||
decisions: List[CleanYargitayDecisionEntry]
|
||||
total_records: int
|
||||
requested_page: int
|
||||
page_size: int
|
||||
Reference in New Issue
Block a user