Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -56,6 +56,9 @@ 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
|
||||
# =============================================================================
|
||||
@@ -67,6 +70,15 @@ BASE_URL=http://localhost:8000
|
||||
# MAX_REQUESTS_PER_MINUTE=60
|
||||
# BURST_CAPACITY=20
|
||||
|
||||
# =============================================================================
|
||||
# SEMANTIC SEARCH SETTINGS (Optional)
|
||||
# =============================================================================
|
||||
|
||||
# OpenRouter API Key for semantic search functionality
|
||||
# Get your API key from: https://openrouter.ai/keys
|
||||
# If not set, semantic search tool will be disabled
|
||||
OPENROUTER_API_KEY=sk-or-v1-your_openrouter_api_key_here
|
||||
|
||||
# =============================================================================
|
||||
# USAGE INSTRUCTIONS
|
||||
# =============================================================================
|
||||
|
||||
+24
@@ -190,3 +190,27 @@ 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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/cache
|
||||
@@ -0,0 +1,84 @@
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al bash clojure cpp csharp csharp_omnisharp
|
||||
# dart elixir elm erlang fortran go
|
||||
# haskell java julia kotlin lua markdown
|
||||
# nix perl php python python_jedi r
|
||||
# rego ruby ruby_solargraph rust scala swift
|
||||
# terraform typescript typescript_vts yaml zig
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# Special requirements:
|
||||
# - csharp: Requires the presence of a .sln file in the project folder.
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- python
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# whether to use the project's gitignore file to ignore files
|
||||
# Added on 2025-04-07
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# list of additional paths to ignore
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||
# Added (renamed) on 2025-04-07
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
project_name: "yargi-mcp"
|
||||
included_optional_tools: []
|
||||
+7
-2
@@ -1,5 +1,5 @@
|
||||
# -------- BASE IMAGE (includes Chromium & deps) ----------------------------
|
||||
FROM mcr.microsoft.com/playwright/python:v1.52.0-noble
|
||||
# -------- BASE IMAGE ---------------------------------------------------------
|
||||
FROM python:3.12-slim
|
||||
|
||||
# -------- Runtime setup ----------------------------------------------------
|
||||
WORKDIR /app
|
||||
@@ -9,8 +9,13 @@ COPY pyproject.toml poetry.lock* requirements*.txt* ./
|
||||
|
||||
# Fast, deterministic install with `uv`
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv pip install --system --no-cache-dir . && \
|
||||
uv pip install --system --no-cache-dir .[asgi,saas]
|
||||
|
||||
# Cache buster - force rebuild
|
||||
ARG CACHE_BUST=202510061202
|
||||
RUN echo "Cache bust: $CACHE_BUST"
|
||||
|
||||
# Copy application source
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -2,12 +2,36 @@
|
||||
|
||||
[](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, Anayasa Mahkemesi - Norm Denetimi ile Bireysel Başvuru Kararları, Kamu İhale Kurulu Kararları, Rekabet Kurumu Kararları ve Sayıştay 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.
|
||||
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ı ve BDDK 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.fastmcp.app/mcp`
|
||||
|
||||
### Claude Desktop ile Kullanım
|
||||
|
||||
1. **Claude Desktop'ı açın**
|
||||
2. **Settings → Connectors → Add Custom Connector**
|
||||
3. **Bilgileri girin:**
|
||||
- **Name:** `Yargı MCP`
|
||||
- **URL:** `https://yargimcp.fastmcp.app/mcp`
|
||||
4. **Add** butonuna tıklayın
|
||||
5. **Hemen kullanmaya başlayın!** 🎉
|
||||
|
||||
> 💡 **İpucu:** Remote MCP sayesinde Python, uv veya herhangi bir kurulum yapmadan doğrudan Claude Desktop üzerinden Türk hukuk kaynaklarına erişebilirsiniz!
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
🎯 **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
|
||||
@@ -26,12 +50,15 @@ Bu proje, çeşitli Türk hukuk kaynaklarına (Yargıtay, Danıştay, Emsal Kara
|
||||
* **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)
|
||||
|
||||
* 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!
|
||||
---
|
||||
🚀 **Claude Haricindeki Modellerle Kullanmak İçin Çok Kolay Kurulum (Örnek: 5ire için)**
|
||||
<details>
|
||||
<summary>🚀 <strong>Claude Haricindeki Modellerle Kullanmak İçin Çok Kolay Kurulum (Örnek: 5ire için)</strong></summary>
|
||||
|
||||
Bu bölüm, Yargı MCP aracını 5ire gibi Claude Desktop dışındaki MCP istemcileriyle kullanmak isteyenler içindir.
|
||||
|
||||
@@ -48,16 +75,18 @@ Bu bölüm, Yargı MCP aracını 5ire gibi Claude Desktop dışındaki MCP istem
|
||||
* **Name:** `Yargı MCP`
|
||||
* **Command:**
|
||||
```
|
||||
uvx --from git+https://github.com/saidsurucu/yargi-mcp yargi-mcp
|
||||
uvx yargi-mcp
|
||||
```
|
||||
* **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.
|
||||
|
||||
---
|
||||
⚙️ **Claude Desktop Manuel Kurulumu**
|
||||
</details>
|
||||
|
||||
---
|
||||
<details>
|
||||
<summary>⚙️ <strong>Claude Desktop Manuel Kurulumu</strong></summary>
|
||||
|
||||
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. Claude Desktop **Settings -> Developer -> Edit Config**.
|
||||
@@ -70,7 +99,6 @@ Bu bölüm, Yargı MCP aracını 5ire gibi Claude Desktop dışındaki MCP istem
|
||||
"Yargı MCP": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"--from", "git+https://github.com/saidsurucu/yargi-mcp",
|
||||
"yargi-mcp"
|
||||
]
|
||||
}
|
||||
@@ -79,8 +107,11 @@ Bu bölüm, Yargı MCP aracını 5ire gibi Claude Desktop dışındaki MCP istem
|
||||
```
|
||||
4. Claude Desktop'ı kapatıp yeniden başlatın.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
🌟 **Gemini CLI ile Kullanım**
|
||||
<details>
|
||||
<summary>🌟 <strong>Gemini CLI ile Kullanım</strong></summary>
|
||||
|
||||
Yargı MCP'yi Gemini CLI ile kullanmak için:
|
||||
|
||||
@@ -101,8 +132,6 @@ Yargı MCP'yi Gemini CLI ile kullanmak için:
|
||||
"yargi_mcp": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"--from",
|
||||
"git+https://github.com/saidsurucu/yargi-mcp",
|
||||
"yargi-mcp"
|
||||
]
|
||||
}
|
||||
@@ -123,58 +152,94 @@ Yargı MCP'yi Gemini CLI ile kullanmak için:
|
||||
- "Danıştay'ın imar planı iptaline ilişkin kararlarını bul"
|
||||
- "Anayasa Mahkemesi'nin ifade özgürlüğü kararlarını getir"
|
||||
|
||||
🛠️ **Kullanılabilir Araçlar (MCP Tools)**
|
||||
</details>
|
||||
|
||||
Bu FastMCP sunucusu aşağıdaki temel araçları sunar:
|
||||
---
|
||||
<details>
|
||||
<summary>🧠 <strong>Semantik Arama (Opsiyonel - OpenRouter API)</strong></summary>
|
||||
|
||||
### **Yargıtay Araçları (Dual API + 52 Daire Filtreleme)**
|
||||
* **Ana API:**
|
||||
* `search_yargitay_detailed(arananKelime, birimYrgKurulDaire, ...)`: Yargıtay kararlarını detaylı kriterlerle arar. **52 daire/kurul seçeneği** (Hukuk/Ceza Daireleri 1-23, Genel Kurullar, Başkanlar Kurulu)
|
||||
* `get_yargitay_document_markdown(id: str)`: Belirli bir Yargıtay kararının metnini Markdown formatında getirir.
|
||||
* **Bedesten API (Alternatif):**
|
||||
* `search_yargitay_bedesten(phrase, birimAdi, kararTarihiStart, kararTarihiEnd, ...)`: Bedesten API ile Yargıtay kararlarını arar. **Aynı 52 daire filtreleme** + **Tarih Filtreleme** + **Kesin Cümle Arama** (`"\"mülkiyet kararı\""`)
|
||||
* `get_yargitay_bedesten_document_markdown(documentId: str)`: Bedesten'den karar metni (HTML/PDF → Markdown)
|
||||
Yargı MCP, **semantik arama** özelliği ile kararları anlamsal olarak sıralayabilir. Bu özellik opsiyoneldir ve `OPENROUTER_API_KEY` ayarlandığında otomatik olarak etkinleşir.
|
||||
|
||||
### **Danıştay Araçları (Triple API + 27 Daire Filtreleme)**
|
||||
* **Ana API'lar:**
|
||||
* `search_danistay_by_keyword(andKelimeler, orKelimeler, ...)`: Danıştay kararlarını anahtar kelimelerle arar.
|
||||
* `search_danistay_detailed(daire, esasYil, ...)`: Danıştay kararlarını detaylı kriterlerle arar.
|
||||
* `get_danistay_document_markdown(id: str)`: Belirli bir Danıştay kararının metnini Markdown formatında getirir.
|
||||
* **Bedesten API (Alternatif):**
|
||||
* `search_danistay_bedesten(phrase, birimAdi, kararTarihiStart, kararTarihiEnd, ...)`: Bedesten API ile Danıştay kararlarını arar. **27 daire/kurul seçeneği** + **Tarih Filtreleme** + **Kesin Cümle Arama** (`"\"idari işlem\""`) (1-17. Daireler, Vergi/İdare Kurulları, Askeri Mahkemeler)
|
||||
* `get_danistay_bedesten_document_markdown(documentId: str)`: Bedesten'den karar metni
|
||||
### 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
|
||||
|
||||
### **Diğer Mahkemeler (Bedesten API + Gelişmiş Arama)**
|
||||
* **Yerel Hukuk Mahkemeleri:**
|
||||
* `search_yerel_hukuk_bedesten(phrase, kararTarihiStart, kararTarihiEnd, ...)`: Yerel hukuk mahkemesi kararlarını arar + **Tarih & Kesin Cümle Arama** (`"\"sözleşme ihlali\""`)
|
||||
* `get_yerel_hukuk_bedesten_document_markdown(documentId: str)`: Karar metni
|
||||
* **İstinaf Hukuk Mahkemeleri:**
|
||||
* `search_istinaf_hukuk_bedesten(phrase, kararTarihiStart, kararTarihiEnd, ...)`: İstinaf mahkemesi kararlarını arar + **Tarih & Kesin Cümle Arama** (`"\"temyiz incelemesi\""`)
|
||||
* `get_istinaf_hukuk_bedesten_document_markdown(documentId: str)`: Karar metni
|
||||
* **Kanun Yararına Bozma (KYB):**
|
||||
* `search_kyb_bedesten(phrase, kararTarihiStart, kararTarihiEnd, ...)`: Olağanüstü kanun yolu kararlarını arar + **Tarih & Kesin Cümle Arama** (`"\"kanun yararına bozma\""`)
|
||||
* `get_kyb_bedesten_document_markdown(documentId: str)`: Karar metni
|
||||
### OpenRouter API Anahtarı Alma
|
||||
1. [OpenRouter](https://openrouter.ai/) sitesine gidin
|
||||
2. Hesap oluşturun ve API anahtarı alın (ücretsiz kredi ile başlayabilirsiniz)
|
||||
|
||||
* **Emsal Karar Araçları:**
|
||||
* `search_emsal_detailed_decisions(search_query: EmsalSearchRequest) -> CompactEmsalSearchResult`: Emsal (UYAP) kararlarını detaylı kriterlerle arar.
|
||||
* `get_emsal_document_markdown(id: str) -> EmsalDocumentMarkdown`: Belirli bir Emsal kararının metnini Markdown formatında getirir.
|
||||
### Claude Desktop için Yapılandırma
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"Yargı MCP": {
|
||||
"command": "uvx",
|
||||
"args": ["yargi-mcp"],
|
||||
"env": {
|
||||
"OPENROUTER_API_KEY": "sk-or-v1-xxx..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
* **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.
|
||||
### 5ire için Yapılandırma
|
||||
Tool ayarlarında **Environment Variables** alanına ekleyin:
|
||||
```
|
||||
OPENROUTER_API_KEY=sk-or-v1-xxx...
|
||||
```
|
||||
|
||||
* **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.
|
||||
### Gemini CLI için Yapılandırma
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"yargi_mcp": {
|
||||
"command": "uvx",
|
||||
"args": ["yargi-mcp"],
|
||||
"env": {
|
||||
"OPENROUTER_API_KEY": "sk-or-v1-xxx..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
* **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.
|
||||
> 💡 **Not:** `OPENROUTER_API_KEY` ayarlanmazsa semantik arama aracı görünmez, diğer 19 araç normal şekilde çalışmaya devam eder.
|
||||
|
||||
* **KİK (Kamu İhale Kurulu) Araçları:**
|
||||
* `search_kik_decisions(search_query: KikSearchRequest) -> KikSearchResult`: KİK (Kamu İhale Kurulu) kararlarını arar.
|
||||
* `get_kik_document_markdown(karar_id: str, page_number: Optional[int] = 1) -> KikDocumentMarkdown`: Belirli bir KİK kararını, Base64 ile encode edilmiş `karar_id`'sini kullanarak alır ve 5.000 karakterlik sayfalanmış Markdown içeriğini getirir.
|
||||
* **Rekabet Kurumu Araçları:**
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🛠️ <strong>Kullanılabilir Araçlar (MCP Tools)</strong></summary>
|
||||
|
||||
Bu FastMCP sunucusu **19 temel 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_decisions(karar_tipi, ...)`: KİK (Kamu İhale Kurulu) kararlarını arar.
|
||||
10. `get_kik_document_markdown(karar_id, page_number)`: Belirli bir KİK kararını, Base64 ile encode edilmiş `karar_id`'sini kullanarak alır ve **sayfalanmış Markdown** içeriğini 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.
|
||||
|
||||
@@ -189,15 +254,34 @@ Bu FastMCP sunucusu aşağıdaki temel araçları sunar:
|
||||
* `get_sayistay_temyiz_kurulu_document_markdown(decision_id: str)`: Temyiz Kurulu kararının tam metnini Markdown formatında getirir
|
||||
* `get_sayistay_daire_document_markdown(decision_id: str)`: Daire kararının tam metnini Markdown formatında getirir
|
||||
|
||||
* **KVKK Araçları (Brave Search API + Türkçe Arama):**
|
||||
* `search_kvkk_decisions(keywords, page, pageSize, ...)`: 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**
|
||||
* `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)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### **📊 Kapsamlı İstatistikler**
|
||||
- **Toplam Mahkeme/Kurum:** 12 farklı hukuki kurum
|
||||
- **Toplam MCP Tool:** 36+ arama ve belge getirme aracı
|
||||
<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:** 13 farklı hukuki kurum (KVKK dahil)
|
||||
- **Toplam MCP Tool:** 19 temel araç + 1 opsiyonel semantik arama aracı
|
||||
- **Daire/Kurul Filtreleme:** 87 farklı seçenek (52 Yargıtay + 27 Danıştay + 8 Sayıştay)
|
||||
- **Tarih Filtreleme:** 5 Bedesten API aracında ISO 8601 formatında tam tarih aralığı desteği
|
||||
- **Kesin Cümle Arama:** 5 Bedesten API aracında çift tırnak ile tam cümle arama (`"\"mülkiyet kararı\""` formatı)
|
||||
- **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
|
||||
|
||||
@@ -222,9 +306,19 @@ Bedesten API Bedesten API Dual/Triple API Norm+Bireysel API
|
||||
- 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>
|
||||
|
||||
---
|
||||
|
||||
🌐 **Web Service / ASGI Deployment**
|
||||
<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:
|
||||
|
||||
@@ -246,6 +340,8 @@ 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,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())
|
||||
@@ -7,8 +7,7 @@ from typing import Dict, Any, List, Optional, Tuple
|
||||
import logging
|
||||
import html
|
||||
import re
|
||||
import tempfile
|
||||
import os
|
||||
import io
|
||||
from urllib.parse import urlencode, urljoin, quote
|
||||
from markitdown import MarkItDown
|
||||
import math # For math.ceil for pagination
|
||||
@@ -100,11 +99,11 @@ class AnayasaBireyselBasvuruApiClient:
|
||||
|
||||
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)
|
||||
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 "")
|
||||
|
||||
|
||||
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
|
||||
ref_no, dec_type, body, app_date, dec_date, url_path = "", "", "", "", "", ""
|
||||
if alti_cizili_div:
|
||||
link_tag = alti_cizili_div.find("a", href=True)
|
||||
if link_tag:
|
||||
@@ -125,14 +124,14 @@ class AnayasaBireyselBasvuruApiClient:
|
||||
ref_no = parts[current_idx]
|
||||
current_idx += 1
|
||||
|
||||
dec_type = parts[current_idx] if len(parts) > current_idx else None
|
||||
dec_type = parts[current_idx] if len(parts) > current_idx else ""
|
||||
current_idx += 1
|
||||
body = parts[current_idx] if len(parts) > current_idx else None
|
||||
body = parts[current_idx] if len(parts) > current_idx else ""
|
||||
current_idx += 1
|
||||
|
||||
app_date_raw = parts[current_idx] if len(parts) > current_idx else None
|
||||
app_date_raw = parts[current_idx] if len(parts) > current_idx else ""
|
||||
current_idx += 1
|
||||
dec_date_raw = parts[current_idx] if len(parts) > current_idx else None
|
||||
dec_date_raw = parts[current_idx] if len(parts) > current_idx else ""
|
||||
|
||||
if app_date_raw and "Başvuru Tarihi :" in app_date_raw:
|
||||
app_date = app_date_raw.replace("Başvuru Tarihi :", "").strip()
|
||||
@@ -149,7 +148,7 @@ class AnayasaBireyselBasvuruApiClient:
|
||||
|
||||
|
||||
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
|
||||
subject_text = subject_div.get_text(strip=True).replace("BAŞVURU KONUSU :", "").strip() if subject_div else ""
|
||||
|
||||
details_list: List[AnayasaBireyselReportDecisionDetail] = []
|
||||
karar_detaylari_div = decision_div.find_next_sibling("div", id="KararDetaylari") # Corrected: was KararDetaylari
|
||||
@@ -160,13 +159,13 @@ class AnayasaBireyselBasvuruApiClient:
|
||||
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,
|
||||
hak=cells[0].get_text(strip=True) or "",
|
||||
mudahale_iddiasi=cells[1].get_text(strip=True) or "",
|
||||
sonuc=cells[2].get_text(strip=True) or "",
|
||||
giderim=cells[3].get_text(strip=True) or "",
|
||||
))
|
||||
|
||||
full_decision_page_url = urljoin(self.BASE_URL, url_path) if url_path else None
|
||||
full_decision_page_url = urljoin(self.BASE_URL, url_path) if url_path else ""
|
||||
|
||||
processed_decisions.append(AnayasaBireyselReportDecisionSummary(
|
||||
title=title_text,
|
||||
@@ -230,23 +229,23 @@ class AnayasaBireyselBasvuruApiClient:
|
||||
html_input_for_markdown = processed_html
|
||||
|
||||
markdown_text = None
|
||||
temp_file_path = None
|
||||
try:
|
||||
md_converter = MarkItDown()
|
||||
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
|
||||
# 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")):
|
||||
html_content = f"<html><head><meta charset=\"UTF-8\"></head><body>{html_input_for_markdown}</body></html>"
|
||||
else:
|
||||
html_content = html_input_for_markdown
|
||||
|
||||
conversion_result = md_converter.convert(temp_file_path)
|
||||
# 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()
|
||||
conversion_result = md_converter.convert(html_stream)
|
||||
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(
|
||||
|
||||
@@ -7,8 +7,7 @@ from typing import Dict, Any, List, Optional, Tuple
|
||||
import logging
|
||||
import html
|
||||
import re
|
||||
import tempfile
|
||||
import os
|
||||
import io
|
||||
from urllib.parse import urlencode, urljoin, quote
|
||||
from markitdown import MarkItDown
|
||||
import math # For math.ceil for pagination
|
||||
@@ -51,36 +50,43 @@ class AnayasaMahkemesiApiClient:
|
||||
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 and params.period.value != "ALL": query_params.append(("Donemler_id", params.period.value))
|
||||
if params.period and params.period and params.period != "ALL": query_params.append(("Donemler_id", params.period))
|
||||
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 and params.application_type.value != "ALL": query_params.append(("BasvuruTurler_id", params.application_type.value))
|
||||
if params.application_type and params.application_type and params.application_type != "ALL": query_params.append(("BasvuruTurler_id", params.application_type))
|
||||
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 and params.norm_type.value != "ALL": query_params.append(("NormunTurler_id", params.norm_type.value))
|
||||
if params.norm_type and params.norm_type and params.norm_type != "ALL": query_params.append(("NormunTurler_id", params.norm_type))
|
||||
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 and outcome_enum_val.value != "ALL": query_params.append(("IncelemeTuruKararSonuclar_id[]", outcome_enum_val.value))
|
||||
if params.reason_for_final_outcome and params.reason_for_final_outcome.value and params.reason_for_final_outcome.value != "ALL":
|
||||
query_params.append(("KararSonucununGerekcesi", params.reason_for_final_outcome.value))
|
||||
for outcome_val in params.review_outcomes:
|
||||
if outcome_val and outcome_val != "ALL": query_params.append(("IncelemeTuruKararSonuclar_id[]", outcome_val))
|
||||
if params.reason_for_final_outcome and params.reason_for_final_outcome and params.reason_for_final_outcome != "ALL":
|
||||
query_params.append(("KararSonucununGerekcesi", params.reason_for_final_outcome))
|
||||
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 and params.has_press_release.value != "ALL": query_params.append(("BasinDuyurusu", params.has_press_release.value))
|
||||
if params.has_dissenting_opinion and params.has_dissenting_opinion.value and params.has_dissenting_opinion.value != "ALL": query_params.append(("KarsiOy", params.has_dissenting_opinion.value))
|
||||
if params.has_different_reasoning and params.has_different_reasoning.value and params.has_different_reasoning.value != "ALL": query_params.append(("FarkliGerekce", params.has_different_reasoning.value))
|
||||
if params.has_press_release and params.has_press_release and params.has_press_release != "ALL": query_params.append(("BasinDuyurusu", params.has_press_release))
|
||||
if params.has_dissenting_opinion and params.has_dissenting_opinion and params.has_dissenting_opinion != "ALL": query_params.append(("KarsiOy", params.has_dissenting_opinion))
|
||||
if params.has_different_reasoning and params.has_different_reasoning and params.has_different_reasoning != "ALL": query_params.append(("FarkliGerekce", params.has_different_reasoning))
|
||||
|
||||
# Add pagination and sorting parameters as query params instead of URL path
|
||||
if params.results_per_page and params.results_per_page != 10:
|
||||
query_params.append(("SatirSayisi", str(params.results_per_page)))
|
||||
|
||||
if params.sort_by_criteria and params.sort_by_criteria != "KararTarihi":
|
||||
query_params.append(("Siralama", params.sort_by_criteria))
|
||||
|
||||
if params.page_to_fetch and params.page_to_fetch > 1:
|
||||
query_params.append(("page", str(params.page_to_fetch)))
|
||||
@@ -90,16 +96,8 @@ class AnayasaMahkemesiApiClient:
|
||||
self,
|
||||
params: AnayasaNormDenetimiSearchRequest
|
||||
) -> AnayasaSearchResult:
|
||||
path_segments = []
|
||||
if params.results_per_page and params.results_per_page != 10: # Default is 10
|
||||
path_segments.append(f"SatirSayisi/{params.results_per_page}")
|
||||
|
||||
if params.sort_by_criteria and params.sort_by_criteria != "KararTarihi": # Default is KararTarihi
|
||||
# Ensure correct quoting for criteria that might have Turkish chars or spaces
|
||||
path_segments.append(f"Siralama/{quote(params.sort_by_criteria)}")
|
||||
|
||||
path_segments.append(self.SEARCH_PATH_SEGMENT)
|
||||
request_path = "/" + "/".join(path_segments)
|
||||
# Use simple /Ara endpoint - the complex path structure seems to cause 404s
|
||||
request_path = f"/{self.SEARCH_PATH_SEGMENT}"
|
||||
|
||||
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}")
|
||||
@@ -222,24 +220,23 @@ class AnayasaMahkemesiApiClient:
|
||||
html_input_for_markdown = str(body_tag) if body_tag else processed_html
|
||||
|
||||
markdown_text = None
|
||||
temp_file_path = None
|
||||
try:
|
||||
md_converter = MarkItDown()
|
||||
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
|
||||
# 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")):
|
||||
html_content = f"<html><head><meta charset=\"UTF-8\"></head><body>{html_input_for_markdown}</body></html>"
|
||||
else:
|
||||
html_content = html_input_for_markdown
|
||||
|
||||
conversion_result = md_converter.convert(temp_file_path)
|
||||
# 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()
|
||||
conversion_result = md_converter.convert(html_stream)
|
||||
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(
|
||||
@@ -277,6 +274,11 @@ class AnayasaMahkemesiApiClient:
|
||||
if not karar_metni_div: # Fallback if not in KararMetni
|
||||
karar_metni_div = soup.find("div", class_="WordSection1")
|
||||
|
||||
# Initialize with empty string defaults
|
||||
decision_ek_no_from_page = ""
|
||||
decision_date_from_page = ""
|
||||
official_gazette_from_page = ""
|
||||
|
||||
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
|
||||
|
||||
@@ -1,43 +1,21 @@
|
||||
# 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 = "ALL"
|
||||
DONEM_1961 = "1"
|
||||
DONEM_1982 = "2"
|
||||
|
||||
class AnayasaBasvuruTuruEnum(str, Enum):
|
||||
TUMU = "ALL"
|
||||
IPTAL = "1"
|
||||
ITIRAZ = "2"
|
||||
DIGER = "3"
|
||||
|
||||
class AnayasaVarYokEnum(str, Enum):
|
||||
TUMU = "ALL"
|
||||
YOK = "0"
|
||||
VAR = "1"
|
||||
|
||||
class AnayasaNormTuruEnum(str, Enum):
|
||||
TUMU = "ALL"
|
||||
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 = "ALL"
|
||||
@@ -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.")
|
||||
@@ -168,27 +146,27 @@ class AnayasaBireyselReportSearchRequest(BaseModel):
|
||||
|
||||
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 +188,43 @@ 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."""
|
||||
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")
|
||||
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)")
|
||||
|
||||
# Norm Denetimi specific parameters (ignored for bireysel_basvuru)
|
||||
keywords_all: List[str] = Field(default_factory=list, description="All keywords must be present (norm_denetimi only)")
|
||||
keywords_any: List[str] = Field(default_factory=list, description="Any of these keywords (norm_denetimi only)")
|
||||
decision_type_norm: Literal["ALL", "1", "2", "3"] = Field("ALL", description="Decision type for norm denetimi")
|
||||
application_date_start: str = Field("", description="Application start date (norm_denetimi only)")
|
||||
application_date_end: str = Field("", description="Application end date (norm_denetimi only)")
|
||||
|
||||
# Bireysel Başvuru specific parameters (ignored for norm_denetimi)
|
||||
decision_start_date: str = Field("", description="Decision start date (bireysel_basvuru only)")
|
||||
decision_end_date: str = Field("", description="Decision end date (bireysel_basvuru only)")
|
||||
norm_type: Literal["ALL", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "0"] = Field("ALL", description="Norm type (bireysel_basvuru only)")
|
||||
subject_category: str = Field("", description="Subject category (bireysel_basvuru only)")
|
||||
|
||||
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,122 @@
|
||||
# anayasa_mcp_module/unified_client.py
|
||||
# Unified client for both Norm Denetimi and Bireysel Başvuru
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .models import (
|
||||
AnayasaUnifiedSearchRequest,
|
||||
AnayasaUnifiedSearchResult,
|
||||
AnayasaUnifiedDocumentMarkdown,
|
||||
# Removed AnayasaDecisionTypeEnum - now using string literals
|
||||
AnayasaNormDenetimiSearchRequest,
|
||||
AnayasaBireyselReportSearchRequest
|
||||
)
|
||||
from .client import AnayasaMahkemesiApiClient
|
||||
from .bireysel_client import AnayasaBireyselBasvuruApiClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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 appropriate client based on decision_type."""
|
||||
|
||||
if params.decision_type == "norm_denetimi":
|
||||
# Convert to norm denetimi request
|
||||
norm_params = AnayasaNormDenetimiSearchRequest(
|
||||
keywords_all=params.keywords_all or params.keywords,
|
||||
keywords_any=params.keywords_any,
|
||||
application_type=params.decision_type_norm,
|
||||
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)
|
||||
|
||||
# Convert to unified format
|
||||
decisions_list = [decision.model_dump() for decision in result.decisions]
|
||||
|
||||
return AnayasaUnifiedSearchResult(
|
||||
decision_type="norm_denetimi",
|
||||
decisions=decisions_list,
|
||||
total_records_found=result.total_records_found,
|
||||
retrieved_page_number=result.retrieved_page_number
|
||||
)
|
||||
|
||||
elif params.decision_type == "bireysel_basvuru":
|
||||
# Convert to bireysel başvuru request
|
||||
bireysel_params = AnayasaBireyselReportSearchRequest(
|
||||
keywords=params.keywords,
|
||||
decision_start_date=params.decision_start_date,
|
||||
decision_end_date=params.decision_end_date,
|
||||
norm_type=params.norm_type,
|
||||
subject_category=params.subject_category,
|
||||
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)
|
||||
|
||||
# Convert to unified format
|
||||
decisions_list = [decision.model_dump() for decision in result.decisions]
|
||||
|
||||
return AnayasaUnifiedSearchResult(
|
||||
decision_type="bireysel_basvuru",
|
||||
decisions=decisions_list,
|
||||
total_records_found=result.total_records_found,
|
||||
retrieved_page_number=result.retrieved_page_number
|
||||
)
|
||||
|
||||
else:
|
||||
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 appropriate client."""
|
||||
|
||||
# Auto-detect decision type based on URL
|
||||
parsed_url = urlparse(document_url)
|
||||
|
||||
if "normkararlarbilgibankasi" in parsed_url.netloc or "/ND/" in document_url:
|
||||
# Norm Denetimi document
|
||||
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(),
|
||||
markdown_chunk=result.markdown_chunk,
|
||||
current_page=result.current_page,
|
||||
total_pages=result.total_pages,
|
||||
is_paginated=result.is_paginated
|
||||
)
|
||||
|
||||
elif "kararlarbilgibankasi" in parsed_url.netloc or "/BB/" in document_url:
|
||||
# Bireysel Başvuru document
|
||||
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(),
|
||||
markdown_chunk=result.markdown_chunk,
|
||||
current_page=result.current_page,
|
||||
total_pages=result.total_pages,
|
||||
is_paginated=result.is_paginated
|
||||
)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Cannot determine document type from URL: {document_url}")
|
||||
|
||||
async def close_client_session(self):
|
||||
"""Close both client sessions."""
|
||||
if hasattr(self.norm_client, 'close_client_session'):
|
||||
await self.norm_client.close_client_session()
|
||||
if hasattr(self.bireysel_client, 'close_client_session'):
|
||||
await self.bireysel_client.close_client_session()
|
||||
Regular → Executable
+403
-147
@@ -3,65 +3,143 @@ 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
|
||||
for Stripe webhook integration.
|
||||
for OAuth integration and proper middleware support.
|
||||
|
||||
Usage:
|
||||
uvicorn asgi_app:app --host 0.0.0.0 --port 8000
|
||||
"""
|
||||
|
||||
import os
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
import time
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import FastAPI, Request, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse, HTMLResponse, Response
|
||||
from fastapi.exception_handlers import http_exception_handler
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.responses import Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
# Import the fully configured MCP app with all tools
|
||||
from mcp_server_main import app as mcp_server
|
||||
# Import the proper create_app function that includes all middleware
|
||||
from mcp_server_main import create_app
|
||||
|
||||
# Import Stripe webhook router
|
||||
from stripe_webhook import router as stripe_router
|
||||
# Conditional auth-related imports (only if auth enabled)
|
||||
_auth_check = os.getenv("ENABLE_AUTH", "false").lower() == "true"
|
||||
|
||||
# Import MCP Auth HTTP adapter
|
||||
from mcp_auth_http_adapter import router as mcp_auth_router
|
||||
if _auth_check:
|
||||
# Import MCP Auth HTTP adapter (OAuth endpoints)
|
||||
try:
|
||||
from mcp_auth_http_simple import router as mcp_auth_router
|
||||
except ImportError:
|
||||
mcp_auth_router = None
|
||||
|
||||
# Import Stripe webhook router
|
||||
try:
|
||||
from stripe_webhook import router as stripe_router
|
||||
except ImportError:
|
||||
stripe_router = None
|
||||
else:
|
||||
mcp_auth_router = None
|
||||
stripe_router = None
|
||||
|
||||
# OAuth configuration from environment variables
|
||||
CLERK_ISSUER = os.getenv("CLERK_ISSUER", "https://accounts.yargimcp.com")
|
||||
BASE_URL = os.getenv("BASE_URL", "https://yargimcp.com")
|
||||
CLERK_ISSUER = os.getenv("CLERK_ISSUER", "https://clerk.yargimcp.com")
|
||||
BASE_URL = os.getenv("BASE_URL", "https://api.yargimcp.com")
|
||||
CLERK_SECRET_KEY = os.getenv("CLERK_SECRET_KEY")
|
||||
CLERK_PUBLISHABLE_KEY = os.getenv("CLERK_PUBLISHABLE_KEY")
|
||||
|
||||
# Configure CORS middleware
|
||||
# Setup logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configure CORS and Auth middleware
|
||||
cors_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",")
|
||||
|
||||
# Import FastMCP Bearer Auth Provider
|
||||
from fastmcp.server.auth import BearerAuthProvider
|
||||
from fastmcp.server.auth.providers.bearer import RSAKeyPair
|
||||
|
||||
# Import Clerk SDK at module level for performance
|
||||
try:
|
||||
from clerk_backend_api import Clerk
|
||||
CLERK_SDK_AVAILABLE = True
|
||||
except ImportError:
|
||||
CLERK_SDK_AVAILABLE = False
|
||||
logger.warning("Clerk SDK not available - falling back to development mode")
|
||||
|
||||
# Configure Bearer token authentication based on ENABLE_AUTH
|
||||
auth_enabled = os.getenv("ENABLE_AUTH", "false").lower() == "true"
|
||||
bearer_auth = None
|
||||
|
||||
if CLERK_SECRET_KEY and CLERK_ISSUER:
|
||||
# Production: Use Clerk JWKS endpoint for token validation
|
||||
bearer_auth = BearerAuthProvider(
|
||||
jwks_uri=f"{CLERK_ISSUER}/.well-known/jwks.json",
|
||||
issuer=None,
|
||||
algorithm="RS256",
|
||||
audience=None,
|
||||
required_scopes=[]
|
||||
)
|
||||
else:
|
||||
# Development: Generate RSA key pair for testing
|
||||
dev_key_pair = RSAKeyPair.generate()
|
||||
bearer_auth = BearerAuthProvider(
|
||||
public_key=dev_key_pair.public_key,
|
||||
issuer="https://dev.yargimcp.com",
|
||||
audience="dev-mcp-server",
|
||||
required_scopes=["yargi.read"]
|
||||
)
|
||||
|
||||
# Create MCP app with Bearer authentication
|
||||
mcp_server = create_app(auth=bearer_auth if auth_enabled else None)
|
||||
|
||||
# Create MCP Starlette sub-application with root path - mount will add /mcp prefix
|
||||
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", "Authorization", "X-Request-ID"],
|
||||
allow_methods=["GET", "POST", "OPTIONS", "DELETE"],
|
||||
allow_headers=["Content-Type", "Authorization", "X-Request-ID", "X-Session-ID"],
|
||||
),
|
||||
]
|
||||
|
||||
# Create MCP Starlette sub-application first
|
||||
mcp_app = mcp_server.http_app(
|
||||
path="/",
|
||||
middleware=custom_middleware
|
||||
)
|
||||
|
||||
# Create FastAPI wrapper application with MCP app's lifespan
|
||||
# Create FastAPI wrapper application
|
||||
app = FastAPI(
|
||||
title="Yargı MCP Server",
|
||||
description="MCP server for Turkish legal databases with OAuth authentication",
|
||||
version="0.1.0",
|
||||
middleware=custom_middleware,
|
||||
lifespan=mcp_app.lifespan # Critical: Get lifespan from mcp_app, not mcp_server
|
||||
default_response_class=UTF8JSONResponse, # Use UTF-8 JSON encoder
|
||||
redirect_slashes=False # Disable to prevent 307 redirects on /mcp endpoint
|
||||
)
|
||||
|
||||
# Add Stripe webhook router to FastAPI
|
||||
app.include_router(stripe_router, prefix="/api")
|
||||
# Add auth-related routers to FastAPI (only if available)
|
||||
if stripe_router:
|
||||
app.include_router(stripe_router, prefix="/api/stripe")
|
||||
|
||||
# Add MCP Auth HTTP adapter to FastAPI (replaces old OAuth router)
|
||||
app.include_router(mcp_auth_router)
|
||||
if mcp_auth_router:
|
||||
app.include_router(mcp_auth_router)
|
||||
|
||||
# Custom 401 exception handler for MCP spec compliance
|
||||
@app.exception_handler(401)
|
||||
@@ -80,67 +158,110 @@ async def custom_401_handler(request: Request, exc: HTTPException):
|
||||
|
||||
return response
|
||||
|
||||
# Mount MCP app as sub-application
|
||||
app.mount("/mcp", mcp_app)
|
||||
|
||||
# Add POST handler for /mcp to forward to mounted app
|
||||
@app.post("/mcp")
|
||||
async def mcp_post_handler(request: Request):
|
||||
"""Forward POST /mcp requests to mounted MCP app"""
|
||||
# Forward to the mounted app by calling it directly
|
||||
async def receive():
|
||||
return await request.receive()
|
||||
|
||||
# Create a new scope for the mounted app
|
||||
scope = request.scope.copy()
|
||||
scope["path"] = "/" # Root path for the mounted app
|
||||
scope["path_info"] = "/"
|
||||
|
||||
# Capture response
|
||||
response_parts = {"status": 200, "headers": [], "body": b""}
|
||||
|
||||
async def send(message):
|
||||
if message["type"] == "http.response.start":
|
||||
response_parts["status"] = message["status"]
|
||||
response_parts["headers"] = message["headers"]
|
||||
elif message["type"] == "http.response.body":
|
||||
response_parts["body"] += message.get("body", b"")
|
||||
|
||||
# Call the mounted MCP app
|
||||
await mcp_app(scope, receive, send)
|
||||
|
||||
# Return the response
|
||||
from starlette.responses import Response
|
||||
|
||||
# Convert ASGI headers to dict
|
||||
headers = {}
|
||||
for name, value in response_parts["headers"]:
|
||||
headers[name.decode()] = value.decode()
|
||||
|
||||
return Response(
|
||||
content=response_parts["body"],
|
||||
status_code=response_parts["status"],
|
||||
headers=headers
|
||||
)
|
||||
|
||||
|
||||
# FastAPI health check endpoint
|
||||
# FastAPI health check endpoint - BEFORE mounting MCP app
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint for monitoring"""
|
||||
return JSONResponse({
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "Yargı MCP Server",
|
||||
"version": "0.1.0",
|
||||
"tools_count": len(mcp_server._tool_manager._tools),
|
||||
"auth_enabled": os.getenv("ENABLE_AUTH", "false").lower() == "true"
|
||||
})
|
||||
}
|
||||
|
||||
# Add explicit redirect for /mcp to /mcp/ with method preservation
|
||||
@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)
|
||||
|
||||
# MCP mount at /mcp handles path routing correctly
|
||||
|
||||
# IMPORTANT: Add FastAPI endpoints BEFORE mounting MCP app
|
||||
# Otherwise mount at root will catch all requests
|
||||
|
||||
# Debug endpoint to test routing
|
||||
@app.get("/debug/test")
|
||||
async def debug_test():
|
||||
"""Debug endpoint to test if FastAPI routes work"""
|
||||
return {"message": "FastAPI routes working", "debug": True}
|
||||
|
||||
# Clerk CORS proxy endpoints
|
||||
@app.api_route("/clerk-proxy/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||
async def clerk_cors_proxy(request: Request, path: str):
|
||||
"""
|
||||
Proxy requests to Clerk to bypass CORS restrictions.
|
||||
Forwards requests from Claude AI to clerk.yargimcp.com with proper CORS headers.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
# Build target URL
|
||||
clerk_url = f"https://clerk.yargimcp.com/{path}"
|
||||
|
||||
# Forward query parameters
|
||||
if request.url.query:
|
||||
clerk_url += f"?{request.url.query}"
|
||||
|
||||
# Copy headers (exclude host/origin)
|
||||
headers = dict(request.headers)
|
||||
headers.pop('host', None)
|
||||
headers.pop('origin', None)
|
||||
headers['origin'] = 'https://yargimcp.com' # Use our frontend domain
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Forward the request to Clerk
|
||||
if request.method == "OPTIONS":
|
||||
# Handle preflight
|
||||
response = await client.request(
|
||||
method=request.method,
|
||||
url=clerk_url,
|
||||
headers=headers
|
||||
)
|
||||
else:
|
||||
# Forward body for POST/PUT requests
|
||||
body = None
|
||||
if request.method in ["POST", "PUT", "PATCH"]:
|
||||
body = await request.body()
|
||||
|
||||
response = await client.request(
|
||||
method=request.method,
|
||||
url=clerk_url,
|
||||
headers=headers,
|
||||
content=body
|
||||
)
|
||||
|
||||
# Create response with CORS headers
|
||||
response_headers = dict(response.headers)
|
||||
response_headers.update({
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization, Accept, Origin, X-Requested-With",
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
"Access-Control-Max-Age": "86400"
|
||||
})
|
||||
|
||||
return Response(
|
||||
content=response.content,
|
||||
status_code=response.status_code,
|
||||
headers=response_headers,
|
||||
media_type=response.headers.get("content-type")
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{"error": "proxy_error", "message": str(e)},
|
||||
status_code=500,
|
||||
headers={"Access-Control-Allow-Origin": "*"}
|
||||
)
|
||||
|
||||
# FastAPI root endpoint
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint with service information"""
|
||||
return JSONResponse({
|
||||
return {
|
||||
"service": "Yargı MCP Server",
|
||||
"description": "MCP server for Turkish legal databases with OAuth authentication",
|
||||
"endpoints": {
|
||||
@@ -153,6 +274,9 @@ async def root():
|
||||
"oauth_google": "/auth/google/login",
|
||||
"user_info": "/auth/user"
|
||||
},
|
||||
"transports": {
|
||||
"http": "/mcp"
|
||||
},
|
||||
"supported_databases": [
|
||||
"Yargıtay (Court of Cassation)",
|
||||
"Danıştay (Council of State)",
|
||||
@@ -162,25 +286,27 @@ async def root():
|
||||
"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)",
|
||||
"Bedesten API (Multiple courts)"
|
||||
],
|
||||
"authentication": {
|
||||
"enabled": os.getenv("ENABLE_AUTH", "false").lower() == "true",
|
||||
"type": "OAuth 2.0 via Clerk",
|
||||
"issuer": os.getenv("CLERK_ISSUER", "https://clerk.accounts.dev"),
|
||||
"issuer": CLERK_ISSUER,
|
||||
"providers": ["google"],
|
||||
"flow": "authorization_code"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
# OAuth 2.0 Authorization Server Metadata proxy (for MCP clients that can't reach Clerk directly)
|
||||
# OAuth 2.0 Authorization Server Metadata - MCP standard location
|
||||
@app.get("/.well-known/oauth-authorization-server")
|
||||
async def oauth_authorization_server():
|
||||
"""OAuth 2.0 Authorization Server Metadata proxy to Clerk"""
|
||||
return JSONResponse({
|
||||
"issuer": CLERK_ISSUER,
|
||||
async def oauth_authorization_server_root():
|
||||
"""OAuth 2.0 Authorization Server Metadata - root level for compatibility"""
|
||||
return {
|
||||
"issuer": BASE_URL, # Use BASE_URL as issuer for MCP integration
|
||||
"authorization_endpoint": f"{BASE_URL}/auth/login",
|
||||
"token_endpoint": f"{BASE_URL}/auth/callback",
|
||||
"token_endpoint": f"{BASE_URL}/token",
|
||||
"jwks_uri": f"{CLERK_ISSUER}/.well-known/jwks.json",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
@@ -191,58 +317,36 @@ async def oauth_authorization_server():
|
||||
"claims_supported": ["sub", "iss", "aud", "exp", "iat", "email", "name"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"service_documentation": f"{BASE_URL}/mcp",
|
||||
"registration_endpoint": f"{BASE_URL}/auth/register",
|
||||
"registration_endpoint": f"{BASE_URL}/register",
|
||||
"resource_documentation": f"{BASE_URL}/mcp"
|
||||
})
|
||||
}
|
||||
|
||||
# MCP endpoint info for GET requests (ChatGPT compatibility)
|
||||
@app.get("/mcp")
|
||||
async def mcp_info():
|
||||
"""MCP endpoint information for discovery"""
|
||||
return JSONResponse({
|
||||
"mcp_server": True,
|
||||
"name": "Yargı MCP Server",
|
||||
"version": "0.1.0",
|
||||
"description": "MCP server for Turkish legal databases",
|
||||
"protocol": "mcp/1.0",
|
||||
"transport": "http",
|
||||
"authentication_required": True,
|
||||
"authentication": {
|
||||
"type": "oauth2",
|
||||
"authorization_url": f"{BASE_URL}/auth/login",
|
||||
"token_url": f"{BASE_URL}/auth/callback",
|
||||
"scopes": ["read", "search"],
|
||||
"provider": "clerk"
|
||||
},
|
||||
"endpoints": {
|
||||
"mcp_protocol": "/mcp",
|
||||
"discovery": "/mcp/discovery",
|
||||
"well_known": "/.well-known/mcp",
|
||||
"health": "/health",
|
||||
"oauth_login": "/auth/login"
|
||||
},
|
||||
"capabilities": {
|
||||
"tools": True,
|
||||
"resources": True,
|
||||
"prompts": False
|
||||
},
|
||||
"tools_count": len(mcp_server._tool_manager._tools),
|
||||
"usage": {
|
||||
"note": "This is an MCP server. Use POST to /mcp/ with proper MCP protocol headers.",
|
||||
"headers_required": [
|
||||
"Content-Type: application/json",
|
||||
"Accept: application/json, text/event-stream",
|
||||
"Authorization: Bearer <token>",
|
||||
"X-Session-ID: <session-id>"
|
||||
]
|
||||
}
|
||||
})
|
||||
# Claude AI MCP specific endpoint format - suffix versions
|
||||
@app.get("/.well-known/oauth-authorization-server/mcp")
|
||||
async def oauth_authorization_server_mcp_suffix():
|
||||
"""OAuth 2.0 Authorization Server Metadata - Claude AI MCP specific format"""
|
||||
return {
|
||||
"issuer": BASE_URL, # Use BASE_URL as issuer for MCP integration
|
||||
"authorization_endpoint": f"{BASE_URL}/auth/login",
|
||||
"token_endpoint": f"{BASE_URL}/token",
|
||||
"jwks_uri": f"{CLERK_ISSUER}/.well-known/jwks.json",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_basic", "none"],
|
||||
"scopes_supported": ["read", "search", "openid", "profile", "email"],
|
||||
"subject_types_supported": ["public"],
|
||||
"id_token_signing_alg_values_supported": ["RS256"],
|
||||
"claims_supported": ["sub", "iss", "aud", "exp", "iat", "email", "name"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"service_documentation": f"{BASE_URL}/mcp",
|
||||
"registration_endpoint": f"{BASE_URL}/register",
|
||||
"resource_documentation": f"{BASE_URL}/mcp"
|
||||
}
|
||||
|
||||
# OAuth 2.0 Protected Resource Metadata (RFC 9728) - MCP Spec Required
|
||||
@app.get("/.well-known/oauth-protected-resource")
|
||||
async def oauth_protected_resource():
|
||||
"""OAuth 2.0 Protected Resource Metadata as required by MCP spec"""
|
||||
return JSONResponse({
|
||||
@app.get("/.well-known/oauth-protected-resource/mcp")
|
||||
async def oauth_protected_resource_mcp_suffix():
|
||||
"""OAuth 2.0 Protected Resource Metadata - Claude AI MCP specific format"""
|
||||
return {
|
||||
"resource": BASE_URL,
|
||||
"authorization_servers": [
|
||||
BASE_URL
|
||||
@@ -251,13 +355,28 @@ async def oauth_protected_resource():
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_documentation": f"{BASE_URL}/mcp",
|
||||
"resource_policy_uri": f"{BASE_URL}/privacy"
|
||||
})
|
||||
}
|
||||
|
||||
# OAuth 2.0 Protected Resource Metadata (RFC 9728) - MCP Spec Required
|
||||
@app.get("/.well-known/oauth-protected-resource")
|
||||
async def oauth_protected_resource():
|
||||
"""OAuth 2.0 Protected Resource Metadata as required by MCP spec"""
|
||||
return {
|
||||
"resource": BASE_URL,
|
||||
"authorization_servers": [
|
||||
BASE_URL
|
||||
],
|
||||
"scopes_supported": ["read", "search"],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_documentation": f"{BASE_URL}/mcp",
|
||||
"resource_policy_uri": f"{BASE_URL}/privacy"
|
||||
}
|
||||
|
||||
# Standard well-known discovery endpoint
|
||||
@app.get("/.well-known/mcp")
|
||||
async def well_known_mcp():
|
||||
"""Standard MCP discovery endpoint"""
|
||||
return JSONResponse({
|
||||
return {
|
||||
"mcp_server": {
|
||||
"name": "Yargı MCP Server",
|
||||
"version": "0.1.0",
|
||||
@@ -270,13 +389,13 @@ async def well_known_mcp():
|
||||
"capabilities": ["tools", "resources"],
|
||||
"tools_count": len(mcp_server._tool_manager._tools)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
# MCP Discovery endpoint for ChatGPT integration
|
||||
@app.get("/mcp/discovery")
|
||||
async def mcp_discovery():
|
||||
"""MCP Discovery endpoint for ChatGPT and other MCP clients"""
|
||||
return JSONResponse({
|
||||
return {
|
||||
"name": "Yargı MCP Server",
|
||||
"description": "MCP server for Turkish legal databases",
|
||||
"version": "0.1.0",
|
||||
@@ -286,7 +405,7 @@ async def mcp_discovery():
|
||||
"authentication": {
|
||||
"type": "oauth2",
|
||||
"authorization_url": "/auth/login",
|
||||
"token_url": "/auth/callback",
|
||||
"token_url": "/token",
|
||||
"scopes": ["read", "search"],
|
||||
"provider": "clerk"
|
||||
},
|
||||
@@ -300,7 +419,7 @@ async def mcp_discovery():
|
||||
"url": BASE_URL,
|
||||
"email": "support@yargi-mcp.dev"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
# FastAPI status endpoint
|
||||
@app.get("/status")
|
||||
@@ -313,21 +432,158 @@ async def status():
|
||||
"description": tool.description[:100] + "..." if len(tool.description) > 100 else tool.description
|
||||
})
|
||||
|
||||
return JSONResponse({
|
||||
return {
|
||||
"status": "operational",
|
||||
"tools": tools,
|
||||
"total_tools": len(tools),
|
||||
"transport": "streamable_http",
|
||||
"architecture": "FastAPI wrapper + MCP Starlette sub-app",
|
||||
"auth_status": "enabled" if os.getenv("ENABLE_AUTH", "false").lower() == "true" else "disabled"
|
||||
})
|
||||
}
|
||||
|
||||
# Alternative: SSE transport (for compatibility)
|
||||
sse_app = mcp_server.http_app(
|
||||
path="/sse",
|
||||
transport="sse",
|
||||
middleware=custom_middleware
|
||||
)
|
||||
# Simplified OAuth session validation for callback endpoints only
|
||||
async def validate_clerk_session_for_oauth(request: Request, clerk_token: str = None) -> str:
|
||||
"""Validate Clerk session for OAuth callback endpoints only (not for MCP endpoints)"""
|
||||
|
||||
try:
|
||||
# Use Clerk SDK if available
|
||||
if not CLERK_SDK_AVAILABLE:
|
||||
raise ImportError("Clerk SDK not available")
|
||||
clerk = Clerk(bearer_auth=CLERK_SECRET_KEY)
|
||||
|
||||
# Try JWT token first (from URL parameter)
|
||||
if clerk_token:
|
||||
try:
|
||||
return "oauth_user_from_token"
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
# Fallback to cookie validation
|
||||
clerk_session = request.cookies.get("__session")
|
||||
if not clerk_session:
|
||||
raise HTTPException(status_code=401, detail="No Clerk session found")
|
||||
|
||||
# Validate session with Clerk
|
||||
session = clerk.sessions.verify_session(clerk_session)
|
||||
return session.user_id
|
||||
|
||||
except ImportError:
|
||||
return "dev_user_123"
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=401, detail=f"OAuth session validation failed: {str(e)}")
|
||||
|
||||
# MCP OAuth Callback Endpoint
|
||||
@app.get("/auth/mcp-callback")
|
||||
async def mcp_oauth_callback(request: Request, clerk_token: str = Query(None)):
|
||||
"""Handle OAuth callback for MCP token generation"""
|
||||
|
||||
try:
|
||||
# Validate Clerk session with JWT token support
|
||||
user_id = await validate_clerk_session_for_oauth(request, clerk_token)
|
||||
|
||||
# Return success response
|
||||
return HTMLResponse(f"""
|
||||
<html>
|
||||
<head>
|
||||
<title>MCP Connection Successful</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; text-align: center; padding: 50px; }}
|
||||
.success {{ color: #28a745; }}
|
||||
.token {{ background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0; word-break: break-all; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="success">✅ MCP Connection Successful!</h1>
|
||||
<p>Your Yargı MCP integration is now active.</p>
|
||||
<div class="token">
|
||||
<strong>Authentication:</strong><br>
|
||||
<code>Use your Clerk JWT token directly with Bearer authentication</code>
|
||||
</div>
|
||||
<p>You can now close this window and return to your MCP client.</p>
|
||||
<script>
|
||||
// Try to close the popup if opened as such
|
||||
if (window.opener) {{
|
||||
window.opener.postMessage({{
|
||||
type: 'MCP_AUTH_SUCCESS',
|
||||
token: 'use_clerk_jwt_token'
|
||||
}}, '*');
|
||||
setTimeout(() => window.close(), 3000);
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
|
||||
except HTTPException as e:
|
||||
return HTMLResponse(f"""
|
||||
<html>
|
||||
<head>
|
||||
<title>MCP Connection Failed</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; text-align: center; padding: 50px; }}
|
||||
.error {{ color: #dc3545; }}
|
||||
.debug {{ background: #f8f9fa; padding: 10px; margin: 20px 0; border-radius: 5px; font-family: monospace; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="error">❌ MCP Connection Failed</h1>
|
||||
<p>{e.detail}</p>
|
||||
<div class="debug">
|
||||
<strong>Debug Info:</strong><br>
|
||||
Clerk Token: {'✅ Provided' if clerk_token else '❌ Missing'}<br>
|
||||
Error: {e.detail}<br>
|
||||
Status: {e.status_code}
|
||||
</div>
|
||||
<p>Please try again or contact support.</p>
|
||||
<a href="https://yargimcp.com/sign-in">Return to Sign In</a>
|
||||
</body>
|
||||
</html>
|
||||
""", status_code=e.status_code)
|
||||
except Exception as e:
|
||||
return HTMLResponse(f"""
|
||||
<html>
|
||||
<head>
|
||||
<title>MCP Connection Error</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; text-align: center; padding: 50px; }}
|
||||
.error {{ color: #dc3545; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="error">❌ Unexpected Error</h1>
|
||||
<p>An unexpected error occurred during authentication.</p>
|
||||
<p>Error: {str(e)}</p>
|
||||
<a href="https://yargimcp.com/sign-in">Return to Sign In</a>
|
||||
</body>
|
||||
</html>
|
||||
""", status_code=500)
|
||||
|
||||
# OAuth2 Token Endpoint - Now uses Clerk JWT tokens directly
|
||||
@app.post("/auth/mcp-token")
|
||||
async def mcp_token_endpoint(request: Request):
|
||||
"""OAuth2 token endpoint for MCP clients - returns Clerk JWT token info"""
|
||||
try:
|
||||
# Validate Clerk session
|
||||
user_id = await validate_clerk_session_for_oauth(request)
|
||||
|
||||
return {
|
||||
"message": "Use your Clerk JWT token directly with Bearer authentication",
|
||||
"token_type": "Bearer",
|
||||
"scope": "yargi.read",
|
||||
"user_id": user_id,
|
||||
"instructions": "Include 'Authorization: Bearer YOUR_CLERK_JWT_TOKEN' in your requests"
|
||||
}
|
||||
except HTTPException as e:
|
||||
return JSONResponse(
|
||||
status_code=e.status_code,
|
||||
content={"error": "invalid_request", "error_description": e.detail}
|
||||
)
|
||||
|
||||
# Mount MCP app at /mcp/ with trailing slash
|
||||
app.mount("/mcp/", mcp_app)
|
||||
|
||||
# Set the lifespan context after mounting
|
||||
app.router.lifespan_context = mcp_app.lifespan
|
||||
|
||||
# Export for uvicorn
|
||||
__all__ = ["app", "sse_app"]
|
||||
__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,247 @@
|
||||
# bddk_mcp_module/client.py
|
||||
|
||||
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
|
||||
pdf_stream = io.BytesIO(response.content)
|
||||
result = self.markitdown.convert_stream(pdf_stream, file_extension=".pdf")
|
||||
markdown_content = result.text_content
|
||||
else:
|
||||
# Handle HTML documents
|
||||
html_stream = io.BytesIO(response.content)
|
||||
result = 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")
|
||||
@@ -5,14 +5,14 @@ import base64
|
||||
from typing import Optional
|
||||
import logging
|
||||
from markitdown import MarkItDown
|
||||
import tempfile
|
||||
import os
|
||||
import io
|
||||
|
||||
from .models import (
|
||||
BedestenSearchRequest, BedestenSearchResponse,
|
||||
BedestenDocumentRequest, BedestenDocumentResponse,
|
||||
BedestenDocumentMarkdown, BedestenDocumentRequestData
|
||||
)
|
||||
from .enums import get_full_birim_adi
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,10 +50,22 @@ class BedestenApiClient:
|
||||
"""
|
||||
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"]
|
||||
|
||||
response = await self.http_client.post(
|
||||
self.SEARCH_ENDPOINT,
|
||||
json=search_request.model_dump()
|
||||
json=request_dict
|
||||
)
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
@@ -90,8 +102,22 @@ class BedestenApiClient:
|
||||
response_json = response.json()
|
||||
doc_response = BedestenDocumentResponse(**response_json)
|
||||
|
||||
# Decode base64 content
|
||||
content_bytes = base64.b64decode(doc_response.data.content)
|
||||
# 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}")
|
||||
@@ -125,17 +151,14 @@ class BedestenApiClient:
|
||||
if not html_content:
|
||||
return None
|
||||
|
||||
temp_file_path = 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()
|
||||
|
||||
# Write HTML to temp file
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as tmp:
|
||||
tmp.write(html_content)
|
||||
temp_file_path = tmp.name
|
||||
|
||||
# Convert
|
||||
result = md_converter.convert(temp_file_path)
|
||||
result = md_converter.convert(html_stream)
|
||||
markdown_content = result.text_content
|
||||
|
||||
logger.info("Successfully converted HTML to Markdown")
|
||||
@@ -144,27 +167,19 @@ class BedestenApiClient:
|
||||
except Exception as e:
|
||||
logger.error(f"Error converting HTML to Markdown: {e}")
|
||||
return f"Error converting HTML content: {str(e)}"
|
||||
finally:
|
||||
if temp_file_path and os.path.exists(temp_file_path):
|
||||
os.remove(temp_file_path)
|
||||
|
||||
def _convert_pdf_to_markdown(self, pdf_bytes: bytes) -> Optional[str]:
|
||||
"""Convert PDF to Markdown using MarkItDown"""
|
||||
if not pdf_bytes:
|
||||
return None
|
||||
|
||||
temp_file_path = None
|
||||
try:
|
||||
# MarkItDown supports PDF with markitdown[pdf]
|
||||
# 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()
|
||||
|
||||
# Write PDF to temp file
|
||||
with tempfile.NamedTemporaryFile(mode="wb", delete=False, suffix=".pdf") as tmp:
|
||||
tmp.write(pdf_bytes)
|
||||
temp_file_path = tmp.name
|
||||
|
||||
# Convert
|
||||
result = md_converter.convert(temp_file_path)
|
||||
result = md_converter.convert(pdf_stream)
|
||||
markdown_content = result.text_content
|
||||
|
||||
logger.info("Successfully converted PDF to Markdown")
|
||||
@@ -173,9 +188,6 @@ class BedestenApiClient:
|
||||
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."
|
||||
finally:
|
||||
if temp_file_path and os.path.exists(temp_file_path):
|
||||
os.remove(temp_file_path)
|
||||
|
||||
async def close_client_session(self):
|
||||
"""Close HTTP client session"""
|
||||
|
||||
@@ -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
|
||||
@@ -4,94 +4,33 @@ from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any, Literal, Union
|
||||
from datetime import datetime
|
||||
|
||||
# Import YargitayBirimEnum for chamber filtering
|
||||
from yargitay_mcp_module.models import YargitayBirimEnum
|
||||
# Import compressed BirimAdiEnum for chamber filtering
|
||||
from .enums import BirimAdiEnum
|
||||
|
||||
# Danıştay Chamber/Board Options
|
||||
DanistayBirimEnum = Literal[
|
||||
"ALL", # "ALL" for all chambers
|
||||
# Main Councils
|
||||
"Büyük Gen.Kur.", # Grand General Assembly
|
||||
"İdare Dava Daireleri Kurulu", # Administrative Cases Chambers Council
|
||||
"Vergi Dava Daireleri Kurulu", # Tax Cases Chambers Council
|
||||
"İçtihatları Birleştirme Kurulu", # Precedents Unification Council
|
||||
"İdari İşler Kurulu", # Administrative Affairs Council
|
||||
"Başkanlar Kurulu", # Presidents Council
|
||||
# Chambers
|
||||
"1. Daire", "2. Daire", "3. Daire", "4. Daire", "5. Daire",
|
||||
"6. Daire", "7. Daire", "8. Daire", "9. Daire", "10. Daire",
|
||||
"11. Daire", "12. Daire", "13. Daire", "14. Daire", "15. Daire",
|
||||
"16. Daire", "17. Daire",
|
||||
# Military High Administrative Court
|
||||
"Askeri Yüksek İdare Mahkemesi",
|
||||
"Askeri Yüksek İdare Mahkemesi Daireler Kurulu",
|
||||
"Askeri Yüksek İdare Mahkemesi Başsavcılığı",
|
||||
"Askeri Yüksek İdare Mahkemesi 1. Daire",
|
||||
"Askeri Yüksek İdare Mahkemesi 2. Daire",
|
||||
"Askeri Yüksek İdare Mahkemesi 3. Daire"
|
||||
# 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="""Number of results per page.
|
||||
Range: 1-100 results per page
|
||||
Recommended: 10-50 for balanced performance
|
||||
Higher values for comprehensive analysis""")
|
||||
pageNumber: int = Field(..., description="""Page number to retrieve (1-indexed).
|
||||
Start with 1 for first page
|
||||
Calculate total pages from response.data.total / pageSize
|
||||
Navigate: pageNumber=2 gets next set of results""")
|
||||
itemTypeList: List[str] = Field(..., description="""Court type filter - determines which court decisions to search:
|
||||
• ["YARGITAYKARARI"]: Court of Cassation (Yargıtay) - supreme court civil/criminal decisions
|
||||
• ["DANISTAYKARAR"]: Council of State (Danıştay) - administrative court decisions
|
||||
• ["YERELHUKUK"]: Local Civil Courts (Yerel Hukuk Mahkemeleri) - first instance civil decisions
|
||||
• ["ISTINAFHUKUK"]: Civil Courts of Appeals (İstinaf Hukuk Mahkemeleri) - appellate court decisions
|
||||
• ["KYB"]: Extraordinary Appeal (Kanun Yararına Bozma) - extraordinary appeal decisions
|
||||
Note: Use single-item list for specific court type targeting""")
|
||||
phrase: str = Field(..., description="""Search phrase/keyword with advanced search support:
|
||||
• Regular search: "mülkiyet kararı" - searches words separately
|
||||
• Exact phrase: "\"mülkiyet kararı\"" - searches exact phrase (more precise)
|
||||
• Legal concepts: "\"idari işlem\"", "\"sözleşme ihlali\"", "\"tazminat davası\""
|
||||
• Empty string: searches all documents (use with filters)
|
||||
Exact phrases significantly reduce false positives for precise legal research""")
|
||||
birimAdi: Optional[Union[YargitayBirimEnum, DanistayBirimEnum]] = Field(None, description="""
|
||||
Chamber/Department (Daire) filter (optional). Available options depend on itemTypeList:
|
||||
|
||||
For YARGITAYKARARI - Court of Cassation (52 options):
|
||||
- None/null for ALL chambers
|
||||
- 'Civil General Assembly (Hukuk Genel Kurulu)', '1st Civil Chamber (1. Hukuk Dairesi)' through '23rd Civil Chamber (23. Hukuk Dairesi)'
|
||||
- 'Criminal General Assembly (Ceza Genel Kurulu)', '1st Criminal Chamber (1. Ceza Dairesi)' through '23rd Criminal Chamber (23. Ceza Dairesi)'
|
||||
- 'Civil Chambers Presidents Board (Hukuk Daireleri Başkanlar Kurulu)', 'Criminal Chambers Presidents Board (Ceza Daireleri Başkanlar Kurulu)'
|
||||
- 'Grand General Assembly (Büyük Genel Kurulu)'
|
||||
|
||||
For DANISTAYKARAR - Council of State (27 options):
|
||||
- None/null for ALL chambers
|
||||
- 'Grand General Assembly (Büyük Gen.Kur.)', 'Administrative Cases Chambers Council (İdare Dava Daireleri Kurulu)', 'Tax Cases Chambers Council (Vergi Dava Daireleri Kurulu)'
|
||||
- '1st Chamber (1. Daire)' through '17th Chamber (17. Daire)'
|
||||
- 'Precedents Unification Council (İçtihatları Birleştirme Kurulu)', 'Administrative Affairs Council (İdari İşler Kurulu)', 'Presidents Council (Başkanlar Kurulu)'
|
||||
- Military courts: 'Military High Administrative Court (Askeri Yüksek İdare Mahkemesi)' variants
|
||||
""")
|
||||
kararTarihiStart: Optional[str] = Field(None, description="""Decision start date (Karar Tarihi Başlangıç) filter (optional).
|
||||
Format: YYYY-MM-DDTHH:MM:SS.000Z (ISO 8601 with Z timezone)
|
||||
Examples:
|
||||
• "2024-01-01T00:00:00.000Z" - from beginning of 2024
|
||||
• "2023-06-15T00:00:00.000Z" - from June 15, 2023
|
||||
• "2024-03-01T00:00:00.000Z" - from March 1, 2024
|
||||
Use with kararTarihiEnd for date range, or alone for "from date" filtering""")
|
||||
kararTarihiEnd: Optional[str] = Field(None, description="""Decision end date (Karar Tarihi Bitiş) filter (optional).
|
||||
Format: YYYY-MM-DDTHH:MM:SS.000Z (ISO 8601 with Z timezone)
|
||||
Examples:
|
||||
• "2024-12-31T23:59:59.999Z" - until end of 2024
|
||||
• "2023-12-31T23:59:59.999Z" - until end of 2023
|
||||
• "2024-06-30T23:59:59.999Z" - until end of June 2024
|
||||
Use with kararTarihiStart for date range, or alone for "until date" filtering""")
|
||||
sortFields: List[str] = Field(default=["KARAR_TARIHI"], description="""Sorting field (Sıralama Alanı) specification.
|
||||
["KARAR_TARIHI"]: Sort by decision date (Karar Tarihi) [DEFAULT]
|
||||
Most common use case for chronological ordering""")
|
||||
sortDirection: str = Field(default="desc", description="""Sort direction (Sıralama Yönü) for results.
|
||||
"desc": Descending order - newest decisions first [DEFAULT]
|
||||
"asc": Ascending order - oldest decisions first
|
||||
Recommended: "desc" for latest legal developments""")
|
||||
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
|
||||
@@ -125,7 +64,7 @@ class BedestenSearchDataResponse(BaseModel):
|
||||
start: int
|
||||
|
||||
class BedestenSearchResponse(BaseModel):
|
||||
data: BedestenSearchDataResponse
|
||||
data: Optional[BedestenSearchDataResponse]
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
# Document Request/Response Models
|
||||
|
||||
@@ -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())
|
||||
@@ -6,8 +6,7 @@ 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 (
|
||||
@@ -77,12 +76,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)
|
||||
|
||||
@@ -124,31 +127,28 @@ class DanistayApiClient:
|
||||
html_input_for_markdown = processed_html
|
||||
|
||||
markdown_text = None
|
||||
temp_file_path = None
|
||||
try:
|
||||
md_converter = MarkItDown() # 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, 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={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: {id}) from {source_url}")
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 (VE Mantığı), e.g., ['word1', 'word2']")
|
||||
orKelimeler: List[str] = Field(default_factory=list, description="Keywords for OR logic (VEYA Mantığı).")
|
||||
notAndKelimeler: List[str] = Field(default_factory=list, description="Keywords for NOT AND logic (VE DEĞİL Mantığı).")
|
||||
notOrKelimeler: List[str] = Field(default_factory=list, description="Keywords for NOT OR logic (VEYA DEĞİL Mantığı).")
|
||||
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,15 +74,15 @@ 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", 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 (Aranan Kelime) 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 (Belge URL) to the full document, constructed by the client.")
|
||||
document_url: Optional[HttpUrl] = Field(None, description="Document URL")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra='ignore') # Important for alias to work and ignore extra fields
|
||||
|
||||
@@ -93,17 +91,17 @@ class DanistayApiResponseInnerData(BaseModel):
|
||||
data: List[DanistayApiDecisionEntry]
|
||||
recordsTotal: int
|
||||
recordsFiltered: int
|
||||
draw: Optional[int] = Field(None, description="Draw counter (Çizim Sayıcısı) 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
|
||||
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."""
|
||||
id: str
|
||||
markdown_content: Optional[str] = Field(None, description="The decision content (Karar İçeriği) converted to Markdown.")
|
||||
markdown_content: str = Field("", description="The decision content (Karar İçeriği) converted to Markdown.")
|
||||
source_url: HttpUrl
|
||||
|
||||
class CompactDanistaySearchResult(BaseModel):
|
||||
|
||||
+12
-13
@@ -6,8 +6,7 @@ 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 (
|
||||
@@ -64,7 +63,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)
|
||||
@@ -114,22 +117,18 @@ class EmsalApiClient:
|
||||
html_input_for_markdown = content
|
||||
|
||||
markdown_text = None
|
||||
temp_file_path = None
|
||||
try:
|
||||
# Convert HTML string to bytes and create BytesIO stream
|
||||
html_bytes = html_input_for_markdown.encode('utf-8')
|
||||
html_stream = io.BytesIO(html_bytes)
|
||||
|
||||
# Pass BytesIO stream to MarkItDown to avoid temp file creation
|
||||
md_converter = MarkItDown()
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
+27
-27
@@ -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 (Bölge Hukuk Mahkemeleri), '+' separated.")
|
||||
birimHukukMah: Optional[str] = Field("", description="Regional chambers (+ separated)")
|
||||
|
||||
esasYil: Optional[str] = ""
|
||||
esasIlkSiraNo: Optional[str] = ""
|
||||
@@ -36,42 +36,42 @@ class EmsalDetailedSearchRequestData(BaseModel):
|
||||
|
||||
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 (Anahtar Kelime) to search.")
|
||||
keyword: str = Field("", description="Keyword")
|
||||
|
||||
selected_bam_civil_court: Optional[str] = Field(None, description="Selected BAM Civil Court (Seçilen BAM Hukuk Mahkemesi) (maps to 'Bam Hukuk Mahkemeleri' payload key).")
|
||||
selected_civil_court: Optional[str] = Field(None, description="Selected Civil Court (Seçilen Hukuk Mahkemesi) (maps to 'Hukuk Mahkemeleri' payload key).")
|
||||
selected_regional_civil_chambers: Optional[List[str]] = Field(default_factory=list, description="Selected Regional Civil Chambers (Seçilen Bölge Hukuk Daireleri) (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 (Dava Yılı) for 'Esas No'.")
|
||||
case_start_seq_esas: Optional[str] = Field(None, description="Starting sequence (Başlangıç Sırası) for 'Esas No'.")
|
||||
case_end_seq_esas: Optional[str] = Field(None, description="Ending sequence (Bitiş Sırası) 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 (Karar Yılı) for 'Karar No'.")
|
||||
decision_start_seq_karar: Optional[str] = Field(None, description="Starting sequence (Başlangıç Sırası) for 'Karar No'.")
|
||||
decision_end_seq_karar: Optional[str] = Field(None, description="Ending sequence (Bitiş Sırası) 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 (Başlangıç Tarihi) for decision (DD.MM.YYYY).")
|
||||
end_date: Optional[str] = Field(None, description="End date (Bitiş Tarihi) 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 (Sıralama Kriteri) (e.g., 1: Esas No).")
|
||||
sort_direction: str = Field("desc", description="Sorting direction (Sıralama Yönü) ('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 (Daire/Mahkeme) 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 (Aranan Kelime) from the search.")
|
||||
durum: Optional[str] = Field(None, description="Status (Durum) 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 (Belge URL) to the full document, constructed by the client.")
|
||||
document_url: Optional[HttpUrl] = Field(None, description="Document URL")
|
||||
|
||||
model_config = ConfigDict(extra='ignore')
|
||||
|
||||
@@ -80,7 +80,7 @@ class EmsalApiResponseInnerData(BaseModel):
|
||||
data: List[EmsalApiDecisionEntry]
|
||||
recordsTotal: int
|
||||
recordsFiltered: int
|
||||
draw: Optional[int] = Field(None, description="Draw counter (Çizim Sayıcısı) 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."""
|
||||
@@ -90,7 +90,7 @@ class EmsalApiResponse(BaseModel):
|
||||
class EmsalDocumentMarkdown(BaseModel):
|
||||
"""Model for an Emsal decision document, containing only Markdown content."""
|
||||
id: str
|
||||
markdown_content: Optional[str] = Field(None, description="The decision content (Karar İçeriği) converted to Markdown.")
|
||||
markdown_content: str = Field("", description="The decision content (Karar İçeriği) converted to Markdown.")
|
||||
source_url: HttpUrl
|
||||
|
||||
class CompactEmsalSearchResult(BaseModel):
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# fly.toml app configuration file for yargi-mcp-noauth
|
||||
#
|
||||
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
|
||||
#
|
||||
|
||||
app = 'yargi-mcp-free'
|
||||
primary_region = 'fra'
|
||||
|
||||
[env]
|
||||
ENABLE_AUTH = "false"
|
||||
HOST = "0.0.0.0"
|
||||
PORT = "8000"
|
||||
LOG_LEVEL = "info"
|
||||
|
||||
[build]
|
||||
|
||||
[http_service]
|
||||
internal_port = 8000
|
||||
force_https = true
|
||||
auto_stop_machines = 'off'
|
||||
auto_start_machines = true
|
||||
min_machines_running = 1
|
||||
processes = ['app']
|
||||
|
||||
# Enable connection persistence for MCP sessions
|
||||
[http_service.concurrency]
|
||||
type = "connections"
|
||||
hard_limit = 100
|
||||
soft_limit = 80
|
||||
|
||||
[[vm]]
|
||||
memory = '1gb'
|
||||
cpu_kind = 'shared'
|
||||
cpus = 1
|
||||
|
||||
[deploy]
|
||||
strategy = "immediate"
|
||||
|
||||
[processes]
|
||||
app = "python asgi_app.py"
|
||||
|
||||
[checks.http_health] # keep MCP /health live
|
||||
type = "http"
|
||||
interval = "30s"
|
||||
timeout = "10s"
|
||||
path = "/health"
|
||||
@@ -17,11 +17,17 @@ LOG_LEVEL = "info"
|
||||
[http_service]
|
||||
internal_port = 8000
|
||||
force_https = true
|
||||
auto_stop_machines = 'stop'
|
||||
auto_stop_machines = 'off'
|
||||
auto_start_machines = true
|
||||
min_machines_running = 0
|
||||
min_machines_running = 1
|
||||
processes = ['app']
|
||||
|
||||
# Enable connection persistence for MCP sessions
|
||||
[http_service.concurrency]
|
||||
type = "connections"
|
||||
hard_limit = 100
|
||||
soft_limit = 80
|
||||
|
||||
[[vm]]
|
||||
memory = '1gb'
|
||||
cpu_kind = 'shared'
|
||||
|
||||
@@ -1,441 +0,0 @@
|
||||
# kik_mcp_module/client.py
|
||||
import asyncio
|
||||
from playwright.async_api import (
|
||||
async_playwright,
|
||||
Page,
|
||||
BrowserContext,
|
||||
Browser,
|
||||
Error as PlaywrightError,
|
||||
TimeoutError as PlaywrightTimeoutError
|
||||
)
|
||||
from bs4 import BeautifulSoup
|
||||
import logging
|
||||
from typing import Dict, Any, List, Optional
|
||||
import urllib.parse
|
||||
import base64 # Base64 için
|
||||
import re
|
||||
import html as html_parser
|
||||
from markitdown import MarkItDown
|
||||
import os
|
||||
import math
|
||||
import tempfile
|
||||
|
||||
from .models import (
|
||||
KikSearchRequest,
|
||||
KikDecisionEntry,
|
||||
KikSearchResult,
|
||||
KikDocumentMarkdown,
|
||||
KikKararTipi
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class KikApiClient:
|
||||
BASE_URL = "https://ekap.kik.gov.tr"
|
||||
SEARCH_PAGE_PATH = "/EKAP/Vatandas/kurulkararsorgu.aspx"
|
||||
FIELD_LOCATORS = {
|
||||
"karar_tipi_radio_group": "input[name='ctl00$ContentPlaceHolder1$kurulKararTip']",
|
||||
"karar_no": "input[name='ctl00$ContentPlaceHolder1$txtKararNo']",
|
||||
"karar_tarihi_baslangic": "input[name='ctl00$ContentPlaceHolder1$etKararTarihBaslangic$EkapTakvimTextBox_etKararTarihBaslangic']",
|
||||
"karar_tarihi_bitis": "input[name='ctl00$ContentPlaceHolder1$etKararTarihBitis$EkapTakvimTextBox_etKararTarihBitis']",
|
||||
"resmi_gazete_sayisi": "input[name='ctl00$ContentPlaceHolder1$txtResmiGazeteSayisi']",
|
||||
"resmi_gazete_tarihi": "input[name='ctl00$ContentPlaceHolder1$etResmiGazeteTarihi$EkapTakvimTextBox_etResmiGazeteTarihi']",
|
||||
"basvuru_konusu_ihale": "input[name='ctl00$ContentPlaceHolder1$txtBasvuruKonusuIhale']",
|
||||
"basvuru_sahibi": "input[name='ctl00$ContentPlaceHolder1$txtSikayetci']",
|
||||
"ihaleyi_yapan_idare": "input[name='ctl00$ContentPlaceHolder1$txtIhaleyiYapanIdare']",
|
||||
"yil": "select[name='ctl00$ContentPlaceHolder1$ddlYil']",
|
||||
"karar_metni": "input[name='ctl00$ContentPlaceHolder1$txtKararMetni']",
|
||||
"search_button_id": "ctl00_ContentPlaceHolder1_btnAra"
|
||||
}
|
||||
RESULTS_TABLE_ID = "grdKurulKararSorguSonuc"
|
||||
NO_RESULTS_MESSAGE_SELECTOR = "div#ctl00_MessageContent1"
|
||||
VALIDATION_SUMMARY_SELECTOR = "div#ctl00_ValidationSummary1"
|
||||
MODAL_CLOSE_BUTTON_SELECTOR = "div#detayPopUp.in a#btnKapatPencere_0.close"
|
||||
DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000
|
||||
|
||||
def __init__(self, request_timeout: float = 60000):
|
||||
self.playwright_instance: Optional[async_playwright] = None
|
||||
self.browser: Optional[Browser] = None
|
||||
self.context: Optional[BrowserContext] = None
|
||||
self.page: Optional[Page] = None
|
||||
self.request_timeout = request_timeout
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def _ensure_playwright_ready(self, force_new_page: bool = False):
|
||||
async with self._lock:
|
||||
browser_recreated = False
|
||||
context_recreated = False
|
||||
if not self.playwright_instance:
|
||||
self.playwright_instance = await async_playwright().start()
|
||||
if not self.browser or not self.browser.is_connected():
|
||||
if self.browser: await self.browser.close()
|
||||
self.browser = await self.playwright_instance.chromium.launch(headless=True)
|
||||
browser_recreated = True
|
||||
if not self.context or browser_recreated:
|
||||
if self.context: await self.context.close()
|
||||
if not self.browser: raise PlaywrightError("Browser not initialized.")
|
||||
self.context = await self.browser.new_context(
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36",
|
||||
java_script_enabled=True,
|
||||
)
|
||||
context_recreated = True
|
||||
if not self.page or self.page.is_closed() or force_new_page or context_recreated or browser_recreated:
|
||||
if self.page and not self.page.is_closed(): await self.page.close()
|
||||
if not self.context: raise PlaywrightError("Context is None.")
|
||||
self.page = await self.context.new_page()
|
||||
if not self.page: raise PlaywrightError("Failed to create new page.")
|
||||
self.page.set_default_navigation_timeout(self.request_timeout)
|
||||
self.page.set_default_timeout(self.request_timeout)
|
||||
if not self.page or self.page.is_closed():
|
||||
raise PlaywrightError("Playwright page initialization failed.")
|
||||
logger.debug("_ensure_playwright_ready completed.")
|
||||
|
||||
async def close_client_session(self):
|
||||
async with self._lock:
|
||||
# ... (öncekiyle aynı)
|
||||
if self.page and not self.page.is_closed(): await self.page.close(); self.page = None
|
||||
if self.context: await self.context.close(); self.context = None
|
||||
if self.browser: await self.browser.close(); self.browser = None
|
||||
if self.playwright_instance: await self.playwright_instance.stop(); self.playwright_instance = None
|
||||
logger.info("KikApiClient (Playwright): Resources closed.")
|
||||
|
||||
def _parse_decision_entries_from_soup(self, soup: BeautifulSoup, search_karar_tipi: KikKararTipi) -> List[KikDecisionEntry]:
|
||||
entries: List[KikDecisionEntry] = []
|
||||
table = soup.find("table", {"id": self.RESULTS_TABLE_ID})
|
||||
if not table: return entries
|
||||
rows = table.find_all("tr")
|
||||
for row_idx, row in enumerate(rows):
|
||||
if row_idx < 2: continue
|
||||
cells = row.find_all("td")
|
||||
if len(cells) == 6:
|
||||
try:
|
||||
preview_button_tag = cells[0].find("a", id=re.compile(r"btnOnizle$"))
|
||||
event_target = ""
|
||||
if preview_button_tag and preview_button_tag.has_attr('href'):
|
||||
match = re.search(r"__doPostBack\('([^']*)','([^']*)'\)", preview_button_tag['href'])
|
||||
if match: event_target = match.group(1)
|
||||
karar_no_span = cells[1].find("span", id=re.compile(r"lblKno$"))
|
||||
karar_tarihi_span = cells[2].find("span", id=re.compile(r"lblKtar$"))
|
||||
idare_span = cells[3].find("span", id=re.compile(r"lblIdare$"))
|
||||
basvuru_sahibi_span = cells[4].find("span", id=re.compile(r"lblSikayetci$"))
|
||||
ihale_span = cells[5].find("span", id=re.compile(r"lblIhale$"))
|
||||
if not (event_target and karar_no_span and karar_tarihi_span): continue
|
||||
|
||||
# Karar tipini arama parametresinden alıyoruz, çünkü HTML'de direkt olarak bulunmuyor.
|
||||
entry = KikDecisionEntry(
|
||||
preview_event_target=event_target,
|
||||
kararNo=karar_no_span.get_text(strip=True),
|
||||
karar_tipi=search_karar_tipi, # Arama yapılan karar tipini ekle
|
||||
kararTarihi=karar_tarihi_span.get_text(strip=True),
|
||||
idare=idare_span.get_text(strip=True) if idare_span else None,
|
||||
basvuruSahibi=basvuru_sahibi_span.get_text(strip=True) if basvuru_sahibi_span else None,
|
||||
ihaleKonusu=ihale_span.get_text(strip=True) if ihale_span else None,
|
||||
)
|
||||
entries.append(entry)
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing a KIK decision entry row: {e}", exc_info=True)
|
||||
return entries
|
||||
|
||||
def _parse_total_records_from_soup(self, soup: BeautifulSoup) -> int:
|
||||
# ... (öncekiyle aynı) ...
|
||||
try:
|
||||
pager_div = soup.find("div", class_="gridToplamSayi")
|
||||
if pager_div:
|
||||
match = re.search(r"Toplam Kayıt Sayısı:(\d+)", pager_div.get_text(strip=True))
|
||||
if match: return int(match.group(1))
|
||||
except: pass
|
||||
return 0
|
||||
|
||||
def _parse_current_page_from_soup(self, soup: BeautifulSoup) -> int:
|
||||
# ... (öncekiyle aynı) ...
|
||||
try:
|
||||
pager_div = soup.find("div", class_="sayfalama")
|
||||
if pager_div:
|
||||
active_page_span = pager_div.find("span", class_="active")
|
||||
if active_page_span: return int(active_page_span.get_text(strip=True))
|
||||
except: pass
|
||||
return 1
|
||||
|
||||
async def search_decisions(self, search_params: KikSearchRequest) -> KikSearchResult:
|
||||
await self._ensure_playwright_ready()
|
||||
page = self.page
|
||||
search_url = f"{self.BASE_URL}{self.SEARCH_PAGE_PATH}"
|
||||
try:
|
||||
if page.url != search_url:
|
||||
await page.goto(search_url, wait_until="networkidle", timeout=self.request_timeout)
|
||||
search_button_selector = f"a[id='{self.FIELD_LOCATORS['search_button_id']}']"
|
||||
await page.wait_for_selector(search_button_selector, state="visible", timeout=self.request_timeout)
|
||||
|
||||
current_karar_tipi_value = search_params.karar_tipi.value
|
||||
radio_locator_selector = f"{self.FIELD_LOCATORS['karar_tipi_radio_group']}[value='{current_karar_tipi_value}']"
|
||||
if not await page.locator(radio_locator_selector).is_checked():
|
||||
js_target_radio = f"ctl00$ContentPlaceHolder1${current_karar_tipi_value}"
|
||||
async with page.expect_navigation(wait_until="networkidle", timeout=self.request_timeout):
|
||||
await page.evaluate(f"javascript:__doPostBack('{js_target_radio}','')")
|
||||
await page.wait_for_timeout(1000)
|
||||
|
||||
async def fill_if_value(selector_key: str, value: Optional[str]):
|
||||
if value is not None: await page.fill(self.FIELD_LOCATORS[selector_key], value)
|
||||
|
||||
# Karar No'yu KİK sitesine göndermeden önce '_' -> '/' dönüşümü yap
|
||||
karar_no_for_kik_form = None
|
||||
if search_params.karar_no: # search_params.karar_no Claude'dan '_' ile gelmiş olabilir
|
||||
karar_no_for_kik_form = search_params.karar_no.replace('_', '/')
|
||||
logger.info(f"Using karar_no '{karar_no_for_kik_form}' (transformed from '{search_params.karar_no}') for KIK form.")
|
||||
|
||||
await fill_if_value('karar_metni', search_params.karar_metni)
|
||||
await fill_if_value('karar_no', karar_no_for_kik_form) # Dönüştürülmüş halini kullan
|
||||
# ... (diğer fill_if_value çağrıları aynı) ...
|
||||
await fill_if_value('karar_tarihi_baslangic', search_params.karar_tarihi_baslangic)
|
||||
await fill_if_value('karar_tarihi_bitis', search_params.karar_tarihi_bitis)
|
||||
await fill_if_value('resmi_gazete_sayisi', search_params.resmi_gazete_sayisi)
|
||||
await fill_if_value('resmi_gazete_tarihi', search_params.resmi_gazete_tarihi)
|
||||
await fill_if_value('basvuru_konusu_ihale', search_params.basvuru_konusu_ihale)
|
||||
await fill_if_value('basvuru_sahibi', search_params.basvuru_sahibi)
|
||||
await fill_if_value('ihaleyi_yapan_idare', search_params.ihaleyi_yapan_idare)
|
||||
|
||||
if search_params.yil:
|
||||
await page.select_option(self.FIELD_LOCATORS['yil'], value=search_params.yil)
|
||||
|
||||
action_is_search_button_click = (search_params.page == 1)
|
||||
event_target_for_submit: str
|
||||
if action_is_search_button_click:
|
||||
event_target_for_submit = self.FIELD_LOCATORS['search_button_id']
|
||||
else: # Pagination
|
||||
page_link_ctl_number = search_params.page + 2
|
||||
event_target_for_submit = f"ctl00$ContentPlaceHolder1$grdKurulKararSorguSonuc$ctl14$ctl{page_link_ctl_number:02d}"
|
||||
|
||||
try:
|
||||
async with page.expect_navigation(wait_until="networkidle", timeout=self.request_timeout):
|
||||
if action_is_search_button_click:
|
||||
await page.locator(search_button_selector).click()
|
||||
else:
|
||||
await page.evaluate(f"javascript:__doPostBack('{event_target_for_submit}','')")
|
||||
except PlaywrightTimeoutError:
|
||||
await page.wait_for_timeout(2000)
|
||||
|
||||
results_table_dom_selector = f"table#{self.RESULTS_TABLE_ID}"
|
||||
try:
|
||||
await page.wait_for_selector(results_table_dom_selector, timeout=30000, state="attached")
|
||||
await page.wait_for_timeout(2000)
|
||||
except PlaywrightTimeoutError:
|
||||
logger.warning(f"Timeout waiting for results table '{results_table_dom_selector}'.")
|
||||
|
||||
html_content = await page.content()
|
||||
soup = BeautifulSoup(html_content, "html.parser")
|
||||
# ... (hata ve sonuç yok mesajı kontrolü aynı) ...
|
||||
validation_summary_tag = soup.find("div", id=self.VALIDATION_SUMMARY_SELECTOR.split('[')[0].split(':')[0])
|
||||
if validation_summary_tag and validation_summary_tag.get_text(strip=True) and \
|
||||
("display: none" not in validation_summary_tag.get("style", "").lower() if validation_summary_tag.has_attr("style") else True) and \
|
||||
validation_summary_tag.get_text(strip=True) != "":
|
||||
return KikSearchResult(decisions=[], total_records=0, current_page=search_params.page)
|
||||
message_content_div = soup.find("div", id=self.NO_RESULTS_MESSAGE_SELECTOR.split(':')[0])
|
||||
if message_content_div and "kayıt bulunamamıştır" in message_content_div.get_text(strip=True).lower():
|
||||
return KikSearchResult(decisions=[], total_records=0, current_page=1)
|
||||
|
||||
# _parse_decision_entries_from_soup'a arama yapılan karar_tipi'ni gönder
|
||||
decisions = self._parse_decision_entries_from_soup(soup, search_params.karar_tipi)
|
||||
total_records = self._parse_total_records_from_soup(soup)
|
||||
current_page_from_html = self._parse_current_page_from_soup(soup)
|
||||
return KikSearchResult(decisions=decisions, total_records=total_records, current_page=current_page_from_html)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during KIK decision search: {e}", exc_info=True)
|
||||
return KikSearchResult(decisions=[], current_page=search_params.page)
|
||||
|
||||
def _clean_html_for_markdown(self, html_content: str) -> str:
|
||||
# ... (öncekiyle aynı) ...
|
||||
if not html_content: return ""
|
||||
return html_parser.unescape(html_content)
|
||||
|
||||
def _convert_html_to_markdown_internal(self, html_fragment: str) -> Optional[str]:
|
||||
# ... (öncekiyle aynı) ...
|
||||
if not html_fragment: return None
|
||||
cleaned_html = self._clean_html_for_markdown(html_fragment)
|
||||
markdown_output = None; temp_file_path = None
|
||||
try:
|
||||
md_converter = MarkItDown(enable_plugins=True, remove_alt_whitespace=True, keep_underline=True)
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as tmp_html_file:
|
||||
tmp_html_file.write(cleaned_html); temp_file_path = tmp_html_file.name
|
||||
markdown_output = md_converter.convert(temp_file_path).text_content
|
||||
if markdown_output: markdown_output = re.sub(r'\n{3,}', '\n\n', markdown_output).strip()
|
||||
except Exception as e: logger.error(f"MarkItDown conversion error: {e}", exc_info=True)
|
||||
finally:
|
||||
if temp_file_path and os.path.exists(temp_file_path): os.remove(temp_file_path)
|
||||
return markdown_output
|
||||
|
||||
|
||||
async def get_decision_document_as_markdown(
|
||||
self,
|
||||
karar_id_b64: str,
|
||||
page_number: int = 1
|
||||
) -> KikDocumentMarkdown:
|
||||
await self._ensure_playwright_ready()
|
||||
# Bu metodun kendi içinde yeni bir 'page' nesnesi ('doc_page_for_content') kullanacağını unutmayın,
|
||||
# ana 'self.page' arama sonuçları sayfasında kalır.
|
||||
current_main_page = self.page # Ana arama sonuçları sayfasını referans alalım
|
||||
|
||||
try:
|
||||
decoded_key = base64.b64decode(karar_id_b64.encode('utf-8')).decode('utf-8')
|
||||
karar_tipi_value, karar_no_for_search = decoded_key.split('|', 1)
|
||||
original_karar_tipi = KikKararTipi(karar_tipi_value)
|
||||
logger.info(f"KIK Get Detail: Decoded karar_id '{karar_id_b64}' to Karar Tipi: {original_karar_tipi.value}, Karar No: {karar_no_for_search}. Requested Markdown Page: {page_number}")
|
||||
except Exception as e_decode:
|
||||
logger.error(f"Invalid karar_id format. Could not decode Base64 or split: {karar_id_b64}. Error: {e_decode}")
|
||||
return KikDocumentMarkdown(retrieved_with_karar_id=karar_id_b64, error_message="Invalid karar_id format.", current_page=page_number)
|
||||
|
||||
default_error_response_data = {
|
||||
"retrieved_with_karar_id": karar_id_b64,
|
||||
"retrieved_karar_no": karar_no_for_search,
|
||||
"retrieved_karar_tipi": original_karar_tipi,
|
||||
"error_message": "An unspecified error occurred.",
|
||||
"current_page": page_number, "total_pages": 1, "is_paginated": False
|
||||
}
|
||||
|
||||
# Ana arama sayfasında olduğumuzdan emin olalım
|
||||
if self.SEARCH_PAGE_PATH not in current_main_page.url:
|
||||
logger.info(f"Not on search page ({current_main_page.url}). Navigating to {self.SEARCH_PAGE_PATH} before targeted search for document.")
|
||||
await current_main_page.goto(f"{self.BASE_URL}{self.SEARCH_PAGE_PATH}", wait_until="networkidle", timeout=self.request_timeout)
|
||||
await current_main_page.wait_for_selector(f"a[id='{self.FIELD_LOCATORS['search_button_id']}']", state="visible", timeout=self.request_timeout)
|
||||
|
||||
targeted_search_params = KikSearchRequest(
|
||||
karar_no=karar_no_for_search,
|
||||
karar_tipi=original_karar_tipi,
|
||||
page=1
|
||||
)
|
||||
logger.info(f"Performing targeted search for Karar No: {karar_no_for_search}")
|
||||
# search_decisions kendi içinde _ensure_playwright_ready çağırır ve self.page'i kullanır.
|
||||
# Bu, current_main_page ile aynı olmalı.
|
||||
search_results = await self.search_decisions(targeted_search_params)
|
||||
|
||||
if not search_results.decisions:
|
||||
default_error_response_data["error_message"] = f"Decision with Karar No '{karar_no_for_search}' (Tipi: {original_karar_tipi.value}) not found by internal search."
|
||||
return KikDocumentMarkdown(**default_error_response_data)
|
||||
|
||||
decision_to_fetch = None
|
||||
for dec_entry in search_results.decisions:
|
||||
if dec_entry.karar_no_str == karar_no_for_search and dec_entry.karar_tipi == original_karar_tipi:
|
||||
decision_to_fetch = dec_entry
|
||||
break
|
||||
|
||||
if not decision_to_fetch:
|
||||
default_error_response_data["error_message"] = f"Karar No '{karar_no_for_search}' (Tipi: {original_karar_tipi.value}) not present with an exact match in first page of targeted search results."
|
||||
return KikDocumentMarkdown(**default_error_response_data)
|
||||
|
||||
decision_preview_event_target = decision_to_fetch.preview_event_target
|
||||
logger.info(f"Found target decision. Using preview_event_target: {decision_preview_event_target} for Karar No: {decision_to_fetch.karar_no_str}")
|
||||
|
||||
iframe_document_url_str = None
|
||||
karar_id_param_from_url_on_doc_page = None
|
||||
document_html_content = ""
|
||||
|
||||
try:
|
||||
logger.info(f"Evaluating __doPostBack on main page to show modal for: {decision_preview_event_target}")
|
||||
# Bu evaluate, self.page (yani current_main_page) üzerinde çalışır
|
||||
await current_main_page.evaluate(f"javascript:__doPostBack('{decision_preview_event_target}','')")
|
||||
await current_main_page.wait_for_timeout(1000)
|
||||
logger.info(f"Executed __doPostBack for {decision_preview_event_target} on main page.")
|
||||
|
||||
iframe_selector = "iframe#iframe_detayPopUp"
|
||||
modal_visible_selector = "div#detayPopUp.in"
|
||||
|
||||
try:
|
||||
logger.info(f"Waiting for modal '{modal_visible_selector}' to be visible and iframe '{iframe_selector}' src to be populated on main page...")
|
||||
await current_main_page.wait_for_function(
|
||||
f"""
|
||||
() => {{
|
||||
const modal = document.querySelector('{modal_visible_selector}');
|
||||
const iframe = document.querySelector('{iframe_selector}');
|
||||
const modalIsTrulyVisible = modal && (window.getComputedStyle(modal).display !== 'none');
|
||||
return modalIsTrulyVisible &&
|
||||
iframe && iframe.getAttribute('src') &&
|
||||
iframe.getAttribute('src').includes('KurulKararGoster.aspx');
|
||||
}}
|
||||
""",
|
||||
timeout=self.request_timeout / 2
|
||||
)
|
||||
iframe_src_value = await current_main_page.locator(iframe_selector).get_attribute("src")
|
||||
logger.info(f"Iframe src populated: {iframe_src_value}")
|
||||
|
||||
except PlaywrightTimeoutError:
|
||||
logger.warning(f"Timeout waiting for KIK iframe src for {decision_preview_event_target}. Trying to parse from static content after presumed update.")
|
||||
html_after_postback = await current_main_page.content()
|
||||
# ... (fallback parsing öncekiyle aynı, default_error_response_data set edilir ve return edilir) ...
|
||||
soup_after_postback = BeautifulSoup(html_after_postback, "html.parser")
|
||||
detay_popup_div = soup_after_postback.find("div", {"id": "detayPopUp", "class": re.compile(r"\bin\b")})
|
||||
if not detay_popup_div: detay_popup_div = soup_after_postback.find("div", {"id": "detayPopUp", "style": re.compile(r"display:\s*block", re.I)})
|
||||
iframe_tag = detay_popup_div.find("iframe", {"id": "iframe_detayPopUp"}) if detay_popup_div else None
|
||||
if iframe_tag and iframe_tag.has_attr("src") and iframe_tag["src"]: iframe_src_value = iframe_tag["src"]
|
||||
else:
|
||||
default_error_response_data["error_message"]="Timeout or failure finding decision content iframe URL after postback."
|
||||
return KikDocumentMarkdown(**default_error_response_data)
|
||||
|
||||
if not iframe_src_value or not iframe_src_value.strip():
|
||||
default_error_response_data["error_message"]="Extracted iframe URL for decision content is empty."
|
||||
return KikDocumentMarkdown(**default_error_response_data)
|
||||
|
||||
# iframe_src_value göreceli bir URL ise, ana sayfanın URL'si ile birleştir
|
||||
iframe_document_url_str = urllib.parse.urljoin(current_main_page.url, iframe_src_value)
|
||||
logger.info(f"Constructed absolute iframe_document_url_str for goto: {iframe_document_url_str}") # Log this absolute URL
|
||||
default_error_response_data["source_url"] = iframe_document_url_str
|
||||
|
||||
parsed_url = urllib.parse.urlparse(iframe_document_url_str)
|
||||
query_params = urllib.parse.parse_qs(parsed_url.query)
|
||||
karar_id_param_from_url_on_doc_page = query_params.get("KararId", [None])[0]
|
||||
default_error_response_data["karar_id_param_from_url"] = karar_id_param_from_url_on_doc_page
|
||||
if not karar_id_param_from_url_on_doc_page:
|
||||
default_error_response_data["error_message"]="KararId (KIK internal ID) not found in extracted iframe URL."
|
||||
return KikDocumentMarkdown(**default_error_response_data)
|
||||
|
||||
logger.info(f"Fetching KIK decision content from iframe URL using a new Playwright page: {iframe_document_url_str}")
|
||||
|
||||
doc_page_for_content = await self.context.new_page()
|
||||
try:
|
||||
# `goto` metoduna MUTLAK URL verilmeli. Loglanan URL'nin mutlak olduğundan emin olalım.
|
||||
await doc_page_for_content.goto(iframe_document_url_str, wait_until="domcontentloaded", timeout=self.request_timeout)
|
||||
document_html_content = await doc_page_for_content.content()
|
||||
except Exception as e_doc_page:
|
||||
logger.error(f"Error navigating or getting content from doc_page ({iframe_document_url_str}): {e_doc_page}")
|
||||
if doc_page_for_content and not doc_page_for_content.is_closed(): await doc_page_for_content.close()
|
||||
default_error_response_data["error_message"]=f"Failed to load decision detail page: {e_doc_page}"
|
||||
return KikDocumentMarkdown(**default_error_response_data)
|
||||
finally:
|
||||
if doc_page_for_content and not doc_page_for_content.is_closed():
|
||||
await doc_page_for_content.close()
|
||||
|
||||
soup_decision_detail = BeautifulSoup(document_html_content, "html.parser")
|
||||
karar_content_span = soup_decision_detail.find("span", {"id": "ctl00_ContentPlaceHolder1_lblKarar"})
|
||||
actual_decision_html = karar_content_span.decode_contents() if karar_content_span else document_html_content
|
||||
full_markdown_content = self._convert_html_to_markdown_internal(actual_decision_html)
|
||||
|
||||
if not full_markdown_content:
|
||||
default_error_response_data["error_message"]="Markdown conversion failed or returned empty content."
|
||||
try:
|
||||
if await current_main_page.locator(self.MODAL_CLOSE_BUTTON_SELECTOR).is_visible(timeout=1000):
|
||||
await current_main_page.locator(self.MODAL_CLOSE_BUTTON_SELECTOR).click()
|
||||
except: pass
|
||||
return KikDocumentMarkdown(**default_error_response_data)
|
||||
|
||||
content_length = len(full_markdown_content); total_pages = math.ceil(content_length / self.DOCUMENT_MARKDOWN_CHUNK_SIZE) or 1
|
||||
current_page_clamped = max(1, min(page_number, total_pages))
|
||||
start_index = (current_page_clamped - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE
|
||||
markdown_chunk = full_markdown_content[start_index : start_index + self.DOCUMENT_MARKDOWN_CHUNK_SIZE]
|
||||
|
||||
try:
|
||||
if await current_main_page.locator(self.MODAL_CLOSE_BUTTON_SELECTOR).is_visible(timeout=2000):
|
||||
await current_main_page.locator(self.MODAL_CLOSE_BUTTON_SELECTOR).click()
|
||||
await current_main_page.wait_for_selector(f"div#detayPopUp:not(.in)", timeout=5000)
|
||||
except: pass
|
||||
|
||||
return KikDocumentMarkdown(
|
||||
retrieved_with_karar_id=karar_id_b64,
|
||||
retrieved_karar_no=karar_no_for_search,
|
||||
retrieved_karar_tipi=original_karar_tipi,
|
||||
kararIdParam=karar_id_param_from_url_on_doc_page,
|
||||
markdown_chunk=markdown_chunk, source_url=iframe_document_url_str,
|
||||
current_page=current_page_clamped, total_pages=total_pages,
|
||||
is_paginated=(total_pages > 1), full_content_char_count=content_length
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_decision_document_as_markdown for Karar ID {karar_id_b64}: {e}", exc_info=True)
|
||||
default_error_response_data["error_message"] = f"General error: {str(e)}"
|
||||
return KikDocumentMarkdown(**default_error_response_data)
|
||||
@@ -0,0 +1,475 @@
|
||||
# kik_mcp_module/client_v2.py
|
||||
|
||||
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
|
||||
])
|
||||
|
||||
@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 _generate_security_headers(self) -> dict:
|
||||
"""
|
||||
Generate the custom security headers required by KIK v2 API.
|
||||
These headers appear to be for request validation/encryption.
|
||||
"""
|
||||
# Generate a random GUID for each session
|
||||
request_guid = str(uuid.uuid4())
|
||||
|
||||
# These are example values - in a real implementation, these might need
|
||||
# to be calculated based on the request content or session
|
||||
return {
|
||||
"X-Custom-Request-Guid": request_guid,
|
||||
"X-Custom-Request-R8id": "hwnOjsN8qdgtDw70x3sKkxab0rj2bQ8Uph4+C+oU+9AMmQqRN3eMOEEeet748DOf",
|
||||
"X-Custom-Request-Siv": "p2IQRTitF8z7I39nBjdAqA==",
|
||||
"X-Custom-Request-Ts": "1vB3Wwrt8YQ5U6t3XAzZ+Q=="
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
result = 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.")
|
||||
@@ -1,75 +0,0 @@
|
||||
# kik_mcp_module/models.py
|
||||
from pydantic import BaseModel, Field, HttpUrl, computed_field, ConfigDict
|
||||
from typing import List, Optional
|
||||
from enum import Enum
|
||||
import base64 # Base64 encoding/decoding için
|
||||
|
||||
class KikKararTipi(str, Enum):
|
||||
"""Enum for KIK (Public Procurement Authority) Decision Types."""
|
||||
UYUSMAZLIK = "rbUyusmazlik"
|
||||
DUZENLEYICI = "rbDuzenleyici"
|
||||
MAHKEME = "rbMahkeme"
|
||||
|
||||
class KikSearchRequest(BaseModel):
|
||||
"""Model for KIK Decision search criteria."""
|
||||
karar_tipi: KikKararTipi = Field(KikKararTipi.UYUSMAZLIK, description="Type of KIK Decision.")
|
||||
karar_no: Optional[str] = Field(None, description="Decision Number (e.g., '2024/UH.II-1766').")
|
||||
karar_tarihi_baslangic: Optional[str] = Field(None, description="Decision Date Start (DD.MM.YYYY).", pattern=r"^\d{2}\.\d{2}\.\d{4}$")
|
||||
karar_tarihi_bitis: Optional[str] = Field(None, description="Decision Date End (DD.MM.YYYY).", pattern=r"^\d{2}\.\d{2}\.\d{4}$")
|
||||
resmi_gazete_sayisi: Optional[str] = Field(None, description="Official Gazette Number.")
|
||||
resmi_gazete_tarihi: Optional[str] = Field(None, description="Official Gazette Date (DD.MM.YYYY).", pattern=r"^\d{2}\.\d{2}\.\d{4}$")
|
||||
basvuru_konusu_ihale: Optional[str] = Field(None, description="Tender subject of the application.")
|
||||
basvuru_sahibi: Optional[str] = Field(None, description="Applicant.")
|
||||
ihaleyi_yapan_idare: Optional[str] = Field(None, description="Procuring Entity.")
|
||||
yil: Optional[str] = Field(None, description="Year of the decision.")
|
||||
karar_metni: Optional[str] = Field(None, description="Keyword/phrase in decision text.")
|
||||
page: int = Field(1, ge=1, description="Results page number.")
|
||||
|
||||
class KikDecisionEntry(BaseModel):
|
||||
"""Represents a single decision entry from KIK search results."""
|
||||
preview_event_target: str = Field(..., description="Internal event target for fetching details.")
|
||||
karar_no_str: str = Field(..., alias="kararNo", description="Raw decision number as extracted from KIK (e.g., '2024/UH.II-1766').")
|
||||
karar_tipi: KikKararTipi = Field(..., description="The type of decision this entry belongs to.")
|
||||
|
||||
karar_tarihi_str: str = Field(..., alias="kararTarihi", description="Decision date.")
|
||||
idare_str: Optional[str] = Field(None, alias="idare", description="Procuring entity.")
|
||||
basvuru_sahibi_str: Optional[str] = Field(None, alias="basvuruSahibi", description="Applicant.")
|
||||
ihale_konusu_str: Optional[str] = Field(None, alias="ihaleKonusu", description="Tender subject.")
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def karar_id(self) -> str:
|
||||
"""
|
||||
A Base64 encoded unique ID for the decision, combining decision type and number.
|
||||
Format before encoding: "{karar_tipi.value}|{karar_no_str}"
|
||||
"""
|
||||
combined_key = f"{self.karar_tipi.value}|{self.karar_no_str}"
|
||||
return base64.b64encode(combined_key.encode('utf-8')).decode('utf-8')
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
class KikSearchResult(BaseModel):
|
||||
"""Model for KIK search results."""
|
||||
decisions: List[KikDecisionEntry]
|
||||
total_records: int = 0
|
||||
current_page: int = 1
|
||||
|
||||
class KikDocumentMarkdown(BaseModel):
|
||||
"""
|
||||
KIK decision document, with Markdown content potentially paginated.
|
||||
"""
|
||||
retrieved_with_karar_id: Optional[str] = Field(None, description="The Base64 encoded karar_id that was used to request this document.")
|
||||
# Decode edilmiş karar no ve tipini de yanıt olarak ekleyelim, Claude için faydalı olabilir.
|
||||
retrieved_karar_no: Optional[str] = Field(None, description="The raw KIK Decision Number (e.g., '2024/UH.II-1766') this document pertains to.")
|
||||
retrieved_karar_tipi: Optional[KikKararTipi] = Field(None, description="The KIK Decision Type this document pertains to.")
|
||||
|
||||
karar_id_param_from_url: Optional[str] = Field(None, alias="kararIdParam", description="The KIK system's internal KararId parameter from the document's display URL (KurulKararGoster.aspx).")
|
||||
markdown_chunk: Optional[str] = Field(None, description="The requested chunk of the decision content converted to Markdown.")
|
||||
source_url: Optional[str] = Field(None, description="The source URL of the original document (KurulKararGoster.aspx).")
|
||||
error_message: Optional[str] = Field(None, description="Error message if document retrieval or processing failed.")
|
||||
current_page: int = Field(1, description="The current page number of the markdown chunk being returned.")
|
||||
total_pages: int = Field(1, description="The total number of pages the full markdown content is divided into.")
|
||||
is_paginated: bool = Field(False, description="True if the full markdown content is split into multiple pages.")
|
||||
full_content_char_count: Optional[int] = Field(None, description="Total character count of the full markdown content before chunking.")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
@@ -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,372 @@
|
||||
# kvkk_mcp_module/client.py
|
||||
|
||||
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 = 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
|
||||
}
|
||||
+109
-64
@@ -135,19 +135,17 @@ async def authorize_endpoint(
|
||||
@router.get("/auth/callback")
|
||||
async def oauth_callback(
|
||||
request: Request,
|
||||
state: Optional[str] = Query(None)
|
||||
state: Optional[str] = Query(None),
|
||||
clerk_token: Optional[str] = Query(None)
|
||||
):
|
||||
"""Handle OAuth callback from Clerk - simplified for custom domains"""
|
||||
"""Handle OAuth callback from Clerk - supports both JWT token and cookie auth"""
|
||||
|
||||
logger.info(f"OAuth callback received - state: {state}")
|
||||
logger.info(f"Query params: {dict(request.query_params)}")
|
||||
logger.info(f"Cookies: {dict(request.cookies)}")
|
||||
logger.info(f"Clerk JWT token provided: {bool(clerk_token)}")
|
||||
|
||||
# For Clerk custom domains, we'll assume authentication succeeded
|
||||
# if Clerk redirected the user to our callback URL
|
||||
|
||||
# For custom domains, we'll skip complex session verification
|
||||
# and rely on the fact that Clerk only redirects here after successful auth
|
||||
# Support both JWT token (for cross-domain) and cookie auth (for subdomain)
|
||||
|
||||
try:
|
||||
if not state:
|
||||
@@ -189,17 +187,79 @@ async def oauth_callback(
|
||||
content={"error": "invalid_request", "error_description": "OAuth session expired or not found"}
|
||||
)
|
||||
|
||||
# Check if we have a JWT token (for cross-domain auth)
|
||||
user_authenticated = False
|
||||
auth_method = "none"
|
||||
|
||||
if clerk_token:
|
||||
logger.info("Attempting JWT token validation")
|
||||
try:
|
||||
# Validate JWT token with Clerk
|
||||
from clerk_backend_api import Clerk
|
||||
clerk = Clerk(bearer_auth=os.getenv("CLERK_SECRET_KEY"))
|
||||
|
||||
# Extract session_id from JWT token and verify with Clerk
|
||||
import jwt
|
||||
decoded_token = jwt.decode(clerk_token, options={"verify_signature": False})
|
||||
session_id = decoded_token.get("sid") or decoded_token.get("session_id")
|
||||
|
||||
if session_id:
|
||||
# Verify with Clerk using session_id
|
||||
session = clerk.sessions.verify(session_id=session_id, token=clerk_token)
|
||||
user_id = session.user_id if session else None
|
||||
else:
|
||||
user_id = None
|
||||
|
||||
if user_id:
|
||||
logger.info(f"JWT token validation successful - user_id: {user_id}")
|
||||
user_authenticated = True
|
||||
auth_method = "jwt_token"
|
||||
# Store user info in session for token exchange
|
||||
oauth_session["user_id"] = user_id
|
||||
oauth_session["auth_method"] = "jwt_token"
|
||||
else:
|
||||
logger.error("JWT token validation failed - no user_id in claims")
|
||||
except Exception as e:
|
||||
logger.error(f"JWT token validation failed: {str(e)}")
|
||||
# Fall through to cookie validation
|
||||
|
||||
# If no JWT token or validation failed, check cookies
|
||||
if not user_authenticated:
|
||||
logger.info("Checking for Clerk session cookies")
|
||||
# Check for Clerk session cookies (for subdomain auth)
|
||||
clerk_session_cookie = request.cookies.get("__session")
|
||||
if clerk_session_cookie:
|
||||
logger.info("Found Clerk session cookie, assuming authenticated")
|
||||
user_authenticated = True
|
||||
auth_method = "cookie"
|
||||
oauth_session["auth_method"] = "cookie"
|
||||
else:
|
||||
logger.info("No Clerk session cookie found")
|
||||
|
||||
# For custom domains, we'll also trust that Clerk redirected here
|
||||
if not user_authenticated:
|
||||
logger.info("Trusting Clerk redirect for custom domain flow")
|
||||
user_authenticated = True
|
||||
auth_method = "trusted_redirect"
|
||||
oauth_session["auth_method"] = "trusted_redirect"
|
||||
|
||||
logger.info(f"User authenticated: {user_authenticated}, method: {auth_method}")
|
||||
|
||||
# Generate simple authorization code for custom domain flow
|
||||
auth_code = f"clerk_custom_{session_id}_{int(time.time())}"
|
||||
|
||||
# Store the code mapping for token exchange
|
||||
code_data = {
|
||||
"session_id": session_id,
|
||||
"clerk_authenticated": True,
|
||||
"clerk_authenticated": user_authenticated,
|
||||
"auth_method": auth_method,
|
||||
"custom_domain_flow": True,
|
||||
"created_at": time.time(),
|
||||
"expires_at": (datetime.utcnow() + timedelta(minutes=5)).timestamp(),
|
||||
}
|
||||
if "user_id" in oauth_session:
|
||||
code_data["user_id"] = oauth_session["user_id"]
|
||||
|
||||
oauth_provider.storage.set_session(f"code_{auth_code}", code_data)
|
||||
|
||||
# Build redirect URL back to Claude
|
||||
@@ -264,71 +324,56 @@ async def token_endpoint(request: Request):
|
||||
)
|
||||
|
||||
try:
|
||||
# Import here to avoid circular imports
|
||||
from mcp_server_main import app as mcp_app
|
||||
from mcp_auth_factory import get_oauth_provider
|
||||
# OAuth token exchange - validate code and return Clerk JWT
|
||||
# This supports proper OAuth flow while using Clerk JWT tokens
|
||||
|
||||
# Get OAuth provider
|
||||
oauth_provider = get_oauth_provider(mcp_app)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(status_code=500, detail="OAuth provider not configured")
|
||||
|
||||
# Extract session info from code
|
||||
code_session = None
|
||||
if code.startswith("clerk_"):
|
||||
# Get the code mapping
|
||||
code_session = oauth_provider.storage.get_session(f"code_{code}")
|
||||
if code_session:
|
||||
session_id = code_session.get("session_id")
|
||||
else:
|
||||
logger.error(f"Code mapping not found for: {code}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_grant", "error_description": "Invalid authorization code"}
|
||||
)
|
||||
else:
|
||||
session_id = code
|
||||
|
||||
session = oauth_provider.storage.get_session(session_id)
|
||||
|
||||
if not session:
|
||||
logger.error(f"Session {session_id} not found for token exchange")
|
||||
if not code or not redirect_uri:
|
||||
logger.error("Missing required parameters: code or redirect_uri")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_grant", "error_description": "Invalid authorization code"}
|
||||
content={"error": "invalid_request", "error_description": "Missing code or redirect_uri"}
|
||||
)
|
||||
|
||||
# Validate PKCE if present
|
||||
if "pkce_challenge" in session and code_verifier:
|
||||
# Validate PKCE challenge
|
||||
if not oauth_provider.validate_pkce(code_verifier, session["pkce_challenge"]):
|
||||
logger.error("PKCE challenge validation failed")
|
||||
# Validate OAuth code with Clerk
|
||||
if CLERK_AVAILABLE:
|
||||
try:
|
||||
clerk = Clerk(bearer_auth=os.getenv("CLERK_SECRET_KEY"))
|
||||
|
||||
# In a real implementation, you'd validate the code with Clerk
|
||||
# For now, we'll assume the code is valid if it looks like a Clerk code
|
||||
if len(code) > 10: # Basic validation
|
||||
# Create a mock session with the code
|
||||
# In practice, this would be validated with Clerk's OAuth flow
|
||||
|
||||
# Return Clerk JWT token format
|
||||
# This should be the actual Clerk JWT token from the OAuth flow
|
||||
return JSONResponse({
|
||||
"access_token": f"mock_clerk_jwt_{code}",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "yargi.read yargi.search"
|
||||
})
|
||||
else:
|
||||
logger.error(f"Invalid code format: {code}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_grant", "error_description": "Invalid authorization code"}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Clerk validation failed: {e}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_grant", "error_description": "Invalid code verifier"}
|
||||
content={"error": "invalid_grant", "error_description": "Authorization code validation failed"}
|
||||
)
|
||||
logger.info("PKCE validation successful")
|
||||
else:
|
||||
logger.info("No PKCE validation required")
|
||||
|
||||
# Create JWT token
|
||||
access_token = oauth_provider._create_mcp_token(
|
||||
session["scopes"],
|
||||
session.get("clerk_token", ""),
|
||||
session_id
|
||||
)
|
||||
|
||||
# Clean up sessions
|
||||
oauth_provider.storage.delete_session(session_id)
|
||||
if code_session:
|
||||
oauth_provider.storage.delete_session(f"code_{code}")
|
||||
|
||||
return JSONResponse({
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": " ".join(session["scopes"])
|
||||
})
|
||||
logger.warning("Clerk SDK not available, using mock response")
|
||||
return JSONResponse({
|
||||
"access_token": "mock_jwt_token_for_development",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "yargi.read yargi.search"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Token exchange failed: {e}")
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
"""
|
||||
Simplified MCP OAuth HTTP adapter - only Clerk JWT based authentication
|
||||
Uses Redis for authorization code storage to support multi-machine deployment
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode, quote
|
||||
|
||||
from fastapi import APIRouter, Request, Query, HTTPException
|
||||
from fastapi.responses import RedirectResponse, JSONResponse
|
||||
|
||||
# Import Redis session store
|
||||
from redis_session_store import get_redis_store
|
||||
|
||||
# Try to import Clerk SDK
|
||||
try:
|
||||
from clerk_backend_api import Clerk
|
||||
CLERK_AVAILABLE = True
|
||||
except ImportError:
|
||||
CLERK_AVAILABLE = False
|
||||
Clerk = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# OAuth configuration
|
||||
BASE_URL = os.getenv("BASE_URL", "https://api.yargimcp.com")
|
||||
CLERK_DOMAIN = os.getenv("CLERK_DOMAIN", "accounts.yargimcp.com")
|
||||
|
||||
# Initialize Redis store
|
||||
redis_store = None
|
||||
|
||||
def get_redis_session_store():
|
||||
"""Get Redis store instance with lazy initialization."""
|
||||
global redis_store
|
||||
if redis_store is None:
|
||||
try:
|
||||
import concurrent.futures
|
||||
import functools
|
||||
|
||||
# Use thread pool with timeout to prevent hanging
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(get_redis_store)
|
||||
try:
|
||||
# 5 second timeout for Redis initialization
|
||||
redis_store = future.result(timeout=5.0)
|
||||
if redis_store:
|
||||
logger.info("Redis session store initialized for OAuth handler")
|
||||
else:
|
||||
logger.warning("Redis store initialization returned None")
|
||||
except concurrent.futures.TimeoutError:
|
||||
logger.error("Redis initialization timed out after 5 seconds")
|
||||
redis_store = None
|
||||
future.cancel() # Try to cancel the hanging operation
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Redis store: {e}")
|
||||
redis_store = None
|
||||
|
||||
if redis_store is None:
|
||||
# Fall back to in-memory storage with warning
|
||||
logger.warning("Falling back to in-memory storage - multi-machine deployment will not work")
|
||||
|
||||
return redis_store
|
||||
|
||||
@router.get("/.well-known/oauth-authorization-server")
|
||||
async def get_oauth_metadata():
|
||||
"""OAuth 2.0 Authorization Server Metadata (RFC 8414)"""
|
||||
return JSONResponse({
|
||||
"issuer": BASE_URL,
|
||||
"authorization_endpoint": "https://yargimcp.com/mcp-callback",
|
||||
"token_endpoint": f"{BASE_URL}/token",
|
||||
"registration_endpoint": f"{BASE_URL}/register",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"token_endpoint_auth_methods_supported": ["none"],
|
||||
"scopes_supported": ["read", "search", "openid", "profile", "email"],
|
||||
"service_documentation": f"{BASE_URL}/mcp/"
|
||||
})
|
||||
|
||||
@router.get("/auth/login")
|
||||
async def oauth_authorize(
|
||||
request: Request,
|
||||
client_id: str = Query(...),
|
||||
redirect_uri: str = Query(...),
|
||||
response_type: str = Query("code"),
|
||||
scope: Optional[str] = Query("read search"),
|
||||
state: Optional[str] = Query(None),
|
||||
code_challenge: Optional[str] = Query(None),
|
||||
code_challenge_method: Optional[str] = Query(None)
|
||||
):
|
||||
"""OAuth 2.1 Authorization Endpoint - redirects to Clerk"""
|
||||
|
||||
logger.info(f"OAuth authorize request - client_id: {client_id}")
|
||||
logger.info(f"Redirect URI: {redirect_uri}")
|
||||
logger.info(f"State: {state}")
|
||||
logger.info(f"PKCE Challenge: {bool(code_challenge)}")
|
||||
|
||||
try:
|
||||
# Build callback URL with all necessary parameters
|
||||
callback_url = f"{BASE_URL}/auth/callback"
|
||||
callback_params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"state": state or "",
|
||||
"scope": scope or "read search"
|
||||
}
|
||||
|
||||
# Add PKCE parameters if present
|
||||
if code_challenge:
|
||||
callback_params["code_challenge"] = code_challenge
|
||||
callback_params["code_challenge_method"] = code_challenge_method or "S256"
|
||||
|
||||
# Encode callback URL as redirect_url for Clerk
|
||||
callback_with_params = f"{callback_url}?{urlencode(callback_params)}"
|
||||
|
||||
# Build Clerk sign-in URL - use yargimcp.com frontend for JWT token generation
|
||||
clerk_params = {
|
||||
"redirect_url": callback_with_params
|
||||
}
|
||||
|
||||
# Use frontend sign-in page that handles JWT token generation
|
||||
clerk_signin_url = f"https://yargimcp.com/sign-in?{urlencode(clerk_params)}"
|
||||
|
||||
logger.info(f"Redirecting to Clerk: {clerk_signin_url}")
|
||||
|
||||
return RedirectResponse(url=clerk_signin_url)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Authorization failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/auth/callback")
|
||||
async def oauth_callback(
|
||||
request: Request,
|
||||
client_id: str = Query(...),
|
||||
redirect_uri: str = Query(...),
|
||||
state: Optional[str] = Query(None),
|
||||
scope: Optional[str] = Query("read search"),
|
||||
code_challenge: Optional[str] = Query(None),
|
||||
code_challenge_method: Optional[str] = Query(None),
|
||||
clerk_token: Optional[str] = Query(None)
|
||||
):
|
||||
"""OAuth callback from Clerk - generates authorization code"""
|
||||
|
||||
logger.info(f"OAuth callback - client_id: {client_id}")
|
||||
logger.info(f"Clerk token provided: {bool(clerk_token)}")
|
||||
|
||||
try:
|
||||
# Validate user with Clerk and generate real JWT token
|
||||
user_authenticated = False
|
||||
user_id = None
|
||||
session_id = None
|
||||
real_jwt_token = None
|
||||
|
||||
if clerk_token and CLERK_AVAILABLE:
|
||||
try:
|
||||
# Extract user info from JWT token (no Clerk session verification needed)
|
||||
import jwt
|
||||
decoded_token = jwt.decode(clerk_token, options={"verify_signature": False})
|
||||
user_id = decoded_token.get("user_id") or decoded_token.get("sub")
|
||||
user_email = decoded_token.get("email")
|
||||
token_scopes = decoded_token.get("scopes", ["read", "search"])
|
||||
|
||||
logger.info(f"JWT token claims - user_id: {user_id}, email: {user_email}, scopes: {token_scopes}")
|
||||
|
||||
if user_id and user_email:
|
||||
# JWT token is already signed by Clerk and contains valid user info
|
||||
user_authenticated = True
|
||||
logger.info(f"User authenticated via JWT token - user_id: {user_id}")
|
||||
|
||||
# Use the JWT token directly as the real token (it's already from Clerk template)
|
||||
real_jwt_token = clerk_token
|
||||
logger.info("Using Clerk JWT token directly (already real token)")
|
||||
|
||||
else:
|
||||
logger.error(f"Missing required fields in JWT token - user_id: {bool(user_id)}, email: {bool(user_email)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"JWT validation failed: {e}")
|
||||
|
||||
# Fallback to cookie validation
|
||||
if not user_authenticated:
|
||||
clerk_session = request.cookies.get("__session")
|
||||
if clerk_session:
|
||||
user_authenticated = True
|
||||
logger.info("User authenticated via cookie")
|
||||
|
||||
# Try to get session from cookie and generate JWT
|
||||
if CLERK_AVAILABLE:
|
||||
try:
|
||||
clerk = Clerk(bearer_auth=os.getenv("CLERK_SECRET_KEY"))
|
||||
# Note: sessions.verify_session is deprecated, but we'll try
|
||||
# In practice, you'd need to extract session_id from cookie
|
||||
logger.info("Cookie authentication - JWT generation not implemented yet")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to generate JWT from cookie: {e}")
|
||||
|
||||
# Only generate authorization code if we have a real JWT token
|
||||
if user_authenticated and real_jwt_token:
|
||||
# Generate authorization code
|
||||
auth_code = f"clerk_auth_{os.urandom(16).hex()}"
|
||||
|
||||
# Prepare code data
|
||||
import time
|
||||
code_data = {
|
||||
"user_id": user_id,
|
||||
"session_id": session_id,
|
||||
"real_jwt_token": real_jwt_token,
|
||||
"user_authenticated": user_authenticated,
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": scope or "read search"
|
||||
}
|
||||
|
||||
# Try to store in Redis, fall back to in-memory if Redis unavailable
|
||||
store = get_redis_session_store()
|
||||
if store:
|
||||
# Store in Redis with automatic expiration
|
||||
success = store.set_oauth_code(auth_code, code_data)
|
||||
if success:
|
||||
logger.info(f"Stored authorization code {auth_code[:10]}... in Redis with real JWT token")
|
||||
else:
|
||||
logger.error(f"Failed to store authorization code in Redis, falling back to in-memory")
|
||||
# Fall back to in-memory storage
|
||||
if not hasattr(oauth_callback, '_code_storage'):
|
||||
oauth_callback._code_storage = {}
|
||||
oauth_callback._code_storage[auth_code] = code_data
|
||||
else:
|
||||
# Fall back to in-memory storage
|
||||
logger.warning("Redis not available, using in-memory storage")
|
||||
if not hasattr(oauth_callback, '_code_storage'):
|
||||
oauth_callback._code_storage = {}
|
||||
oauth_callback._code_storage[auth_code] = code_data
|
||||
logger.info(f"Stored authorization code in memory (fallback)")
|
||||
|
||||
# Redirect back to client with authorization code
|
||||
redirect_params = {
|
||||
"code": auth_code,
|
||||
"state": state or ""
|
||||
}
|
||||
|
||||
final_redirect_url = f"{redirect_uri}?{urlencode(redirect_params)}"
|
||||
logger.info(f"Redirecting back to client: {final_redirect_url}")
|
||||
|
||||
return RedirectResponse(url=final_redirect_url)
|
||||
else:
|
||||
# No JWT token yet - redirect back to sign-in page to wait for authentication
|
||||
logger.info("No JWT token provided - redirecting back to sign-in to complete authentication")
|
||||
|
||||
# Keep the same redirect URL so the flow continues
|
||||
sign_in_params = {
|
||||
"redirect_url": f"{request.url._url}" # Current callback URL with all params
|
||||
}
|
||||
|
||||
sign_in_url = f"https://yargimcp.com/sign-in?{urlencode(sign_in_params)}"
|
||||
logger.info(f"Redirecting back to sign-in: {sign_in_url}")
|
||||
|
||||
return RedirectResponse(url=sign_in_url)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Callback processing failed: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "server_error", "error_description": str(e)}
|
||||
)
|
||||
|
||||
@router.post("/auth/register")
|
||||
async def register_client(request: Request):
|
||||
"""Dynamic Client Registration (RFC 7591)"""
|
||||
|
||||
data = await request.json()
|
||||
logger.info(f"Client registration request: {data}")
|
||||
|
||||
# Simple dynamic registration - accept any client
|
||||
client_id = f"mcp-client-{os.urandom(8).hex()}"
|
||||
|
||||
return JSONResponse({
|
||||
"client_id": client_id,
|
||||
"client_secret": None, # Public client
|
||||
"redirect_uris": data.get("redirect_uris", []),
|
||||
"grant_types": ["authorization_code"],
|
||||
"response_types": ["code"],
|
||||
"client_name": data.get("client_name", "MCP Client"),
|
||||
"token_endpoint_auth_method": "none"
|
||||
})
|
||||
|
||||
@router.post("/auth/callback")
|
||||
async def oauth_callback_post(request: Request):
|
||||
"""OAuth callback POST endpoint for token exchange"""
|
||||
|
||||
# Parse form data (standard OAuth token exchange format)
|
||||
form_data = await request.form()
|
||||
grant_type = form_data.get("grant_type")
|
||||
code = form_data.get("code")
|
||||
redirect_uri = form_data.get("redirect_uri")
|
||||
client_id = form_data.get("client_id")
|
||||
code_verifier = form_data.get("code_verifier")
|
||||
|
||||
logger.info(f"OAuth callback POST - grant_type: {grant_type}")
|
||||
logger.info(f"Code: {code[:20] if code else 'None'}...")
|
||||
logger.info(f"Client ID: {client_id}")
|
||||
logger.info(f"PKCE verifier: {bool(code_verifier)}")
|
||||
|
||||
if grant_type != "authorization_code":
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "unsupported_grant_type"}
|
||||
)
|
||||
|
||||
if not code or not redirect_uri:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_request", "error_description": "Missing code or redirect_uri"}
|
||||
)
|
||||
|
||||
try:
|
||||
# Validate authorization code
|
||||
if not code.startswith("clerk_auth_"):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_grant", "error_description": "Invalid authorization code"}
|
||||
)
|
||||
|
||||
# Retrieve stored JWT token using authorization code from Redis or in-memory fallback
|
||||
stored_code_data = None
|
||||
|
||||
# Try to get from Redis first, then fall back to in-memory
|
||||
store = get_redis_session_store()
|
||||
if store:
|
||||
stored_code_data = store.get_oauth_code(code, delete_after_use=True)
|
||||
if stored_code_data:
|
||||
logger.info(f"Retrieved authorization code {code[:10]}... from Redis")
|
||||
else:
|
||||
logger.warning(f"Authorization code {code[:10]}... not found in Redis")
|
||||
|
||||
# Fall back to in-memory storage if Redis unavailable or code not found
|
||||
if not stored_code_data and hasattr(oauth_callback, '_code_storage'):
|
||||
stored_code_data = oauth_callback._code_storage.get(code)
|
||||
if stored_code_data:
|
||||
# Clean up in-memory storage
|
||||
oauth_callback._code_storage.pop(code, None)
|
||||
logger.info(f"Retrieved authorization code {code[:10]}... from in-memory storage")
|
||||
|
||||
if not stored_code_data:
|
||||
logger.error(f"No stored data found for authorization code: {code}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_grant", "error_description": "Authorization code not found or expired"}
|
||||
)
|
||||
|
||||
# Note: Redis TTL handles expiration automatically, but check for manual expiration for in-memory fallback
|
||||
import time
|
||||
expires_at = stored_code_data.get("expires_at", 0)
|
||||
if expires_at and time.time() > expires_at:
|
||||
logger.error(f"Authorization code expired: {code}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_grant", "error_description": "Authorization code expired"}
|
||||
)
|
||||
|
||||
# Get the real JWT token
|
||||
real_jwt_token = stored_code_data.get("real_jwt_token")
|
||||
|
||||
if real_jwt_token:
|
||||
logger.info("Returning real Clerk JWT token")
|
||||
# Note: Code already deleted from Redis, clean up in-memory fallback if used
|
||||
if hasattr(oauth_callback, '_code_storage'):
|
||||
oauth_callback._code_storage.pop(code, None)
|
||||
|
||||
return JSONResponse({
|
||||
"access_token": real_jwt_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "read search"
|
||||
})
|
||||
else:
|
||||
logger.warning("No real JWT token found, generating mock token")
|
||||
# Fallback to mock token for testing
|
||||
mock_token = f"mock_clerk_jwt_{code}"
|
||||
return JSONResponse({
|
||||
"access_token": mock_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "read search"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"OAuth callback POST failed: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "server_error", "error_description": str(e)}
|
||||
)
|
||||
|
||||
@router.post("/register")
|
||||
async def register_client(request: Request):
|
||||
"""Dynamic Client Registration (RFC 7591)"""
|
||||
|
||||
data = await request.json()
|
||||
logger.info(f"Client registration request: {data}")
|
||||
|
||||
# Simple dynamic registration - accept any client
|
||||
client_id = f"mcp-client-{os.urandom(8).hex()}"
|
||||
|
||||
return JSONResponse({
|
||||
"client_id": client_id,
|
||||
"client_secret": None, # Public client
|
||||
"redirect_uris": data.get("redirect_uris", []),
|
||||
"grant_types": ["authorization_code"],
|
||||
"response_types": ["code"],
|
||||
"client_name": data.get("client_name", "MCP Client"),
|
||||
"token_endpoint_auth_method": "none"
|
||||
})
|
||||
|
||||
@router.post("/token")
|
||||
async def token_endpoint(request: Request):
|
||||
"""OAuth 2.1 Token Endpoint - exchanges code for Clerk JWT"""
|
||||
|
||||
# Parse form data
|
||||
form_data = await request.form()
|
||||
grant_type = form_data.get("grant_type")
|
||||
code = form_data.get("code")
|
||||
redirect_uri = form_data.get("redirect_uri")
|
||||
client_id = form_data.get("client_id")
|
||||
code_verifier = form_data.get("code_verifier")
|
||||
|
||||
logger.info(f"Token exchange - grant_type: {grant_type}")
|
||||
logger.info(f"Code: {code[:20] if code else 'None'}...")
|
||||
|
||||
if grant_type != "authorization_code":
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "unsupported_grant_type"}
|
||||
)
|
||||
|
||||
if not code or not redirect_uri:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_request", "error_description": "Missing code or redirect_uri"}
|
||||
)
|
||||
|
||||
try:
|
||||
# Validate authorization code
|
||||
if not code.startswith("clerk_auth_"):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_grant", "error_description": "Invalid authorization code"}
|
||||
)
|
||||
|
||||
# Retrieve stored JWT token using authorization code from Redis or in-memory fallback
|
||||
stored_code_data = None
|
||||
|
||||
# Try to get from Redis first, then fall back to in-memory
|
||||
store = get_redis_session_store()
|
||||
if store:
|
||||
stored_code_data = store.get_oauth_code(code, delete_after_use=True)
|
||||
if stored_code_data:
|
||||
logger.info(f"Retrieved authorization code {code[:10]}... from Redis (/token endpoint)")
|
||||
else:
|
||||
logger.warning(f"Authorization code {code[:10]}... not found in Redis (/token endpoint)")
|
||||
|
||||
# Fall back to in-memory storage if Redis unavailable or code not found
|
||||
if not stored_code_data and hasattr(oauth_callback, '_code_storage'):
|
||||
stored_code_data = oauth_callback._code_storage.get(code)
|
||||
if stored_code_data:
|
||||
# Clean up in-memory storage
|
||||
oauth_callback._code_storage.pop(code, None)
|
||||
logger.info(f"Retrieved authorization code {code[:10]}... from in-memory storage (/token endpoint)")
|
||||
|
||||
if not stored_code_data:
|
||||
logger.error(f"No stored data found for authorization code: {code}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_grant", "error_description": "Authorization code not found or expired"}
|
||||
)
|
||||
|
||||
# Note: Redis TTL handles expiration automatically, but check for manual expiration for in-memory fallback
|
||||
import time
|
||||
expires_at = stored_code_data.get("expires_at", 0)
|
||||
if expires_at and time.time() > expires_at:
|
||||
logger.error(f"Authorization code expired: {code}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_grant", "error_description": "Authorization code expired"}
|
||||
)
|
||||
|
||||
# Get the real JWT token
|
||||
real_jwt_token = stored_code_data.get("real_jwt_token")
|
||||
|
||||
if real_jwt_token:
|
||||
logger.info("Returning real Clerk JWT token from /token endpoint")
|
||||
# Note: Code already deleted from Redis, clean up in-memory fallback if used
|
||||
if hasattr(oauth_callback, '_code_storage'):
|
||||
oauth_callback._code_storage.pop(code, None)
|
||||
|
||||
return JSONResponse({
|
||||
"access_token": real_jwt_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "read search"
|
||||
})
|
||||
else:
|
||||
logger.warning("No real JWT token found in /token endpoint, generating mock token")
|
||||
# Fallback to mock token for testing
|
||||
mock_token = f"mock_clerk_jwt_{code}"
|
||||
return JSONResponse({
|
||||
"access_token": mock_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "read search"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Token exchange failed: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "server_error", "error_description": str(e)}
|
||||
)
|
||||
+1678
-1968
File diff suppressed because it is too large
Load Diff
+10
-6
@@ -1,12 +1,12 @@
|
||||
[project]
|
||||
name = "yargi-mcp"
|
||||
version = "0.1.2"
|
||||
version = "0.2.0"
|
||||
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", "turkish", "law", "court", "decisions"]
|
||||
keywords = ["mcp", "turkish-law", "legal", "yargitay", "danistay", "bddk", "kvkk", "turkish", "law", "court", "decisions"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Legal Industry",
|
||||
@@ -25,11 +25,12 @@ dependencies = [
|
||||
"markitdown[pdf]>=0.1.1",
|
||||
"pydantic>=2.11.4",
|
||||
"aiohttp>=3.11.18",
|
||||
"playwright>=1.52.0",
|
||||
"fastmcp>=2.9.2",
|
||||
"fastmcp>=2.10.5",
|
||||
"pypdf>=5.5.0",
|
||||
"fastapi>=0.115.14",
|
||||
"PyJWT>=2.8.0",
|
||||
"cryptography>=44.0.0",
|
||||
"openai>=1.0.0",
|
||||
"numpy>=1.24.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -48,6 +49,9 @@ production = [
|
||||
saas = [
|
||||
"clerk-backend-api>=3.0.0",
|
||||
"stripe>=9.1.0",
|
||||
"upstash-redis>=1.1.0",
|
||||
"tiktoken>=0.5.0",
|
||||
"PyJWT>=2.8.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -57,7 +61,7 @@ yargi-mcp = "mcp_server_main:main"
|
||||
py-modules = ["mcp_server_main", "mcp_auth_factory", "mcp_auth_http_adapter", "asgi_app", "fastapi_app", "starlette_app", "run_asgi", "stripe_webhook"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["*_mcp_module", "mcp_auth"]
|
||||
include = ["*_mcp_module", "mcp_auth", "semantic_search"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=65.0", "wheel"]
|
||||
|
||||
@@ -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
|
||||
@@ -25,35 +25,31 @@ class RekabetKararTuruAdiEnum(str, Enum):
|
||||
|
||||
class RekabetKurumuSearchRequest(BaseModel):
|
||||
"""Model for Rekabet Kurumu (Turkish Competition Authority) search request."""
|
||||
sayfaAdi: Optional[str] = Field(None, description="Search in decision title (Başlık).")
|
||||
YayinlanmaTarihi: Optional[str] = Field(None, description="Publication date (Yayım Tarihi), e.g., DD.MM.YYYY.")
|
||||
PdfText: Optional[str] = Field(
|
||||
None,
|
||||
description='Search in decision text (Metin). For an exact phrase match, enclose the phrase in double quotes (e.g., "\\"vertical agreement\\" competition). The website indicates that using "" provides more precise results for phrases.'
|
||||
)
|
||||
# This field uses the GUID enum as it's used by the client to make the actual web request.
|
||||
KararTuruID: Optional[RekabetKararTuruGuidEnum] = Field(RekabetKararTuruGuidEnum.TUMU, description="Decision type (Karar Türü) GUID for internal client use, corresponding to the website's values.")
|
||||
KararSayisi: Optional[str] = Field(None, description="Decision number (Karar Sayısı).")
|
||||
KararTarihi: Optional[str] = Field(None, description="Decision date (Karar Tarihi), e.g., DD.MM.YYYY.")
|
||||
page: int = Field(1, ge=1, description="Page number to fetch for results list.")
|
||||
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: Optional[str] = Field(None, description="Publication Date (Yayımlanma Tarihi).")
|
||||
decision_number: Optional[str] = Field(None, description="Decision Number (Karar Sayısı).")
|
||||
decision_date: Optional[str] = Field(None, description="Decision Date (Karar Tarihi).")
|
||||
decision_type_text: Optional[str] = Field(None, description="Decision Type as text (Karar Türü - metin olarak).")
|
||||
title: Optional[str] = Field(None, description="Decision title or summary text.")
|
||||
decision_url: Optional[HttpUrl] = Field(None, description="URL to the decision's landing page (e.g., /Karar?kararId=...).")
|
||||
karar_id: Optional[str] = Field(None, description="GUID of the decision, extracted from its URL.")
|
||||
related_cases_url: Optional[HttpUrl] = Field(None, description="URL to related court cases page, if available.")
|
||||
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: Optional[int] = Field(None, description="Total number of records found matching the query.")
|
||||
retrieved_page_number: int = Field(description="The page number of the results that were retrieved.")
|
||||
total_pages: Optional[int] = Field(None, description="Total number of pages available for the query.")
|
||||
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):
|
||||
"""
|
||||
@@ -61,16 +57,15 @@ class RekabetDocument(BaseModel):
|
||||
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="The URL of the decision's landing page from which the PDF was identified.")
|
||||
karar_id: str = Field(description="GUID of the decision.")
|
||||
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 as found on the landing page (e.g., from <title> tag or a main heading). Could be a generic title if direct PDF.")
|
||||
pdf_url: Optional[HttpUrl] = Field(None, description="Direct URL to the decision PDF document, if successfully found and resolved.")
|
||||
title_on_landing_page: Optional[str] = Field(None, description="Title")
|
||||
pdf_url: Optional[HttpUrl] = Field(None, description="PDF URL")
|
||||
|
||||
# Fields for Markdown content derived from the PDF
|
||||
markdown_chunk: Optional[str] = Field(None, description="A 5,000 character chunk of the Markdown content derived from the decision PDF.")
|
||||
current_page: int = Field(1, description="The current page number of the PDF-derived markdown chunk (1-indexed).")
|
||||
total_pages: int = Field(1, description="Total number of pages for the full PDF-derived markdown content. Will be 0 if content could not be processed.")
|
||||
is_paginated: bool = Field(False, description="True if the full PDF-derived markdown content is split into multiple pages.")
|
||||
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="Contains an error message if the document retrieval or processing failed at any stage.")
|
||||
error_message: Optional[str] = Field(None, description="Error")
|
||||
@@ -1,11 +0,0 @@
|
||||
fastmcp
|
||||
httpx
|
||||
beautifulsoup4
|
||||
markitdown[pdf]
|
||||
pydantic
|
||||
aiohttp
|
||||
playwright
|
||||
pypdf
|
||||
fastapi>=0.115.14
|
||||
uvicorn[standard]>=0.30.0
|
||||
starlette>=0.37.0
|
||||
@@ -6,8 +6,7 @@ from bs4 import BeautifulSoup
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
import logging
|
||||
import html
|
||||
import tempfile
|
||||
import os
|
||||
import io
|
||||
from urllib.parse import urlencode, urljoin
|
||||
from markitdown import MarkItDown
|
||||
|
||||
@@ -17,7 +16,7 @@ from .models import (
|
||||
DaireSearchRequest, DaireSearchResponse, DaireDecision,
|
||||
SayistayDocumentMarkdown
|
||||
)
|
||||
from .enums import DaireEnum, KamuIdaresiTuruEnum, WebKararKonusuEnum
|
||||
from .enums import DaireEnum, KamuIdaresiTuruEnum, WebKararKonusuEnum, WEB_KARAR_KONUSU_MAPPING
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if not logger.hasHandlers():
|
||||
@@ -135,6 +134,11 @@ class SayistayApiClient:
|
||||
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 _build_datatables_params(self, start: int, length: int, draw: int = 1) -> List[Tuple[str, str]]:
|
||||
@@ -532,21 +536,18 @@ class SayistayApiClient:
|
||||
raise
|
||||
|
||||
def _convert_html_to_markdown(self, html_content: str) -> Optional[str]:
|
||||
"""Convert HTML content to Markdown using MarkItDown."""
|
||||
"""Convert HTML content to Markdown using MarkItDown with BytesIO to avoid filename length issues."""
|
||||
if not html_content:
|
||||
return None
|
||||
|
||||
temp_file_path = 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()
|
||||
|
||||
# Write HTML to temp file
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as tmp:
|
||||
tmp.write(html_content)
|
||||
temp_file_path = tmp.name
|
||||
|
||||
# Convert
|
||||
result = md_converter.convert(temp_file_path)
|
||||
result = md_converter.convert(html_stream)
|
||||
markdown_content = result.text_content
|
||||
|
||||
logger.info("Successfully converted HTML to Markdown")
|
||||
@@ -555,9 +556,6 @@ class SayistayApiClient:
|
||||
except Exception as e:
|
||||
logger.error(f"Error converting HTML to Markdown: {e}")
|
||||
return f"Error converting HTML content: {str(e)}"
|
||||
finally:
|
||||
if temp_file_path and os.path.exists(temp_file_path):
|
||||
os.remove(temp_file_path)
|
||||
|
||||
async def get_document_as_markdown(self, decision_id: str, decision_type: str) -> SayistayDocumentMarkdown:
|
||||
"""
|
||||
|
||||
@@ -28,18 +28,30 @@ KamuIdaresiTuruEnum = Literal[
|
||||
"Diğer" # Other
|
||||
]
|
||||
|
||||
# Decision Subject Categories (Web Karar Konusu)
|
||||
# Decision Subject Categories (Web Karar Konusu) - Shortened for token efficiency
|
||||
WebKararKonusuEnum = Literal[
|
||||
"ALL", # All subjects
|
||||
"Harcırah Mevzuatı ile İlgili Kararlar", # Travel Allowance Legislation Related Decisions
|
||||
"İhale Mevzuatı ile İlgili Kararlar", # Procurement Legislation Related Decisions
|
||||
"İş Mevzuatı ile İlgili Kararlar", # Labor Legislation Related Decisions
|
||||
"Personel Mevzuatı ile İlgili Kararlar", # Personnel Legislation Related Decisions
|
||||
"Sorumluluk ve Yargılama Usulleri ile İlgili Kararlar", # Liability and Trial Procedures Related Decisions
|
||||
"Vergi Resmi Harç ve Diğer Gelirlerle İlgili Kararlar", # Tax, Official Fee and Other Revenue Related Decisions
|
||||
"Çeşitli Konuları İlgilendiren Kararlar" # Decisions Concerning Various Topics
|
||||
"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
|
||||
|
||||
+89
-111
@@ -1,9 +1,16 @@
|
||||
# sayistay_mcp_module/models.py
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Union
|
||||
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
|
||||
# ============================================================================
|
||||
@@ -16,30 +23,18 @@ class GenelKurulSearchRequest(BaseModel):
|
||||
of the Turkish Court of Accounts, typically addressing interpretation of
|
||||
audit and accountability regulations.
|
||||
"""
|
||||
karar_no: Optional[str] = Field(None, description="Decision number (e.g., '5415')")
|
||||
karar_ek: Optional[str] = Field(None, description="Decision appendix number (max 99)")
|
||||
karar_no: str = Field("", description="Decision no")
|
||||
karar_ek: str = Field("", description="Appendix no")
|
||||
|
||||
karar_tarih_baslangic: Optional[str] = Field(None, description="""
|
||||
Decision start year for date range filtering.
|
||||
Available years: 2006-2024. Format: 'YYYY' (e.g., '2020')
|
||||
Use with karar_tarih_bitis for date range filtering.
|
||||
""")
|
||||
karar_tarih_baslangic: str = Field("", description="Start year (YYYY)")
|
||||
|
||||
karar_tarih_bitis: Optional[str] = Field(None, description="""
|
||||
Decision end year for date range filtering.
|
||||
Available years: 2006-2024. Format: 'YYYY' (e.g., '2024')
|
||||
Use with karar_tarih_baslangic for date range filtering.
|
||||
""")
|
||||
karar_tarih_bitis: str = Field("", description="End year")
|
||||
|
||||
karar_tamami: Optional[str] = Field(None, description="""
|
||||
Content/text search within decision summaries (max 400 characters).
|
||||
Searches in decision abstracts and main content.
|
||||
Example: 'belediye taşınmaz tahsis'
|
||||
""")
|
||||
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-100)")
|
||||
length: int = Field(10, description="Number of records per page (1-10)")
|
||||
|
||||
class GenelKurulDecision(BaseModel):
|
||||
"""Single Genel Kurul decision entry from search results."""
|
||||
@@ -66,62 +61,27 @@ class TemyizKuruluSearchRequest(BaseModel):
|
||||
Temyiz Kurulu reviews appeals against audit chamber decisions,
|
||||
providing higher-level review of audit findings and sanctions.
|
||||
"""
|
||||
ilam_dairesi: DaireEnum = Field("ALL", description="""
|
||||
Chamber/Department filter for appeals board decisions.
|
||||
• ALL: All chambers (default)
|
||||
• 1-8: Specific chamber number (1. Daire through 8. Daire)
|
||||
Each chamber specializes in different types of public institutions.
|
||||
""")
|
||||
ilam_dairesi: DaireEnum = Field("ALL", description="Value")
|
||||
|
||||
yili: Optional[str] = Field(None, description="""
|
||||
Account year filter (Hesap Yılı).
|
||||
Available years: 1993-2022. Format: 'YYYY' (e.g., '2020')
|
||||
Refers to the fiscal year being audited, not decision date.
|
||||
""")
|
||||
yili: str = Field("", description="Value")
|
||||
|
||||
karar_tarih_baslangic: Optional[str] = Field(None, description="""
|
||||
Decision start year for date range filtering.
|
||||
Available years: 2000, 2006-2024. Format: 'YYYY' (e.g., '2020')
|
||||
Use with karar_tarih_bitis for date range filtering.
|
||||
""")
|
||||
karar_tarih_baslangic: str = Field("", description="Value")
|
||||
|
||||
karar_tarih_bitis: Optional[str] = Field(None, description="""
|
||||
Decision end year for date range filtering.
|
||||
Available years: 2000, 2006-2024. Format: 'YYYY' (e.g., '2024')
|
||||
Use with karar_tarih_baslangic for date range filtering.
|
||||
""")
|
||||
karar_tarih_bitis: str = Field("", description="End year")
|
||||
|
||||
kamu_idaresi_turu: KamuIdaresiTuruEnum = Field("ALL", description="""
|
||||
Public administration type filter:
|
||||
• ALL: All institutions (default)
|
||||
• Genel Bütçe Kapsamındaki İdareler: General budget administrations
|
||||
• Yüksek Öğretim Kurumları: Higher education institutions
|
||||
• Belediyeler ve Bağlı İdareler: Municipalities and affiliates
|
||||
• Other specific institution types
|
||||
""")
|
||||
kamu_idaresi_turu: KamuIdaresiTuruEnum = Field("ALL", description="Value")
|
||||
|
||||
ilam_no: Optional[str] = Field(None, description="Audit report number (İlam No, max 50 chars)")
|
||||
dosya_no: Optional[str] = Field(None, description="File number for the case")
|
||||
temyiz_tutanak_no: Optional[str] = Field(None, description="Appeals board meeting minutes number")
|
||||
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: Optional[str] = Field(None, description="""
|
||||
Content search within appeals decisions.
|
||||
Searches decision text and reasoning.
|
||||
Example: 'araç kiralama kasko'
|
||||
""")
|
||||
temyiz_karar: str = Field("", description="Value")
|
||||
|
||||
web_karar_konusu: WebKararKonusuEnum = Field("ALL", description="""
|
||||
Decision subject category filter:
|
||||
• ALL: All subjects (default)
|
||||
• İhale Mevzuatı ile İlgili Kararlar: Procurement legislation
|
||||
• Personel Mevzuatı ile İlgili Kararlar: Personnel legislation
|
||||
• Harcırah Mevzuatı ile İlgili Kararlar: Travel allowance legislation
|
||||
• Other specialized legal areas
|
||||
""")
|
||||
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-100)")
|
||||
length: int = Field(10, description="Number of records per page (1-10)")
|
||||
|
||||
class TemyizKuruluDecision(BaseModel):
|
||||
"""Single Temyiz Kurulu decision entry from search results."""
|
||||
@@ -148,60 +108,25 @@ class DaireSearchRequest(BaseModel):
|
||||
Daire decisions are first-instance audit findings and sanctions
|
||||
issued by individual audit chambers before potential appeals.
|
||||
"""
|
||||
yargilama_dairesi: DaireEnum = Field("ALL", description="""
|
||||
Audit chamber filter:
|
||||
• ALL: All chambers (default)
|
||||
• 1-8: Specific chamber number (1. Daire through 8. Daire)
|
||||
Each chamber audits different types of public institutions.
|
||||
""")
|
||||
yargilama_dairesi: DaireEnum = Field("ALL", description="Value")
|
||||
|
||||
karar_tarih_baslangic: Optional[str] = Field(None, description="""
|
||||
Decision start year for date range filtering.
|
||||
Available years: 2012-2025. Format: 'YYYY' (e.g., '2020')
|
||||
Use with karar_tarih_bitis for date range filtering.
|
||||
""")
|
||||
karar_tarih_baslangic: str = Field("", description="Value")
|
||||
|
||||
karar_tarih_bitis: Optional[str] = Field(None, description="""
|
||||
Decision end year for date range filtering.
|
||||
Available years: 2012-2025. Format: 'YYYY' (e.g., '2024')
|
||||
Use with karar_tarih_baslangic for date range filtering.
|
||||
""")
|
||||
karar_tarih_bitis: str = Field("", description="End year")
|
||||
|
||||
ilam_no: Optional[str] = Field(None, description="Audit report number (İlam No, max 50 chars)")
|
||||
ilam_no: str = Field("", description="Audit report number (İlam No, max 50 chars)")
|
||||
|
||||
kamu_idaresi_turu: KamuIdaresiTuruEnum = Field("ALL", description="""
|
||||
Public administration type filter:
|
||||
• ALL: All institutions (default)
|
||||
• Genel Bütçe Kapsamındaki İdareler: General budget administrations
|
||||
• Yüksek Öğretim Kurumları: Higher education institutions
|
||||
• Belediyeler ve Bağlı İdareler: Municipalities and affiliates
|
||||
• Other specific institution types
|
||||
""")
|
||||
kamu_idaresi_turu: KamuIdaresiTuruEnum = Field("ALL", description="Value")
|
||||
|
||||
hesap_yili: Optional[str] = Field(None, description="""
|
||||
Account year filter (Hesap Yılı).
|
||||
Available years: 2005, 2008-2023. Format: 'YYYY' (e.g., '2020')
|
||||
Refers to the fiscal year being audited, not decision date.
|
||||
""")
|
||||
hesap_yili: str = Field("", description="Value")
|
||||
|
||||
web_karar_konusu: WebKararKonusuEnum = Field("ALL", description="""
|
||||
Decision subject category filter:
|
||||
• ALL: All subjects (default)
|
||||
• İhale Mevzuatı ile İlgili Kararlar: Procurement legislation
|
||||
• Personel Mevzuatı ile İlgili Kararlar: Personnel legislation
|
||||
• Vergi Resmi Harç ve Diğer Gelirlerle İlgili Kararlar: Tax and fee legislation
|
||||
• Other specialized legal areas
|
||||
""")
|
||||
web_karar_konusu: WebKararKonusuEnum = Field("ALL", description="Value")
|
||||
|
||||
web_karar_metni: Optional[str] = Field(None, description="""
|
||||
Content search within chamber decisions.
|
||||
Searches decision text and audit findings.
|
||||
Example: 'birim fiyat revize edilmemesi'
|
||||
""")
|
||||
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-100)")
|
||||
length: int = Field(10, description="Number of records per page (1-10)")
|
||||
|
||||
class DaireDecision(BaseModel):
|
||||
"""Single Daire decision entry from search results."""
|
||||
@@ -209,7 +134,7 @@ class DaireDecision(BaseModel):
|
||||
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: Optional[str] = Field(None, description="Audit report number (may be null)")
|
||||
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")
|
||||
@@ -235,8 +160,61 @@ class SayistayDocumentMarkdown(BaseModel):
|
||||
decision types (Genel Kurul, Temyiz Kurulu, Daire).
|
||||
"""
|
||||
decision_id: str = Field(..., description="Unique decision identifier")
|
||||
decision_type: str = Field(..., description="Type of decision: 'genel_kurul', 'temyiz_kurulu', or 'daire'")
|
||||
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,7 @@
|
||||
# semantic_search/__init__.py
|
||||
|
||||
from .embedder import OpenRouterEmbedder, is_openrouter_available
|
||||
from .vector_store import VectorStore
|
||||
from .processor import DocumentProcessor
|
||||
|
||||
__all__ = ['OpenRouterEmbedder', 'is_openrouter_available', 'VectorStore', 'DocumentProcessor']
|
||||
@@ -0,0 +1,154 @@
|
||||
# semantic_search/embedder.py
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Optional
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_openrouter_available() -> bool:
|
||||
"""Check if OpenRouter API key is available."""
|
||||
return bool(os.getenv("OPENROUTER_API_KEY"))
|
||||
|
||||
|
||||
class OpenRouterEmbedder:
|
||||
"""
|
||||
Embedder using OpenRouter API with Google's Gemini Embedding model.
|
||||
Requires OPENROUTER_API_KEY environment variable.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize OpenRouter Embedder.
|
||||
|
||||
Raises:
|
||||
ValueError: If OPENROUTER_API_KEY is not set
|
||||
ImportError: If openai package is not installed
|
||||
"""
|
||||
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 = "google/gemini-embedding-001"
|
||||
self.dimension = 3072
|
||||
|
||||
logger.info(f"OpenRouter Embedder initialized with model: {self.model}")
|
||||
|
||||
def encode_query(self, query: str, task: str = "search result") -> np.ndarray:
|
||||
"""
|
||||
Encode a search query.
|
||||
|
||||
Args:
|
||||
query: The search query text
|
||||
task: Task type for prompt template
|
||||
|
||||
Returns:
|
||||
Numpy array of embeddings (3072 dimensions)
|
||||
"""
|
||||
# Apply query prompt template
|
||||
text = f"task: {task} | query: {query}"
|
||||
|
||||
try:
|
||||
response = self.client.embeddings.create(
|
||||
model=self.model,
|
||||
input=text,
|
||||
encoding_format="float",
|
||||
extra_headers={
|
||||
"HTTP-Referer": "https://yargimcp.com",
|
||||
"X-Title": "Yargi MCP Server",
|
||||
}
|
||||
)
|
||||
|
||||
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 batch API call.
|
||||
|
||||
Args:
|
||||
documents: List of document texts
|
||||
titles: Optional list of document titles
|
||||
|
||||
Returns:
|
||||
Numpy array of embeddings (N x 3072 dimensions)
|
||||
"""
|
||||
if not documents:
|
||||
return np.array([])
|
||||
|
||||
# Apply document prompt template
|
||||
texts = []
|
||||
for i, doc in enumerate(documents):
|
||||
title = titles[i] if titles and i < len(titles) else "none"
|
||||
text = f"title: {title} | text: {doc}"
|
||||
texts.append(text)
|
||||
|
||||
try:
|
||||
response = self.client.embeddings.create(
|
||||
model=self.model,
|
||||
input=texts,
|
||||
encoding_format="float",
|
||||
extra_headers={
|
||||
"HTTP-Referer": "https://yargimcp.com",
|
||||
"X-Title": "Yargi MCP Server",
|
||||
}
|
||||
)
|
||||
|
||||
# Extract embeddings in order
|
||||
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 (3072,)
|
||||
document_embeddings: Document embeddings (N x 3072)
|
||||
|
||||
Returns:
|
||||
Similarity scores (N,)
|
||||
"""
|
||||
# Ensure query is 2D for matrix multiplication
|
||||
if len(query_embedding.shape) == 1:
|
||||
query_embedding = query_embedding.reshape(1, -1)
|
||||
|
||||
# Compute cosine similarity (embeddings are already normalized)
|
||||
similarities = np.dot(document_embeddings, query_embedding.T).squeeze()
|
||||
|
||||
return similarities
|
||||
@@ -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
|
||||
@@ -1,16 +1,14 @@
|
||||
# uyusmazlik_mcp_module/client.py
|
||||
|
||||
import httpx
|
||||
import aiohttp
|
||||
from bs4 import BeautifulSoup
|
||||
from typing import Dict, Any, List, Optional, Union, Tuple
|
||||
import logging
|
||||
import html
|
||||
import re
|
||||
import tempfile
|
||||
import os
|
||||
import io
|
||||
from markitdown import MarkItDown
|
||||
from urllib.parse import urljoin, urlencode # urlencode for aiohttp form data
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from .models import (
|
||||
UyusmazlikSearchRequest,
|
||||
@@ -57,17 +55,21 @@ class UyusmazlikApiClient:
|
||||
# Individual documents are fetched by their full URLs obtained from search results.
|
||||
|
||||
def __init__(self, request_timeout: float = 30.0):
|
||||
self.request_timeout = request_timeout # Store timeout for aiohttp and httpx
|
||||
# Headers for aiohttp search. httpx for docs will create its own.
|
||||
self.default_aiohttp_search_headers = {
|
||||
"Accept": "*/*", # Mimicking browser headers provided by user
|
||||
"Accept-Encoding": "gzip, deflate, br, zstd",
|
||||
"Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Origin": self.BASE_URL,
|
||||
"Referer": self.BASE_URL + "/",
|
||||
|
||||
}
|
||||
self.request_timeout = request_timeout
|
||||
# Create shared httpx client for all requests
|
||||
self.http_client = httpx.AsyncClient(
|
||||
base_url=self.BASE_URL,
|
||||
headers={
|
||||
"Accept": "*/*",
|
||||
"Accept-Encoding": "gzip, deflate, br, zstd",
|
||||
"Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Origin": self.BASE_URL,
|
||||
"Referer": self.BASE_URL + "/",
|
||||
},
|
||||
timeout=request_timeout,
|
||||
verify=False
|
||||
)
|
||||
|
||||
|
||||
async def search_decisions(
|
||||
@@ -108,32 +110,36 @@ class UyusmazlikApiClient:
|
||||
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')
|
||||
# Convert form data to dict for httpx
|
||||
form_data_dict = {}
|
||||
for key, value in form_data_list:
|
||||
if key in form_data_dict:
|
||||
# Handle multiple values (like KararSonucuList)
|
||||
if not isinstance(form_data_dict[key], list):
|
||||
form_data_dict[key] = [form_data_dict[key]]
|
||||
form_data_dict[key].append(value)
|
||||
else:
|
||||
form_data_dict[key] = value
|
||||
|
||||
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"
|
||||
logger.info(f"UyusmazlikApiClient (httpx): Performing search to {self.SEARCH_ENDPOINT} with form_data: {form_data_dict}")
|
||||
|
||||
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.")
|
||||
# Use shared httpx client
|
||||
response = await self.http_client.post(
|
||||
self.SEARCH_ENDPOINT,
|
||||
data=form_data_dict,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
html_content = response.text
|
||||
logger.debug("UyusmazlikApiClient (httpx): Received HTML response for search.")
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"UyusmazlikApiClient (aiohttp): HTTP client error during search: {e}")
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"UyusmazlikApiClient (httpx): 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}")
|
||||
logger.error(f"UyusmazlikApiClient (httpx): Error processing search request: {e}")
|
||||
raise
|
||||
|
||||
# --- HTML Parsing (remains the same as previous version) ---
|
||||
@@ -196,21 +202,18 @@ class UyusmazlikApiClient:
|
||||
html_input_for_markdown = processed_html
|
||||
|
||||
markdown_text = None
|
||||
temp_file_path = None
|
||||
try:
|
||||
md_converter = MarkItDown()
|
||||
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
|
||||
# Convert HTML string to bytes and create BytesIO stream
|
||||
html_bytes = html_input_for_markdown.encode('utf-8')
|
||||
html_stream = io.BytesIO(html_bytes)
|
||||
|
||||
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("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:
|
||||
@@ -221,7 +224,6 @@ class UyusmazlikApiClient:
|
||||
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
|
||||
@@ -240,5 +242,9 @@ class UyusmazlikApiClient:
|
||||
raise
|
||||
|
||||
async def close_client_session(self):
|
||||
|
||||
logger.info("UyusmazlikApiClient: No persistent client session from __init__ to close.")
|
||||
"""Close the shared httpx client session."""
|
||||
if hasattr(self, 'http_client') and self.http_client:
|
||||
await self.http_client.aclose()
|
||||
logger.info("UyusmazlikApiClient: HTTP client session closed.")
|
||||
else:
|
||||
logger.info("UyusmazlikApiClient: No persistent client session from __init__ to close.")
|
||||
@@ -28,41 +28,41 @@ class UyusmazlikKararSonucuEnum(str, Enum): # Based on checkbox text in the form
|
||||
|
||||
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).")
|
||||
icerik: Optional[str] = Field("", description="Search text")
|
||||
|
||||
bolum: Optional[UyusmazlikBolumEnum] = Field(
|
||||
UyusmazlikBolumEnum.TUMU,
|
||||
description="Select the department (Bölüm)."
|
||||
description="Department"
|
||||
)
|
||||
uyusmazlik_turu: Optional[UyusmazlikTuruEnum] = Field(
|
||||
UyusmazlikTuruEnum.TUMU,
|
||||
description="Select the type of dispute (Uyuşmazlık)."
|
||||
description="Dispute type"
|
||||
)
|
||||
|
||||
# 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."
|
||||
description="Decision 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').")
|
||||
esas_yil: Optional[str] = Field("", description="Case year")
|
||||
esas_sayisi: Optional[str] = Field("", description="Case no")
|
||||
karar_yil: Optional[str] = Field("", description="Decision year")
|
||||
karar_sayisi: Optional[str] = Field("", description="Decision no")
|
||||
kanun_no: Optional[str] = Field("", description="Law no")
|
||||
|
||||
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').")
|
||||
karar_date_begin: Optional[str] = Field("", description="Start date (DD.MM.YYYY)")
|
||||
karar_date_end: Optional[str] = Field("", description="End date (DD.MM.YYYY)")
|
||||
|
||||
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').")
|
||||
resmi_gazete_sayi: Optional[str] = Field("", description="Gazette no")
|
||||
resmi_gazete_date: Optional[str] = Field("", description="Gazette date (DD.MM.YYYY)")
|
||||
|
||||
# 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').")
|
||||
tumce: Optional[str] = Field("", description="Exact phrase")
|
||||
wild_card: Optional[str] = Field("", description="Wildcard search")
|
||||
hepsi: Optional[str] = Field("", description="All words")
|
||||
herhangi_birisi: Optional[str] = Field("", description="Any word")
|
||||
not_hepsi: Optional[str] = Field("", description="Exclude words")
|
||||
|
||||
class UyusmazlikApiDecisionEntry(BaseModel):
|
||||
"""Model for an individual decision entry parsed from Uyuşmazlık API's HTML search response."""
|
||||
@@ -71,9 +71,9 @@ class UyusmazlikApiDecisionEntry(BaseModel):
|
||||
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.")
|
||||
popover_content: Optional[str] = Field(None, description="Summary")
|
||||
document_url: HttpUrl # Full URL to the decision document HTML page
|
||||
pdf_url: Optional[HttpUrl] = Field(None, description="Direct URL to PDF if available.")
|
||||
pdf_url: Optional[HttpUrl] = Field(None, description="PDF URL")
|
||||
|
||||
class UyusmazlikSearchResponse(BaseModel): # This is what the MCP tool will return
|
||||
"""Response model for Uyuşmazlık Mahkemesi search results for the MCP tool."""
|
||||
|
||||
@@ -6,8 +6,7 @@ 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 +65,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,25 +120,20 @@ class YargitayOfficialApiClient:
|
||||
html_to_convert = processed_html
|
||||
|
||||
markdown_output = None
|
||||
temp_file_path = None
|
||||
try:
|
||||
md_converter = MarkItDown() # 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
|
||||
|
||||
|
||||
@@ -34,111 +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 with advanced operators support:
|
||||
• Simple words: 'arsa payı' (OR logic - finds documents with ANY word)
|
||||
• Exact phrases: '"arsa payı"' (finds exact phrase)
|
||||
• AND logic: 'arsa+payı' (both words required)
|
||||
• Wildcards: 'bozma*' (matches bozma, bozması, bozmanın, etc.)
|
||||
• Multiple required: '+"arsa payı" +"bozma sebebi"'
|
||||
• Exclusion: '+"arsa payı" -"inşaat sözleşmesi"'
|
||||
Examples: arsa payı | "arsa payı" | +"mülkiyet hakkı" +"bozma sebebi" | hukuk*""")
|
||||
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="""
|
||||
Court of Cassation (Yargıtay) chamber/board selection. Options include:
|
||||
- 'ALL' for all chambers
|
||||
- Civil: 'Civil General Assembly (Hukuk Genel Kurulu)', '1st Civil Chamber (1. Hukuk Dairesi)' through '23rd Civil Chamber (23. Hukuk Dairesi)', 'Civil Chambers Presidents Board (Hukuk Daireleri Başkanlar Kurulu)'
|
||||
- Criminal: 'Criminal General Assembly (Ceza Genel Kurulu)', '1st Criminal Chamber (1. Ceza Dairesi)' through '23rd Criminal Chamber (23. Ceza Dairesi)', 'Criminal Chambers Presidents Board (Ceza Daireleri Başkanlar Kurulu)'
|
||||
- General: 'Grand General Assembly (Büyük Genel Kurulu)'
|
||||
Total: 52 possible values (including 'ALL' for all chambers)
|
||||
""")
|
||||
birimYrgHukukDaire: Optional[str] = Field("", description="Legacy field - use birimYrgKurulDaire instead for chamber selection")
|
||||
birimYrgCezaDaire: Optional[str] = Field("", description="Legacy field - use birimYrgKurulDaire instead for chamber selection")
|
||||
birimYrgKurulDaire: Optional[str] = Field("ALL", description="Chamber (ALL or specific chamber name)")
|
||||
|
||||
esasYil: Optional[str] = Field("", description="""Case year for 'Esas No' filtering.
|
||||
Format: YYYY (e.g., '2024')
|
||||
Use with sequence numbers for precise case targeting""")
|
||||
esasIlkSiraNo: Optional[str] = Field("", description="""Starting sequence number for 'Esas No' range filtering.
|
||||
Format: numeric string (e.g., '1', '100')
|
||||
Use with esasSonSiraNo for range: cases 100-200 in specified year""")
|
||||
esasSonSiraNo: Optional[str] = Field("", description="""Ending sequence number for 'Esas No' range filtering.
|
||||
Format: numeric string (e.g., '500', '1000')
|
||||
Creates range from esasIlkSiraNo to this number""")
|
||||
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' filtering.
|
||||
Format: YYYY (e.g., '2024')
|
||||
Filters decisions by the year they were issued""")
|
||||
kararIlkSiraNo: Optional[str] = Field("", description="""Starting sequence number for 'Karar No' range filtering.
|
||||
Format: numeric string (e.g., '1', '50')
|
||||
Use with kararSonSiraNo for decision number ranges""")
|
||||
kararSonSiraNo: Optional[str] = Field("", description="""Ending sequence number for 'Karar No' range filtering.
|
||||
Format: numeric string (e.g., '100', '500')
|
||||
Creates range from kararIlkSiraNo to this number""")
|
||||
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.
|
||||
Format: DD.MM.YYYY (e.g., '01.01.2024')
|
||||
Use with bitisTarihi for date range filtering
|
||||
Examples: '01.01.2024', '15.06.2023'""")
|
||||
bitisTarihi: Optional[str] = Field("", description="""End date for decision search.
|
||||
Format: DD.MM.YYYY (e.g., '31.12.2024')
|
||||
Creates date range from baslangicTarihi to this date
|
||||
Examples: '31.12.2024', '30.06.2023'""")
|
||||
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 for search results:
|
||||
• '1': Esas No (Case Number) - sorts by case registration order
|
||||
• '2': Karar No (Decision Number) - sorts by decision issuance order
|
||||
• '3': Karar Tarihi (Decision Date) - sorts by chronological order [DEFAULT]
|
||||
Recommended: Use '3' for most recent decisions first""")
|
||||
siralamaDirection: Optional[str] = Field("desc", description="""Sorting direction for results:
|
||||
• 'desc': Descending order (newest/highest first) [DEFAULT]
|
||||
• 'asc': Ascending order (oldest/lowest first)
|
||||
Most common: 'desc' for latest decisions first""")
|
||||
|
||||
pageSize: int = Field(10, ge=1, le=100, description="""Number of results per page.
|
||||
Range: 1-100 results per page
|
||||
Recommended: 10-50 for balanced performance and coverage
|
||||
Large values (50-100) for comprehensive analysis""")
|
||||
pageNumber: int = Field(1, ge=1, description="""Page number to retrieve (1-indexed).
|
||||
Start with 1 for first page
|
||||
Use with pageSize to navigate through large result sets
|
||||
Example: pageSize=50, pageNumber=3 gets results 101-150""")
|
||||
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 (Daire) 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 (Karar Tarihi).")
|
||||
arananKelime: Optional[str] = Field(None, alias="arananKelime", description="Matched keyword (Aranan Kelime) 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 (Belge URL) to the decision document.")
|
||||
document_url: Optional[HttpUrl] = Field(None, description="Document URL")
|
||||
|
||||
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."""
|
||||
id: str = Field(..., description="The unique ID (Belge Kimliği) of the document.")
|
||||
markdown_content: Optional[str] = Field(None, description="The decision content (Karar İçeriği) converted to Markdown.")
|
||||
source_url: HttpUrl = Field(..., description="The source URL (Kaynak 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