İçeriğe atla
Tüm Yazılar
Mimari
18.07.2026
8 dk okuma

Kamu Evrak Akışı için 11 Ajan: Tek LLM Çağrısı Yerine Neden Orkestrasyon Seçtik

İçindekiler

TEKNOFEST 2026 Yapay Zeka Dil Ajanları Yarışması'nın 1. senaryosu, kamu kurumlarındaki evrak ve resmî yazışma süreçleri için bir "akıllı agent destek sistemi" istiyor. AGENTRA TECH ekibiyle geliştirdiğimiz sistem 11 uzman ajan ve saf Python bir orkestratörden oluşuyor; depo Apache-2.0 ile açık: teknofest-2026-kamu-evrak-akilli-ajan.

Bu yazı bir tanıtım değil. Mimaride aldığımız kararları, karşılığında neyi feda ettiğimizi ve sistemin nerede kırıldığını anlatıyorum. Kod parçalarının hepsi depodan; kısalttım ve yorumladım ama uydurmadım.


Problem: evrak akışı tek bir "anla" işi değil#

Bir kuruma gelen dilekçe şu zinciri geçer: oku ve türlendir → içinden bilgi çıkar → eksikleri tespit et → ilgili mevzuatı bul → aciliyeti ve yasal süreyi hesapla → özetle → paylaşılacaksa kişisel veriyi maskele → cevap yazısı taslağını üret → doğru birime havale et → başvurana durumu bildir.

Bu zincirin dört can sıkıcı özelliği var. Çok adımlı ve tekrarlı — her adım bir öncekinin çıktısına bağlı. Süre baskısı altında — 4982 sayılı Bilgi Edinme Kanunu, 3071 sayılı Dilekçe Hakkı Kanunu, 2577 sayılı İYUK ve CİMER akışları farklı yasal süreler dayatır; kaçırılırsa hak kaybı doğar. Kişiye bağımlı — aynı evrak farklı personelde farklı yorumlanır. KVKK riskli — kişisel veri içeren evrak paylaşılırken maskeleme genelde elle yapılır.

Bir de yarışma şartnamesinin ve kamu gerçeğinin dayattığı bir kısıt var: kurum ağları çoğu zaman internetsizdir ve vatandaşın kişisel verisi üçüncü taraf bir API'ye çıkamaz. Bu kısıt bütün mimariyi belirledi.


Karar: neden tek LLM çağrısı değil#

En kısa yol belliydi: evrak metnini büyük bir modele ver, JSON şeması dayat, on iki alanı birden doldurt. Bunu yapmadık. Üç sebeple:

1. LLM'in olmadığı bir dünyada da çalışmak zorunda. Sistem çevrimdışı-öncelikli. Çekirdek requirements.txt içinde ne LangChain, ne LangGraph, ne OpenAI SDK, ne torch var — sadece pypdf, streamlit, pandas, altair, rich, pytest ve pydantic. LLM erişimi bile stdlib urllib ile yazıldı; OpenAI-uyumlu bir uç ya da yerel Ollama bulunursa devreye giriyor, bulunmazsa hiçbir şey kırılmıyor. Tek LLM çağrısı mimarisinde bu imkânsızdı: model yoksa sistem yok.

2. Güvenin adım adım ölçülebilir olması gerekiyor. Tek çağrı tek bir güven skoru verir. Bizde sınıflandırmanın güveni ayrı, birim yönlendirmenin güveni ayrı ölçülüyor ve ikisi ayrı ayrı insan onayına düşebiliyor. Kamu senaryosunda "hangi kararından emin değilsin?" sorusunun cevabı "genel olarak %70 eminim" olamaz.

3. Akışın durabilmesi gerekiyor. Boş bir taranmış sayfa geldiğinde doğru davranış, sınıflandırma uydurmak değil, süreci kesip insana devretmektir. Bunu bir prompt talimatı olarak değil, akışın yapısı olarak kodlamak istedik.

Karşılığında neyi kaybettik: esneklik. On bir ajanın her biri kendi kural setini taşıyor ve yeni bir evrak türü eklemek tek bir prompt satırı değil, birkaç dosyada koordineli değişiklik demek. Küçük bir alanda (8+1 evrak türü, 9 birim, 15 mevzuat belgesi) bu takas kârlı; alan genişledikçe bakım maliyeti artar.


Orkestratör: paylaşılan durum ve üç koşullu kapı#

Ajanlar birbirini doğrudan çağırmıyor. Hepsi AgentState adlı tek bir dataclass'ı okuyup zenginleştiriyor; orkestratör de bu durum üzerinde koşullu bir akış yürütüyor.

text
  TXT / PDF / Goruntu
          |
          v
  (0) OCR / Metin Okuma
          |
   +------+------------------------------+
   | KAPI 1: >= 30 anlamli karakter?     |-- hayir --> surec durur
   +------+------------------------------+             tur = "bilinmiyor"
          | evet                                       insan onayi istenir
   +------+------------------------------+
   | KAPI 2: metin Turkce mi?            |-- hayir --> taslak ATLANIR
   +------+------------------------------+             (analiz surer)
          | evet
          v
  GOREV 1  (1) Siniflandirma --[KAPI 3a: guven < 0.6 -> insan onayi]
           (2) Bilgi Cikarim
           (3) Eksik Bilgi Tespiti
           (4) Mevzuat RAG
           (5) Triage / yasal sure
           (6) Ozet
           (7) KVKK Maskeleme
          |
  GOREV 2  (8) Taslak + Format Denetimi
           (9) Yonlendirme --[KAPI 3b: guven < 0.6 -> insan onayi]
          (10) Kullanici Bilgilendirme
          |
          v
  25+ anahtarli sonuc sozlugu + adim sureleri + guven izi

Kapılar prompt'ta değil, kodda. Eşikler tek bir yerde tanımlı:

python
# src/agents/orchestrator.py — kosullu kapi esikleri
_MIN_ANLAMLI_KARAKTER = 30        # Kapi 1: altinda metin "okunamaz" sayilir
_INSAN_ONAYI_GUVEN_ESIGI = 0.6    # Kapi 3a/3b: altinda insan onayi
_MAX_GIRDI_KARAKTER = 200_000     # DoS siniri (CWE-400 / OWASP LLM04)

def _metin_okunabilir_mi(self) -> bool:
    """Anlamli karakter = harf veya rakam; bosluk/noktalama sayilmaz."""
    anlamli = sum(1 for ch in self.state.raw_text if ch.isalnum())
    return anlamli >= _MIN_ANLAMLI_KARAKTER

def _run_workflow(self, mode: str) -> dict:
    self._apply_girdi_siniri()
    try:
        # KAPI 1 — bos/cok kisa metinde uydurma cikti uretilmez
        metin_okunabilir = self._metin_okunabilir_mi()
        if not metin_okunabilir:
            self._uygula_bos_metin_kapisi(mode)

        # KAPI 2 — yalnizca okunabilir metinde anlamli
        metin_turkce = self._metin_turkce_mi() if metin_okunabilir else True

        if mode in ("full", "classify") and metin_okunabilir:
            self._run_step("classification", "Evrak sınıflandırma")
            self._record_confidence("classification",
                                    self.state.classification.get("guven"))
            self._degerlendir_siniflandirma_guveni()      # KAPI 3a
            self._run_step("info_extraction", "Bilgi çıkarma")
            self._run_step("legislation", "Mevzuat eşleştirme")
            # ... triage, summarization, anonimlestirme

        if mode in ("full", "draft"):
            if metin_okunabilir and metin_turkce:
                self._run_step("draft_writer", "Yazı taslağı oluşturma")
            elif metin_okunabilir:
                self._skip_step("draft_writer", "Yazı taslağı oluşturma",
                                "evrak dili Türkçe görünmüyor")
            # ... routing (KAPI 3b), user_info
    except Exception as e:
        self.state.errors.append(str(e))
    return self._compile_results()

Atlanan adımlar sessizce kaybolmuyor: _skip_step her atlamayı gerekçesiyle processing_steps listesine yazıyor, _run_step ise her adımı time.perf_counter() ile ölçüyor. Çıktı sözlüğünde hangi adımın çalıştığı, ne kadar sürdüğü ve neden atlandığı görünür.


Kütüphane seçimleri: çekirdekte ağır bağımlılık yok#

Mevzuat arama katmanının çekirdeği saf Python BM25-Okapi. rank_bm25 ya da sklearn kurmadık; 100 satırlık bir dosya yeterliydi:

python
# src/utils/bm25.py — IDF: negatif olmayan varyant
self.idf = {
    token: math.log((self.corpus_size - n + 0.5) / (n + 0.5) + 1.0)
    for token, n in df.items()
}

def get_scores(self, query_tokens):
    scores = [0.0] * self.corpus_size
    for token in query_tokens:
        idf = self.idf.get(token)
        if idf is None:
            continue
        for i, freqs in enumerate(self.doc_freqs):
            tf = freqs.get(token, 0)
            if tf == 0:
                continue
            norm = self.k1 * (1 - self.b + self.b * self.doc_lens[i] / self.avgdl)
            scores[i] += idf * tf * (self.k1 + 1) / (tf + norm)
    return scores

Tokenizasyon Türkçeye özel: turkish_lower ile küçük harfe indiriyor (i/ı sorunu), şapkalı ünlüleri (malî, resmî) token deseninde tutuyor, durak kelimeleri atıyor.

Sınıflandırma da aynı felsefeyle: TF-IDF + karakter 3-gram özellikli, saf Python Multinomial Naive Bayes. Kural skorlaması ile ağırlıklı ortalama alıyor:

python
# src/agents/classification_agent.py
_ENSEMBLE_KURAL_AGIRLIGI = 0.6   # yonetmelik temelli yapisal capalar
_ENSEMBLE_ML_AGIRLIGI = 0.4      # kucuk korpusta ogrenilmis sozcuksel oruntuler

birlesik = {
    tur: (_ENSEMBLE_KURAL_AGIRLIGI * kural_olasiliklar.get(tur, 0.0)
          + _ENSEMBLE_ML_AGIRLIGI * ml_olasiliklar.get(tur, 0.0))
    for tur in EVRAK_TURLERI
}

Ağırlıkların gerekçesi kod yorumunda yazılı: kural katmanı Resmî Yazışma Yönetmeliği'ne dayalı yapısal çapaları (T.C. başlığı, İlgi/Sayı blokları, hitap kalıpları) kodladığı için görülmemiş belgelerde de geçerli, bu yüzden çoğunluk ağırlığı taşıyor. NB modeli 52 evrakla eğitildi — yüksek varyans, azınlık ağırlığı. Aritmetik ortalama seçtik çünkü hiçbir bileşen nihai skora kendi ağırlığından fazla katkı veremiyor; 1.0 olasılık üreten aşırı güvenli bir model kararı tek başına belirleyemiyor.

Opsiyonel katman olarak turkish-e5-large semantik arama ve bge-reranker-v2-m3 yeniden sıralama var, ikisi de sentence-transformers üzerinden ve varsayılan kapalı — ilk açılışta model indirmek offline-first sözünü bozar.


Takas: mutlak benzerlik mi, rölatif mi#

RAG demolarının çoğu skorları en iyi sonuca bölerek normalize eder. Sonuç her zaman "%98 benzerlik" gösterir, eşleşme ne kadar zayıf olursa olsun. Biz bunu yapamadık, çünkü taslak ajanı bu skoru yasal dayanak atfı için eşikle süzüyor: gövdede yalnızca benzerliği ≥0.6 olan mevzuat maddesine atıf yapılıyor. Şişirilmiş bir skor doğrudan yanlış madde alıntısına dönüşür.

Bu yüzden benzerlik mutlak bir doygunluk noktasına oranlanıyor: min(1, agirlikli_skor / (1.5 × toplam_idf)). Katsayı 1.5'in gerekçesi BM25 teorisinden geliyor — ortalama uzunluktaki bir bölümde tek geçiş (tf=1) tam olarak idf katkısı verir, katkı (k1+1)=2.5·idf doygunluğuna yaklaşır. 1.5 katsayısı "tam benzerlik" tanımını sorgu sözcüklerinin bölümde merkezî (tf≈2-3) kullanılmasına bağlıyor. En iyi eşleşme bile 0.5'in altındaysa sonuç listesi açıkça "zayıf eşleşme" işaretiyle dönüyor ve taslakta hiçbir madde numarası uydurulmuyor — "ilgili mevzuat hükümleri" ifadesi kullanılıyor.

Bunun bedeli şu: arayüzde skorlar mütevazı görünüyor. 0.83 benzerlik, rölatif normalizasyonda 1.00 diye gösterilebilirdi. Dürüst ölçek, pazarlama açısından kötü bir karar; savunulabilirlik açısından tek doğru karar.


Nerede tıkandık: adversarial v3#

Geliştirme setinde (52 kurgu evrak) her şey iyi görünüyordu. Sonra geliştirmede hiç bakılmamış, zorlayıcı bir held-out set hazırladık: bozuk sayı bloğu, kopuk İlgi zinciri, rakamsız sözel tarih, KVKK-yoğun ama "kişisel veri" sözcüğü hiç geçmeyen evraklar. Eksik bilgi tespiti micro-F1'i 0.667'ye düştü, mevzuat isabet@3 0.875'e indi.

Üç ayrı kök neden vardı ve üçü de "modeli biraz daha eğit" ile çözülemezdi.

Kopuk İlgi zinciri. Ajan, gövde metnindeki "İlgi (b)'de kayıtlı yazınız" gibi düz cümle atıflarını İlgi alan etiketi sanıyordu; dolayısıyla var olmayan bir İlgi bloğunu "var" görüyor ve eksik alan raporlamıyordu. Çözüm yapısal: resmî yazışmada İlgi bir alan başlığı olarak daima iki nokta ile yazılır.

python
# src/agents/info_extraction_agent.py
# Iki nokta ZORUNLU. Govde metnindeki "Ilgi (b)'de kayitli yaziniz",
# "Ilgi yazi ile", "Ilgili eylem plani..." gibi duz cumle atiflari alan
# etiketi DEGILDIR ve Ilgi blogu sayilmaz (kopuk zincirin yapisal tespiti).
_ILGI_SATIRI = re.compile(r"^\s*[İI]lgi\s*:\s*(.*)$")
_ILGI_MADDE  = re.compile(r"^\s*([a-zçğıöşü])\)\s*(.+)$")

Sözel tarih. Rakamsal tarih içermeyen belgelerde ("Haziran ayının on beşinci günü") evrak tarihi hiç çözülemiyordu, dolayısıyla yasal son işlem tarihi de hesaplanamıyordu. Bir sözel tarih çözücü eklendi.

KVKK'nın sözcüksüz hali. 6698 sayılı Kanun, metinde "kişisel" ya da "kvkk" sözcüğü geçmese bile belge doğrulanmış bir T.C. kimlik numarası veya IBAN içeriyorsa uygulanır. BM25 tema aktifleşmesi tetikleyici sözcüklere bağlı olduğu için bu evraklarda 6698 hiç önerilmiyordu. Çözüm bir "veri-sinyali köprüsü": bilgi çıkarım ajanının doğruladığı TCKN/IBAN varlığı, doğrudan mevzuat önerisine bağlanıyor. Ama enjekte edilen öneri metinsel eşleşme iddia etmiyor — benzerliği 0.55 olarak, yani taslak atıf eşiğinin (0.6) altında raporlanıyor ve eklenme_nedeni="kvkk_veri_sinyali" etiketiyle işaretleniyor. Yani öneri listesinde görünüyor, taslakta alıntı olarak zorlanmıyor.

Üç düzeltmenin de kritik kısıtı şuydu: dosyaya özel ezber yasak. Held-out setteki bir evrağa özel kural yazarsan set held-out niteliğini kaybeder. Düzeltmeler genel kurallar olarak yazıldı, geliştirme ve önceki held-out setlerinde sıfır regresyon doğrulandı, sonra hiç dokunulmamış yeni bir adversarial set (v4) ile temiz ölçüldü.


Eşik kalibrasyonu: 0.5 mi 0.15 mi#

Mevzuat ajanında bir "düzeltici arama döngüsü" var: ilk aramanın en iyi benzerliği bir eşiğin altındaysa sorgu, evrak türünün usul söz dağarcığıyla genişletilip bir kez daha aranıyor.

python
# src/agents/legislation_agent.py
ilk_benzerlik = matches[0]["benzerlik"] if matches else 0.0
genisletme = TUR_SORGU_GENISLETME.get(evrak_turu, [])
if ilk_benzerlik < DUZELTME_ESIGI and genisletme:
    genis_sorgu = f"{query_text} {' '.join(genisletme)}"
    yeni_matches, yeni_yontem = self._hibrit_ara(
        genis_sorgu, evrak_turu, duzeltilmis=True
    )
    yeni_benzerlik = yeni_matches[0]["benzerlik"] if yeni_matches else 0.0
    if yeni_benzerlik > ilk_benzerlik:      # YALNIZCA iyilesirse benimse
        matches, yontem = yeni_matches, yeni_yontem

İlk sezgim eşiği zayıf-eşleşme işaretiyle aynı yere, 0.5'e koymaktı. Ölçüm bunu çürüttü: 0.5'te döngü geliştirme setindeki 35 evrağın 33'ünde ateşleniyor, eklenen usul terimleri alan mevzuatını ilk üçten itiyor ve isabet@3 0.943'ten 0.914'e düşüyordu. 0.15'te isabet 0.943'te kalıyor ve döngü yalnızca 2 sınır evrakta çalışıyordu. Eşik 0.15'e indi.

Buradan öğrendiğim şey: "sistem emin değilse daha çok çalış" sezgisi tek başına yanlış yönlü. Kurtarma mekanizmasının ne zaman devreye girmemesi gerektiğini de kalibre etmek zorundasın, yoksa iyi çalışan çoğunluğu bozarsın. Zayıf-eşleşme işareti (0.5) ile düzeltme tetiği (0.15) bilinçli olarak ayrı iki sayı — biri şeffaflık için, diğeri güvenlik ağı için.


Ölçüm: sayılar ne söylüyor, ne söylemiyor#

Deponun data/processed/eval_report*.json dosyaları scripts/evaluate.py tarafından üretiliyor ve her rapora git commit SHA, platform, requirements hash ve veri seti içerik hash'i gömülüyor. Dokunulmamış v4 adversarial setinde (16 evrak, tamamen çevrimdışı mod, llm.kullanilabilir: false) ölçülen değerler: sınıflandırma doğruluğu 0.938, macro-F1 0.933, birim yönlendirme 0.938, eksik bilgi micro-F1 1.000, mevzuat isabet@3 0.938.

Bu sayıların ne olduğu konusunda net olmak gerekiyor:

  • Veri sentetik. Gerçek kamu verisi kullanılmadı (şartname yasağı ve KVKK). Kurgu TCKN'ler resmî checksum'ı geçiyor ama gerçek kişiye ait değil.
  • N küçük. Held-out setler 16 evrak; her sınıfta 2 örnek. 0.938 doğruluk, 16'da 15 demek. Tek bir hata metriği 6 puan oynatıyor.
  • Bizim ölçümümüz. Bunlar yarışma jürisinin değerlendirmesi değil, kendi harness'imizin çıktısı. Yarışmanın ön değerlendirme sunumu 12 Temmuz 2026'da yapıldı; sonuç, puan veya derece bilgisi depoda kayıtlı DEĞİL. Bu sayfadaki hiçbir sayı yarışma değerlendirmesi değil.
  • Kırılan yer görünür. v4'te tek hata ust_yazi sınıfında: iki örnekten biri cevap_yazisi sanıldı (recall 0.5). Confusion matrisi raporda duruyor, gizlenmiyor.

Test tarafında 16.07.2026 itibarıyla ölçülen pytest tests/ çıktısı 632 test / 42 modül (biri reportlab kurulumuna bağlı olduğu için atlanıyor). CI her push'ta Python 3.9 ve 3.12 matrisinde derleme + testler + 5 evraklık hızlı değerlendirme koşuyor. Performans için depodaki benchmark raporu tek çekirdek çevrimdışı koşumda ~88 evrak/saniye ve 11.3 ms medyan gecikme bildiriyor — bu sayılar kural tabanlı yola ait; LLM devredeyse gecikme tamamen sağlayıcıya bağlı olur.


Sınırlar ve şu an durduğu yer#

Canlı bir demo yayınlamıyoruz; sistem yerel çalışıyor. Windows'ta calistir.bat, Linux/macOS'ta ./baslat.sh sanal ortamı kurup Streamlit panosunu açıyor; python -m src.api stdlib http.server üzerine kurulu sıfır-bağımlılıklı JSON API'yi, python -m src.mcp_server ise Model Context Protocol sunucusunu ayağa kaldırıyor — böylece sistem başka bir ajanın "aracı" olarak çağrılabiliyor. python:3.12-slim tabanlı bir Dockerfile da var.

Bildiğim sınırlar: mevzuat korpusu 15 belge — gerçek bir kurumun ihtiyacının çok altında; Standart Dosya Planı entegrasyonu henüz bir not düzeyinde; EBYS entegrasyonu vizyon aşamasında, e-Yazışma üstverisi taslak olarak üretiliyor ama canlı bir sistemle test edilmedi. Dinî bayramlar yasal süre takvimine dahil değil, bu yüzden son işlem tarihi ihtiyatlı bir "en geç" tahmini.

Bu projeden çıkardığım en genel ders mimari değil, ölçüm disiplini: bir sistemin nerede emin olmadığını görünür kılmak, doğruluk oranını bir puan yükseltmekten daha değerli. Üç koşullu kapı, mutlak benzerlik ölçeği ve insan onayı kuyruğu aynı fikrin üç ayrı yerde tekrarı.


Kaynaklar#

Paylaş
Birlikte bir şeyler yapalım
Proje, iş birliği ya da sadece bir fikir — mesajına açığım. En kısa sürede dönüş yaparım.
Tüm YazılarMuhammed Sina Gün