12 Min. Lesezeit
Ein leeres Horoskop und ein poetischer Prompt: Wie ich das LLM davon abhalte, den Himmel zu erfinden
Vor dem Fix enthielt jeder Solarhoroskop-Prompt meiner Astrologie-App ein leeres Horoskop und verlangte trotzdem Poesie. Wie ich die Grenze zwischen Ephemeride und LLM in den Code verlegt habe – und wo das noch aussteht.

Bis ich es behoben habe, steckte meine Astrologie-App in jeden Prompt für ein Solarhoroskop dasselbe Horoskop, ganz gleich, wer fragte: kein Aszendent, kein Mond. Poesie verlangte der Prompt trotzdem. Ein Solarhoroskop (englisch solar return) ist das Horoskop für den Moment, in dem die Sonne auf genau den Grad zurückkehrt, auf dem sie bei deiner Geburt stand, und mehr Horoskop als das hier trug die Anfrage nicht:
Solar Return Haritası Bilgileri:
Solar Return (Güneş Dönüşü) Yılı: 2026
Yükselen: Bilinmiyor
Ay:Die Prompts sind auf Türkisch. Yükselen: Bilinmiyor heißt „Aszendent: unbekannt“; Ay ist der Mond, und danach kommt nichts. Die Systemnachricht verlangte, das Jahr daran zu erklären, „dass der Aszendent Bilinmiyor und das Mondzeichen“ (dann eine Lücke) „ist, in einer mystischen, aber verständlichen Sprache. Verwende einen poetischen Stil.“ Die einzigen konkreten Stellungen der ganzen Anfrage standen ganz unten im JSON-Formatbeispiel: Aszendent Stier, Mond Zwillinge.
Für diesen Beitrag habe ich die Anfrage aus dem Code vor dem Fix nachgebaut, mit Kerykeion 5.12.8, der in den Requirements gepinnten Version, und sie ausgegeben, statt sie abzuschicken. In der Standardkonfiguration antwortet statt eines Modells ein Platzhalter-Provider; seine Antwort ist kein JSON, der Parser gab auf, und der Endpunkt antwortete:
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."}„Das Flüstern der uralten Sterne ist gerade zu verworren. Bitte versuche es erneut.“ Den Sternen ging es gut. Das Horoskop war leer, und nichts zwischen Ephemeride und Prompt hatte das geprüft. Dass dieser Prompt kein echtes Modell erreichte, lag nur am Platzhalter als Standard und an einer Allowlist der Features, die überhaupt ein Modell aufrufen dürfen, und keins von beiden hat je auf das Horoskop geschaut.
Universal AI Astro ist meine Flutter-App mit FastAPI-Backend: Geburtshoroskope, Solar- und Lunarhoroskope, drakonische Horoskope, Stundenastrologie, Numerologie. Kerykeion, eine Astrologie-Bibliothek für Python auf Basis der Swiss Ephemeris, einer hochgenauen Quelle für Planetenpositionen, berechnet die Horoskope auf dem Server, und ein Sprachmodell macht daraus etwas, das man gern liest. Die KI-Sicherheitsregeln des Projekts fassen die Aufteilung in eine Zeile: „AI must interpret calculated astrology data; it must not invent chart data.“ Die KI deutet also berechnete Daten und erfindet keine Horoskopdaten. In diesem Beitrag geht es darum, diesen Satz in Code zu überführen, und darum, wo er dort noch nicht angekommen ist.
Ein Horoskop zu berechnen und eines zu beschreiben sind verschiedene Aufgaben, und sie scheitern auf verschiedene Weise. Die Berechnung ist deterministisch: Dasselbe Datum, dieselbe Uhrzeit und derselbe Ort ergeben jedes Mal dasselbe Horoskop, also lässt sie sich testen wie jede andere Funktion. calculate_real_chart baut ein Kerykeion-Subject und liefert die Sonne, den Mond, den Aszendenten und acht weitere Körper, jeweils mit Zeichen, mit Haus, wenn die Geburtszeit bekannt ist, mit absolutem Grad auf dem 360°-Rad und mit dem Grad innerhalb des Zeichens. Der Aszendent, das Zeichen, das am östlichen Horizont aufsteigt, dreht sich einmal am Tag im Kreis und hängt deshalb minutengenau von der Geburtszeit ab; das wird weiter unten zweimal wichtig. Die Erzählung dagegen ändert sich von Lauf zu Lauf, kann in flüssigen Sätzen falsch sein und kostet bei jedem Aufruf Geld.
Deshalb sieht das Modell immer nur eine Kopie. Der Endpunkt für das Geburtshoroskop serialisiert die Stellungen in den Prompt, neben die Regeln, und verlangt JSON-Abschnitte mit Key, Titel und Text. Die Antwort trägt dann beide Hälften nebeneinander: astrology_context, genau das, was der Horoskop-Code erzeugt hat, und sections, so wie sie geschrieben wurden. Nichts von dem, was das Modell zurückgibt, wird in diesen Kontext geschrieben.
Die Flutter-App hält dieselbe Trennung ein: Das Horoskoprad, ein CustomPainter, der jeden Körper auf seinen absoluten Grad setzt, und die Stellungs-Chips darunter lesen astrology_context.placements; nur die Textkarten lesen sections. Das war nicht immer so. Bis zu der Änderung, die den Ablauf für die Deutung des Geburtshoroskops hinzufügte, wurde das Rad aus sieben fest eincodierten Graden gezeichnet (die Sonne bei 30°, der Mond bei 120°, Merkur bei 45°), und so zeigte der Bildschirm bei jedem Horoskop denselben Himmel. Das Modell war nicht der einzige Teil der App, der sich Horoskopdaten ausdachte.
Die Anfrage vom Anfang kam nicht von einem Modell, das sich danebenbenommen hat. Sie kam von Code, der eine JSON-Form las, die das gepinnte Kerykeion gar nicht ausgibt:
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 legt jeden Punkt auf die oberste Ebene seines JSON (sun, moon, ascendant, first_house) und hat keine Sammlung planets oder houses. Jedes .get mit Default erledigte seinen defensiven Job perfekt: kein KeyError, kein Absturz, nur leere Strings und ein freundliches „Bilinmiyor“. Das Stundenhoroskop, das für den Augenblick berechnet wird, in dem jemand eine Frage stellt, kam auf dieselbe Weise mit leeren Zeichen zurück. Das drakonische Horoskop, also das Geburtshoroskop, gemessen vom nördlichen Mondknoten statt von 0° Widder, las subject.houses als Attribut. Nur dort löste das fehlende Feld selbst einen Fehler aus: einen AttributeError, direkt in einen 500.
Der Fix gab dem Service für die erweiterte Astrologie und dem für die Stundenastrologie jeweils eigene Funktionen zum Auslesen der Punkte, und eine Entscheidung im ersten der beiden zählt mehr als alle anderen: Hat ein Punkt keinen absoluten Grad, wirft _point_abs einen Fehler, statt einen Default zurückzugeben. Die Suchen nach der Rückkehr weiter unten laufen auf genau diesem Grad, also stoppt ein Horoskop, das sich nicht lesen lässt, die Anfrage jetzt, bevor überhaupt ein Prompt gebaut wird. Die Funktionen für Zeichen und Grad fallen in beiden Services weiterhin auf „Bilinmiyor“ und 0,0 zurück.
Ein Default auf der berechneten Seite ist ein Fakt, den du dir ausgedacht hast.
Sobald die richtigen Felder gelesen wurden, kam das nächste Problem ans Licht, und der alte Code hatte es in seinen eigenen Kommentaren schon festgehalten. Beim Solarhoroskop: „Just calculate chart on birthday of target_year“. Das Lunarhoroskop nahm den 15. des Zielmonats um 12 Uhr mittags: „A real lunar return needs precise ephemeris solving which is heavy. This is a MVP approx.“
Eine Rückkehr ist über einen Winkel definiert: den Moment, in dem die transitierende Sonne (beim Lunarhoroskop der Mond) wieder dieselbe Länge erreicht wie bei der Geburt. Also behandelt die Neufassung sie als Suche:
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_distanceDie Abstandsfunktion kümmert sich um die Naht bei 0°: 29,9° Fische und 0,1° Widder liegen 0,2° auseinander und nicht fast einen ganzen Kreis. Die Suche tastet in groben Schritten ab (für die Sonne alle sechs Stunden über zwei Tage vor und nach dem Geburtstag, für den Mond alle zwölf Stunden über den Monat) und verfeinert dann rund um den besten Schritt im 30-Minuten-Takt. Zählt man das Geburtshoroskop und das Rückkehrhoroskop selbst mit, sind das 52 Horoskopberechnungen für ein Solarhoroskop und 105 für ein Lunarhoroskop in einem Monat mit 31 Tagen. Das ist Brute Force, und nichts daran hängt von einem Modell ab.
Um zu sehen, was die Abkürzungen gekostet hatten, habe ich die alte und die neue Logik auf das Profil angewendet, das die Genauigkeitstests verwenden: geboren am 1. Januar 1990 um 12:00 Uhr in Istanbul. Für 2026 landet die Suche am 1. Januar um 07:00 Uhr, 0,01° von der Geburtssonne entfernt. Die Geburtstagsabkürzung um 12:00 Uhr liegt in der Länge nur 0,22° daneben, aber fünf Stunden sind für den Aszendenten eine lange Zeit: Zwischen 07:00 und 12:00 Uhr wandert er um mehr als 90°, vom Schützen bis in die Fische, und die Anweisung für das Solarhoroskop baut das ganze Jahr auf zwei Stellungen auf, dem Aszendenten und dem Mondzeichen. Die Abkürzung beim Lunarhoroskop ist schlimmer. Am 15. Januar um 12 Uhr mittags steht der Mond 75,6° von seinem Geburtsgrad entfernt, im Schützen statt in den Fischen. Was auch immer dieses Horoskop ist, ein Lunarhoroskop ist es nicht.
So sieht die Anfrage vom Anfang aus, nachgebaut auf dem aktuellen Code für dasselbe Profil:
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°Nichts ist leer, und jeder Wert kommt aus der Ephemeride; dass der Mond wie im Formatbeispiel in den Zwillingen landet, ist ein Zufall dieses Geburtsdatums. Die Tests prüfen eine Eigenschaft statt eines Snapshots, nämlich eine Suche, die innerhalb eines halben Grads endet (distance < 0.5), und der Hinweis unter einer Rückkehr nennt die gemessene Nähe, „Yakınlık: 0.01°“. Beides ist schwächer, als es aussieht; warum, steht unten in der Liste der offenen Lücken.
Vor dem Fix löste eine Antwort, die sich nicht als JSON parsen ließ, einen Fehler aus, der den Sternen die Schuld gab, und die Route machte daraus einen 500. Der einzige Teil der Arbeit, der nicht von einem Modell abhing, wurde gleich mit weggeworfen.
Jetzt erzeugt ein Parse-Fehler stattdessen eine strukturierte Zusammenfassung: einen Abschnitt, der sagt, dass das Horoskop berechnet wurde, die Deutung aber nicht im erwarteten Format ankam, die erkannten Punkte aus dem berechneten Horoskop, is_placeholder: true und bei Rückkehrhoroskopen den Hinweis zur Nähe. Die drei Endpunkte, die in meinem Nachbau des alten Codes mit 500 antworteten, antworten jetzt mit 200 und dieser Zusammenfassung. Zwei Tests nageln den Fallback für Solar- und Lunarhoroskope fest, und ein Endpunkt-Test schickt eine drakonische Deutung durch den Platzhalter-Provider und erwartet ein 200.
Diese Regel würde ich auf jede Pipeline dieser Form anwenden: Wenn die Erzählung scheitert, falle auf die Fakten zurück. Das Ergebnis der Ephemeride ist das Wertvollste an der Antwort und am billigsten zu behalten.
Im Prompt für das Geburtshoroskop wird die Regel für das Modell ausformuliert:
"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."
),Er sagt dem Modell, exakte Stellungen vorzuziehen und Unsicherheit zuzugeben, und legt die Ausgabe auf eine Form fest, die der Code parsen kann. Aber ein Prompt ist eine Bitte. Was die Grenze hält, ist der Code drumherum:
- Das Handy spricht nie mit einem Modell. Jeder Modellaufruf läuft über das Backend.
- Der Code entscheidet, wer überhaupt ein Modell aufrufen darf. Jede Anfrage nennt statt eines Modells eine Stufe (cheap, balanced, premium oder vision), und ein Feature muss auf einer Allowlist stehen, bevor seine Anfragen einen echten Provider erreichen. Alles andere geht an den Fallback, und der Standard-Fallback ist ein Platzhalter, der keinen externen Aufruf macht.
- Die Ausgabe wird geparst, bevor jemand sie sieht. Nur Abschnitte mit Key, Titel und Text überleben; alles, was sich nicht parsen lässt, wird zu einem einzigen schlichten Abschnitt. Der berechnete Kontext in der Antwort wird nie angefasst.
- Die Faktenseite wird ohne Modell getestet. Kein Test der Suite macht einen echten Modellaufruf: Die API-Tests laufen auf dem Platzhalter-Provider, und die Provider-Tests stubben die HTTP-Schicht. Auf dem aktuellen Code laufen alle 120 Backend-Tests grün, und der Flutter-Analyzer meldet keine Probleme. Möglich ist das nur, weil die Fakten nicht vom Modell abhängen.
Der Vision-Pfad ist die einzige Stelle, an der es nichts zu berechnen gibt: /birth-chart/read-image nimmt ein Foto eines Horoskops, das jemand schon hat, und das Modell extrahiert, was es lesen kann. Hier landet seine Ausgabe tatsächlich in astrology_context, als extracted_chart neben requires_visual_verification: true, also als das gekennzeichnet, was sie ist. Das Rad zeichnet weiterhin nur aus berechneten placements, und die erzeugt dieser Pfad nie.
So weit gilt das für das Geburtshoroskop. Rückkehrhoroskope, drakonische Horoskope und Deutungen der Stundenastrologie haben aber ein eigenes Faktenfeld, planets_detected, das die App als Chips zeigt. Die Prompts bitten das Modell, es auszufüllen, und der Code behält, was auch immer zurückkommt:
planets_detected=data.get('planets_detected', fallback_planets),Die berechnete Liste ist nur der Fallback, für einen fehlenden Key oder eine Antwort, die sich nicht parsen lässt, und der Service für die Stundenastrologie macht es genauso. Bei der Stundenastrologie kann das passieren, sobald ein echter Provider konfiguriert ist: Von diesen vier Features steht nur sie auf der Allowlist. In meinem Test antwortete ein Stub an Stelle des Providers mit einer eigenen Liste, „Yükselen Boğa“ und „Ay İkizler“ (Aszendent Stier, Mond Zwillinge), und die Antwort lieferte sie mit is_placeholder: false aus, für einen Himmel, den der Code gerade mit Aszendent Zwillinge und Mond im Widder berechnet hatte. Anders als beim Vision-Pfad kennzeichnet nichts die Liste als die des Modells.
Solar- und Lunarhoroskope und drakonische Horoskope stehen nicht auf der Allowlist, also kommen sie mit dem Standard-Fallback immer als reine Faktenzusammenfassung zurück und bekommen noch keine echte Erzählung. Ihre Prompts legen die Falle trotzdem schon aus: Die Formatbeispiele für Rückkehrhoroskope nennen konkrete Zeichen, und ein Stub, der das Beispiel des Solarhoroskops zurückgibt und an der Allowlist vorbei eingeschleust wird, liefert Aszendent Stier für ein Horoskop aus, dessen Aszendent im Schützen steht. Der Fix ist eine einzeilige Änderung in jedem der beiden Services: immer die berechnete Liste.
Als ich die Grenze für diesen Beitrag End-to-End getestet habe, mit Stub-Modellen an Stelle echter, tauchten mehr Lücken auf als diese eine. Zwei davon sind schon geschlossen. Stellungen tragen jetzt ihre Hausnummern: Kerykeion benennt Häuser mit Wörtern wie Tenth_House, der Parser suchte nur nach Ziffern, und jedes Haus ging als null hinaus. Und die Deutung eines berechneten Horoskops liefert nicht mehr den Hinweis aus MVP-Zeiten aus, der es als vorläufige Daten bezeichnete; ihr Kontext weist das Modell jetzt an, nur die aufgeführten Stellungen zu deuten. Diese hier sind noch offen:
- Eine unbekannte Geburtszeit ist eine Regel ohne Fakt. Die App schickt
is_birth_time_unknown, und das Horoskop wird für 12 Uhr mittags berechnet. Der Häuser-Fix lässt die Häuser in diesem Fall schon weg, weil Häuserspitzen für eine geratene Zeit erfunden wären. Der Aszendent ist aber auch eine Häuserspitze, und er geht weiterhin hinaus: für eine Geburt am 1. Januar 1990 in Istanbul Aszendent Widder bei 16,92° um 12 Uhr mittags, Schütze um 06:30 Uhr, mit demselben Ephemeriden-Hinweis, den auch eine bekannte Geburtszeit bekommt. Nichts im Kontext sagt, dass die Zeit unbekannt war, also hat die Anweisung, Unsicherheit anzugeben, nichts, worauf sie anspringen könnte. - Der Kontext trägt noch Textbausteine. Neben die Stellungen setzt der Horoskop-Code vier Zusammenfassungszeilen, und drei davon sind bei jedem Horoskop gleich. Eine lobt „die wunderbaren Aspekte Ihres Horoskops“, dabei berechnet der Horoskop-Code überhaupt keine.
- Zwei Lesefunktionen fallen noch auf Defaults zurück. Die Funktionen für Zeichen und Grad sollten einen Fehler werfen, so wie
_point_abses tut. - Der Solar-Test würde die alte Abkürzung durchlassen. Sein Fenster beginnt am Geburtstag um 12 Uhr mittags, also liefert die Suche, die er prüft, die Abkürzung selbst, 12:00 Uhr bei 0,22°, und besteht
< 0.5; nur der Lunar-Test, dessen Abkürzung 75,6° danebenliegt, würde eine Schätzung erwischen. Der Solar-Test sollte das Fenster aus der Produktion verwenden, zwei Tage vor und nach dem Geburtstag, und eine Schranke, die die Abkürzung nicht schafft. - Die Nähe wird an der Sonne gemessen. Die Suche verfeinert in 30-Minuten-Schritten. Beim Testprofil erreicht die Sonne ihren Geburtsgrad tatsächlich um 06:47 Uhr, dreizehn Minuten vor den gemeldeten 07:00 Uhr, und der Aszendent, auf den sich die Deutung stützt, wandert in dieser Zeit um 2,8°: hier bleibt er im selben Zeichen, bei einem Horoskop nahe einer Zeichengrenze nicht unbedingt.
- Der Aufruf der Engine ist veraltet. Kerykeion 5 leitet
AstrologicalSubjectdurch sein Modul für Abwärtskompatibilität und warnt bei jedem Aufruf: 312 der 314 Warnungen in der aktuellen Suite sind dieser Hinweis.
Nichts davon ist spezifisch für Astrologie. Alles, was eine deterministische Engine mit einem Modell kombiniert, das ihre Ergebnisse erklärt (Preisberechnung, Routenplanung, Medikationspläne, Sportstatistiken), hat dieselben zwei Seiten und dieselbe Versuchung, die Prosaseite an die Zahlen zu lassen.
Ein Prompt kann ein Modell bitten, den Himmel nicht zu erfinden. Nur Code kann dafür sorgen, dass es das nie muss: Gib ihm einen vollständigen Himmel, bewahre das Original dort auf, wo es nicht schreiben kann, und wenn die Erzählung scheitert, liefere den Himmel trotzdem aus. Die Ephemeride rechnet, das Modell erzählt, und die Grenze zwischen beiden gehört dorthin, wo ein Test sie sehen kann.


