Özgür Işık Damar
Yazılara dön

12 dk okuma

Boş bir harita ve şiirsel bir prompt: LLM'in gökyüzünü uydurmasının önüne geçmek

Düzeltmeden önce astroloji uygulamamın kurabildiği her solar return prompt'u boş bir harita taşıyor ve yine de şiir istiyordu. Efemeris ile LLM arasındaki çizgiyi nasıl koda taşıdığım ve henüz taşıyamadığım yerler.

Yazar: Özgür Işık DamarKıdemli yazılım mühendisi · Türkiye

Ben düzeltene kadar astroloji uygulamam, kim sorarsa sorsun her solar return prompt'una aynı haritayı koyuyordu: yükselen yok, Ay yok. Prompt yine de şiir istiyordu. Solar return (Güneş dönüşü), Güneş'in doğduğun andaki derecesine tam olarak geri döndüğü anın haritasıdır; istek ise haritaya dair yalnızca şunu taşıyordu:

Solar Return Haritası Bilgileri:
Solar Return (Güneş Dönüşü) Yılı: 2026
Yükselen: Bilinmiyor
Ay:

Yükselen bilinmiyor, Ay satırının ardından da hiçbir şey gelmiyor. Sistem mesajı modelden kullanıcının o yılki temalarını "yükseleninin Bilinmiyor ve Ay burcunun" (ardından bir boşluk) "olması üzerinden mistik ama anlaşılır bir dille" açıklamasını istiyor, sonra bir de ekliyordu: "Şiirsel bir üslup kullan." Bütün istekteki tek somut yerleşimler en alttaki JSON format örneğinde duruyordu: "Yükselen Boğa", "Ay İkizler".

Bu yazı için o isteği düzeltme öncesi koddan, requirements dosyasının sabitlediği sürüm olan Kerykeion 5.12.8 ile yeniden kurdum ve göndermek yerine ekrana yazdırdım. Varsayılan yapılandırmada bir model yerine placeholder bir sağlayıcı cevap veriyor; cevabı JSON değil, parser pes etti ve endpoint şunu döndü:

POST /api/v1/advanced-astrology/solar-return
500 {"detail":"Kadim yıldızların fısıltıları şu an çok karmaşık. Lütfen tekrar deneyin."}

Oysa yıldızların fısıltılarında bir karmaşa yoktu. Harita boştu ve efemeris ile prompt arasındaki hiçbir şey bunu kontrol etmemişti. O prompt'u gerçek bir modelden uzak tutan şey, varsayılan placeholder ile model çağırmasına izin verilen özelliklerin allowlist'iydi; ikisi de haritaya hiç bakmıyordu.

Universal AI Astro, FastAPI backend'li Flutter uygulamam: doğum haritaları, solar ve lunar return'ler, drakonik haritalar, horary sorular, numeroloji. Haritaları sunucuda Kerykeion hesaplıyor; Kerykeion, gezegen konumları için yüksek hassasiyetli bir kaynak olan Swiss Ephemeris üzerine kurulu bir Python astroloji kütüphanesi. Bir dil modeli de o haritaları okumak isteyeceğin bir metne dönüştürüyor. Projenin yapay zekâ güvenlik kuralları bu ayrımı tek satırda söylüyor: "AI must interpret calculated astrology data; it must not invent chart data." Yani yapay zekâ hesaplanmış astroloji verisini yorumlamalı, harita verisi uydurmamalı. Bu yazı o cümleyi koda taşımayı ve henüz taşınamadığı yerleri anlatıyor.

İki iş ve aralarındaki çizgi

Bir haritayı hesaplamak ile onu anlatmak iki ayrı iş ve ikisi farklı biçimlerde bozuluyor. Hesaplama deterministik: aynı tarih, saat ve yer her seferinde aynı haritayı verir, dolayısıyla herhangi bir fonksiyon gibi test edilebilir. calculate_real_chart bir Kerykeion subject'i kuruyor ve Güneş'i, Ay'ı, yükseleni ve sekiz gök cismini daha döndürüyor: her birinin burcunu, doğum saati biliniyorsa evini, 360°'lik çarktaki mutlak derecesini ve burç içindeki derecesini. Yükselen, yani doğu ufkunda yükselmekte olan burç, günde bir tam tur atar; bu yüzden doğum saatine dakikası dakikasına bağlıdır. Bu, aşağıda iki kez önem kazanacak. Anlatım ise koşudan koşuya değişir, akıcı cümlelerle yanılabilir ve her çağrıda para yakar.

Bu yüzden model yalnızca bir kopya görüyor. Doğum haritası endpoint'i yerleşimleri kurallarla yan yana prompt'a serileştiriyor ve modelden key, title ve text alanları olan JSON bölümleri istiyor. Cevap da iki yarıyı yan yana taşıyor: harita kodunun ürettiğinin birebir aynısı olan astrology_context ve modelin yazdığı hâliyle sections. Modelin döndürdüğü hiçbir şey o bağlama yazılmıyor.

Flutter uygulaması da aynı ayrımı koruyor: her gök cismini mutlak derecesine yerleştiren bir CustomPainter olan harita çarkı ve altındaki yerleşim chip'leri astrology_context.placements'ı okuyor; sections'ı yalnızca metin kartları okuyor. Bu her zaman böyle değildi. Doğum haritası okuma akışını ekleyen değişikliğe kadar çark, koda gömülü yedi dereceden çiziliyordu (Güneş 30°, Ay 120°, Merkür 45°); yani ekranın gösterdiği her harita aynı gökyüzüydü. Harita verisi uyduran tek parça model değildi.

Diyagram. Üst sıra: doğum verisi Swiss Ephemeris üzerindeki Kerykeion'a akıyor, sonucun bir kopyası kesikli bir çizgiyi geçip prompt'a giriyor, prompt da istek başına seçilen bir katmanla LLM'e gidiyor. Sol alt, hesaplanan taraf: cevabın olgu alanları, yani doğum haritası için astrology_context ile return'ler, drakonik haritalar ve horary okumalar için planets_detected, uygulamanın harita çarkını ve chip'lerini besliyor. Sağ alt, anlatılan taraf: modelin ayrıştırılmış JSON'u, metin kartlarını besleyen sections'ı ve bir planets_detected listesini taşıyor. Amber bir ok, modelin planets_detected listesinden geriye, planets_detected olgu alanına uzanıyor.
Model haritanın bir kopyasını alıyor ve yalnızca metin döndürmeli. Çizgiyi geri yönde geçen tek ok, hâlâ açık duran boşluk.

En sessiz hata boş bir alandır

Girişteki isteği yoldan çıkan bir model üretmedi. Onu, sabitlenmiş Kerykeion sürümünün hiç üretmediği bir JSON şeklini okuyan kod üretti:

asc_sign = self._map_sign(data.get("houses", [])[0].get("sign", "")) if data.get("houses") else "Bilinmiyor"
moon_sign = self._map_sign(data.get("planets", {}).get("Moon", {}).get("sign", ""))

Kerykeion 5 her noktayı JSON'un en üst seviyesine koyuyor (sun, moon, ascendant, first_house); ortada ne bir planets ne de bir houses koleksiyonu var. Varsayılan değer alan her .get savunma görevini kusursuzca yaptı: KeyError yok, çökme yok; yalnızca boş string'ler ve güler yüzlü bir "Bilinmiyor". Bir sorunun sorulduğu an için çıkarılan horary harita da burçları aynı şekilde boş döndürdü. Drakonik harita, yani 0° Koç yerine Ay'ın kuzey düğümünden ölçülen doğum haritası ise subject.houses'ı bir attribute olarak okuyordu. Eksik alanın bizzat hata fırlattığı tek yer oydu: doğrudan 500'e giden bir AttributeError.

Düzeltme, advanced-astrology ve horary servislerine kendi nokta okuyucularını verdi; ilk setteki bir karar da diğerlerinin hepsinden önemli: bir noktanın mutlak derecesi yoksa _point_abs varsayılan bir değer döndürmek yerine hata fırlatıyor. Aşağıdaki return aramaları o derece üzerinden çalışıyor; dolayısıyla okunamayan bir harita artık isteği, daha hiçbir prompt kurulmadan durduruyor. İki setteki burç ve derece okuyucuları ise hâlâ "Bilinmiyor" ile 0.0'a düşüyor.

Hesaplanan taraftaki bir varsayılan değer, senin uydurduğun bir olgudur.

Return bir aramadır, doğum günü değil

Doğru alanları okumak bir sonraki sorunu ortaya çıkardı; eski kod onu kendi yorumlarında zaten yazmıştı. Solar return için: "Just calculate chart on birthday of target_year", yani haritayı hedef yıldaki doğum gününe göre hesaplayıver. Lunar return ise hedef ayın 15'ini, öğlen saatini alıyordu: "A real lunar return needs precise ephemeris solving which is heavy. This is a MVP approx." Gerçek bir lunar return ağır bir efemeris çözümü gerektirdiği için bu bir MVP yaklaşıklığıydı.

Bir return bir açıyla tanımlanır: transit Güneş'in, lunar return'de ise Ay'ın, doğum haritasındaki boylamına yeniden ulaştığı an. Bu yüzden yeniden yazılan kod onu bir arama olarak ele alıyor:

def _angular_distance(self, first: float, second: float) -> float:
    return abs((first - second + 180.0) % 360.0 - 180.0)
 
def _find_closest_return_datetime(
    self,
    profile: BirthProfile,
    target_degree: float,
    point_key: str,
    start: datetime,
    end: datetime,
    coarse_minutes: int,
    refine_minutes: int,
    refine_window_hours: int,
) -> tuple[datetime, float]:
    best_moment = start
    best_distance = 360.0
    moment = start
 
    while moment <= end:
        data = self._subject_data("ReturnScan", profile, moment)
        distance = self._angular_distance(self._point_abs(data, point_key), target_degree)
        if distance < best_distance:
            best_moment = moment
            best_distance = distance
        moment += timedelta(minutes=coarse_minutes)
 
    # ...then the same loop again, every refine_minutes,
    # within ±refine_window_hours of best_moment
    return best_moment, best_distance

Mesafe fonksiyonu 0°'deki dikişi hallediyor: Balık 29,9° ile Koç 0,1° arasında neredeyse tam bir tur değil, 0,2° çıkıyor. Arama önce kaba adımlarla tarıyor (Güneş için doğum gününün iki yanında ikişer gün boyunca altı saatte bir, Ay için ay boyunca on iki saatte bir), sonra en iyi adımın çevresinde 30 dakikalık adımlarla aramayı inceltiyor. Doğum haritası ile return haritasının kendisi de sayılınca bu, solar return için 52, 31 günlük bir ayda lunar return için 105 harita hesabı ediyor. Kaba kuvvet bu; hiçbir yanı modele bağlı değil.

Kestirmelerin neye mal olduğunu görmek için eski ve yeni mantığı, doğruluk testlerinin kullandığı profille koşturdum: 1 Ocak 1990'da saat 12:00'de İstanbul'da doğmuş biri. 2026 için arama 1 Ocak 07:00'ye iniyor; doğum Güneş'ine uzaklık 0,01°. Saat 12:00'deki doğum günü kestirmesi boylamda yalnızca 0,22° sapıyor, ama beş saat yükselen için uzun bir süre: 07:00 ile 12:00 arasında 90°'den fazla ilerleyip Yay'dan Balık'a geçiyor. Solar return talimatı da bütün yılı iki yerleşimin, yükselen burcun ve Ay burcunun üzerine kuruyor. Lunar kestirmesi daha da kötü: 15 Ocak öğlen Ay, doğum derecesinden 75,6° uzakta; Balık'ta değil Yay'da. O harita her neyse, bir lunar return değil.

Uygulamanın kendi koduyla, 1 Ocak 1990'da saat 12:00'de İstanbul'da doğmuş bir profil için hesaplanmış iki çizgi grafiği. Solda: Güneş'in doğum derecesine uzaklığı, ince arama penceresi olan 31 Aralık 22:00 ile 1 Ocak 14:00 aralığında, 07:00'nin hemen öncesinde dibe iniyor; aramanın 07:00'deki sonucu 0,01 derece uzakta ve yükselen Yay, eski doğum günü kestirmesi ise 12:00'de yokuşun üzerinde, 0,22 derecede duruyor ve yükselen Balık. Sağda: Ay'ın doğum derecesine uzaklığı Ocak 2026 boyunca 21 Ocak 14:00'te 0,11 dereceyle dibe iniyor, Ay Balık'ta; eski kestirme ise ayın 15'inde öğlen 75,6 derecede, Ay Yay'da.
Bir return, bir mesafe eğrisinin dibidir. Solar kestirme yokuşun 0,22° yukarısında kaldı: doğru görünecek kadar yakın, ama yükselen burç için beş saat geç. Lunar kestirme ise dibin yakınından bile geçmedi.

Girişteki isteğin aynı profil için güncel kodla yeniden kurulmuş hâli şu:

Solar Return Haritası Bilgileri:
Solar Return (Güneş Dönüşü) Yılı: 2026
Hesaplanan dönüş anı: 2026-01-01 07:00
Güneş: Oğlak 10.74°
Yükselen: Yay
Ay: İkizler 9.22°

Boş satır yok ve her değer efemeristen geliyor; Ay'ın format örneğindeki gibi İkizler'e düşmesi bu doğum tarihinin bir tesadüfü. Testler bir snapshot yerine bir özelliği kontrol ediyor: yarım derece içinde biten bir arama (distance < 0.5). Bir return'ün altındaki uyarı notu da ölçülen yakınlığı söylüyor: "Yakınlık: 0.01°". İkisi de göründüğünden zayıf; nedenini aşağıdaki açık boşluklar listesi anlatıyor.

Model çuvallarsa haritayı yine de teslim et

Düzeltmeden önce JSON olarak ayrıştırılamayan bir cevap, suçu yıldızlara atan bir hata fırlatıyor, route da onu bir 500'e çeviriyordu. İşin modele bağlı olmayan tek parçası da onunla birlikte çöpe gidiyordu.

Artık ayrıştırma hatası onun yerine yapılandırılmış bir özet üretiyor: haritanın hesaplandığını ama yorumun beklenen formatta gelmediğini söyleyen tek bir bölüm, hesaplanan haritadan tespit edilen noktalar, is_placeholder: true ve return'ler için yakınlık notu. Eski kodu yeniden kurduğumda 500 dönen üç endpoint artık bu özetle 200 dönüyor. İki test solar ve lunar return'lerdeki fallback'i çiviliyor; bir endpoint testi de placeholder sağlayıcı üzerinden bir drakonik okuma gönderip 200 bekliyor.

Böyle kurulmuş her pipeline'da uygulayacağım kural şu: anlatım çökerse olgulara geri çekil. Efemeris sonucu, cevaptaki en değerli ve elde tutması en ucuz şey.

Prompt rica eder, kod karar verir

Kuralın model için açıkça yazıldığı yer doğum haritası prompt'u:

"instruction": (
    "You are an empathetic, insightful astrologer. "
    "Read the supplied birth chart context accurately: prioritize exact planet, sign, house, and degree placements when present. "
    "If birth time is unknown or a placement is missing, state uncertainty instead of inventing it. "
    "OUTPUT FORMAT: You MUST return ONLY a JSON object with a single key 'sections'. "
    "The value of 'sections' must be an array of section objects. "
    "Each section object must have 'key', 'title', and 'text' strings. "
    "Required keys: 'personality', 'love', 'career', 'spiritual_growth'. "
    "If 'focus_area' is daily_guidance, add a 'daily_guidance' key as well. "
    "Always write the 'text' beautifully in Turkish using markdown formatting for emphasis. "
    "Do NOT wrap the JSON in markdown code blocks or add any other text."
),

Prompt modele kesin yerleşimleri öne almasını ve belirsizliği kabul etmesini söylüyor, çıktıyı da kodun ayrıştırabileceği bir şekle bağlıyor. Ama prompt bir ricadır. Sınırı ayakta tutan, onu çevreleyen kod:

  • Telefon hiçbir zaman bir modelle konuşmaz. Her model çağrısı backend'den geçer.
  • Kimin model çağırabileceğine kod karar verir. Her istek bir model değil, bir katman adı verir (cheap, balanced, premium ya da vision); bir özelliğin istekleri gerçek bir sağlayıcıya ulaşmadan önce o özelliğin allowlist'te olması gerekir. Geri kalan her şey fallback'e gider; varsayılan fallback da dışarıya hiç çağrı yapmayan bir placeholder'dır.
  • Çıktı, kimse görmeden önce ayrıştırılır. Yalnızca key, title ve text taşıyan bölümler hayatta kalır; ayrıştırılamayan her şey tek, sade bir bölüme dönüşür. Cevaptaki hesaplanmış bağlama hiç dokunulmaz.
  • Olgu tarafı modelsiz test edilir. Suite'teki hiçbir test gerçek bir model çağrısı yapmıyor: API testleri placeholder sağlayıcı üzerinde koşuyor, sağlayıcı testleri de HTTP katmanını stub'lıyor. Güncel kodda 120 backend testinin hepsi geçiyor ve Flutter analyzer hiçbir sorun raporlamıyor. Bu yalnızca olgular modele bağlı olmadığı için mümkün.

Hesaplanacak hiçbir şeyin olmadığı tek yer vision yolu: /birth-chart/read-image birinin elinde zaten olan bir haritanın fotoğrafını alıyor, model de okuyabildiğini çıkarıyor. Burada modelin çıktısı gerçekten astrology_context'e düşüyor, ama requires_visual_verification: true'nun yanında extracted_chart olarak, yani ne olduğu açıkça etiketlenmiş hâlde. Çark yine yalnızca hesaplanmış placements'tan çiziliyor ve bu yol onu hiç üretmiyor.

Çizgiyi hâlâ geçen tek alan

Bu, doğum haritası için geçerli. Return'lerin, drakonik haritaların ve horary okumaların ise kendilerine ait bir olgu alanı var: uygulamanın chip olarak gösterdiği planets_detected. Prompt'lar modelden bu alanı doldurmasını istiyor, kod da geriye ne gelirse onu tutuyor:

planets_detected=data.get('planets_detected', fallback_planets),

Hesaplanan liste yalnızca fallback: key eksikse ya da cevap ayrıştırılamıyorsa devreye giriyor; horary servisi de aynısını yapıyor. Gerçek bir sağlayıcı yapılandırıldığı anda horary buna takılabilir, çünkü bu dört özellik içinde allowlist'te olan tek özellik o. Benim testimde sağlayıcının yerine konan bir stub kendi listesiyle cevap verdi, "Yükselen Boğa" ve "Ay İkizler"; cevap da bu listeyi is_placeholder: false ile gönderdi. Oysa kod aynı gökyüzünü az önce yükselen İkizler, Ay Koç'ta olarak hesaplamıştı. Vision yolunun aksine, listenin modele ait olduğunu işaretleyen hiçbir şey yok.

Solar return'ler, lunar return'ler ve drakonik haritalar allowlist'te değil; bu yüzden varsayılan fallback ile her zaman yalnızca olgulardan oluşan özet olarak dönüyorlar ve henüz gerçek bir anlatım almıyorlar. Ama prompt'ları tuzağı şimdiden kurmuş durumda: return format örnekleri somut burçlar sayıyor ve solar örneği aynen geri yansıtan, allowlist'i aşacak şekilde araya sokulmuş bir stub, yükseleni Yay olan bir harita için "Yükselen Boğa" gönderiyor. Düzeltme, iki servisin her birinde tek satırlık bir değişiklik: her zaman hesaplanan liste.

Neleri (henüz) yapmıyor

Bu yazı için çizgiyi uçtan uca, gerçek modellerin yerine stub'lar koyarak test etmek bundan fazla boşluk çıkardı. İkisi şimdiden kapandı. Yerleşimler artık ev numaralarını taşıyor: Kerykeion evleri Tenth_House gibi kelimelerle adlandırıyor, parser yalnızca rakam arıyordu ve her ev null olarak gidiyordu. Hesaplanmış bir haritanın okuması da artık onu geçici veri diye niteleyen MVP döneminden kalma uyarı notunu taşımıyor; bağlamı artık modele yalnızca listelenen yerleşimleri yorumlamasını söylüyor. Şunlar hâlâ açık:

  • Bilinmeyen doğum saati, olgusu olmayan bir kural. Uygulama is_birth_time_unknown gönderiyor, harita da öğlen için hesaplanıyor. Ev düzeltmesi bu durumda evleri zaten düşürüyor, çünkü tahmin edilmiş bir saat için çıkarılan ev başlangıçları (cusp'lar) uydurma olurdu. Yükselen de bir cusp ve o hâlâ gidiyor: 1 Ocak 1990'da İstanbul'da doğan biri için öğlen yükselen Koç 16,92°, 06:30'da ise Yay; üstelik doğum saati bilinen bir haritanın aldığı efemeris notunun aynısıyla. Bağlamda saatin bilinmediğini söyleyen hiçbir şey yok; dolayısıyla belirsizliği belirtme talimatını tetikleyecek bir şey de yok.
  • Bağlam hâlâ kalıp metin taşıyor. Harita kodu yerleşimlerin yanına dört özet satır ekliyor ve bunların üçü her harita için aynı. Biri kariyerdeki desteği "haritanızın harika açıları"na bağlıyor; oysa harita kodu tek bir açı bile hesaplamıyor.
  • İki okuyucu hâlâ varsayılana düşüyor. Burç ve derece okuyucuları da _point_abs gibi hata fırlatmalı.
  • Solar testi eski kestirmeyi de geçirirdi. Testin penceresi doğum günü öğlen açılıyor; bu yüzden kontrol ettiği arama kestirmenin kendisini, 0,22°'deki 12:00'yi döndürüyor ve < 0.5'ten geçiyor. Bir tahmini yalnızca kestirmesi 75,6° uzakta kalan lunar testi yakalardı. Solar testi, üretimdeki iki yanda ikişer günlük pencereyi ve kestirmenin karşılayamayacağı bir sınırı kullanmalı.
  • Yakınlık Güneş üzerinden ölçülüyor. Arama 30 dakikalık adımlarla inceliyor. Test profilinde Güneş'in gerçek geçişi 06:47'de, raporlanan 07:00'den on üç dakika önce; okumanın dayandığı yükselen ise bu sürede 2,8° ilerliyor. Burada burç aynı kalıyor, ama bir burç sınırına yakın haritada aynı kalacağının garantisi yok.
  • Motor çağrısı deprecated. Kerykeion 5, AstrologicalSubject'i geriye dönük uyumluluk modülünden geçiriyor ve her çağrıda uyarı veriyor: güncel suite'teki 314 uyarının 312'si bu bildirim.
Bilinmeyen doğum saati yolunun diyagramı. Olgu şeridi: Flutter uygulaması doğum saati olmadan is_birth_time_unknown true gönderiyor, calculate_real_chart saat 12'ye düşüp evleri null olan bir öğle haritası hesaplıyor ve prompt'taki astrology_context'te saat bayrağı yok; bu kutu amber ile vurgulanmış. Kural şeridi: prompt talimatı, doğum saati bilinmiyorsa ya da bir yerleşim eksikse modelin uydurmak yerine belirsizliği belirtmesini söylüyor. LLM haritayı, yani yükselen Koç 16,92 dereceyi, ve kuralı alıyor ama tetikleyiciyi almıyor. Altta: doğum saati olmadan harita Koç yükseliyor; aynı tarih ve yerde 06:30 ile Yay yükseliyor.
Kural prompt'a girdi; onu tetiklemesi gereken olgu hiç girmedi. Ev düzeltmesi bilinmeyen saat için cusp'ları düşürüyor, ama yükselen hâlâ doğum saati biliniyormuş gibi bir kesinlikle gidiyor.

Aynı ayrımı sen de kuruyorsan

Bunların hiçbiri astrolojiye özgü değil. Deterministik bir motoru onu açıklayan bir modelle eşleştiren her şeyde (fiyatlama, rota planlama, ilaç takvimleri, spor istatistikleri) aynı iki taraf ve metin tarafının sayılara dokunmasına izin verme cazibesi var.

Bir prompt, modelden gökyüzünü uydurmamasını rica edebilir. Buna hiç gerek kalmamasını ise ancak kod sağlayabilir: ona eksiksiz bir gökyüzü ver, aslını onun yazamayacağı yerde tut ve anlatım çuvallarsa gökyüzünü yine de teslim et. Efemeris hesaplar, model anlatır; aradaki çizginin yeri de bir testin görebildiği yerdir.