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.

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.
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.
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.
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_distanceMesafe 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.
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.
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.
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.
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.
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_unknowngö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_absgibi 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.
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.


