12 min read
An empty chart and a poetic prompt: keeping the LLM from inventing the sky
Before the fix, every solar-return prompt my astrology app could build carried an empty chart and still asked for poetry. How I moved the line between the ephemeris and the LLM into code, and where it isn't there yet.

Until I fixed it, my astrology app would put the same chart into every solar-return prompt, whoever asked: no rising sign, no Moon. The prompt asked for poetry anyway. A solar return is the chart for the moment the Sun comes back to the exact degree it held when you were born, and this is all the chart the request carried:
Solar Return Haritası Bilgileri:
Solar Return (Güneş Dönüşü) Yılı: 2026
Yükselen: Bilinmiyor
Ay:The prompts are in Turkish. Yükselen: Bilinmiyor is "Rising: Unknown"; Ay is the Moon, followed by nothing. The system message asked the model to explain the year "through the rising sign being Bilinmiyor and the Moon sign being" (then a blank) "in a mystical but understandable language. Use a poetic style." The only concrete placements in the whole request sat in the JSON format example at the bottom: Rising Taurus, Moon Gemini.
I rebuilt that request for this post from the pre-fix code, with Kerykeion 5.12.8, the version the requirements pin, and printed it instead of sending it. In the default configuration a placeholder provider answers instead of a model; its reply isn't JSON, the parser gave up, and the endpoint answered:
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."}"The whispers of the ancient stars are too complex right now. Please try again." The stars were fine. The chart was empty, and nothing between the ephemeris and the prompt had checked. What kept that prompt away from a real model was the placeholder default and an allowlist of the features that may call one, and neither of them ever looked at the chart.
Universal AI Astro is my Flutter app with a FastAPI backend: birth charts, solar and lunar returns, draconic charts, horary questions, numerology. Kerykeion, a Python astrology library built on the Swiss Ephemeris, a high-precision source of planetary positions, computes the charts on the server, and a language model turns them into something you'd want to read. The project's AI safety rules put the split in one line: "AI must interpret calculated astrology data; it must not invent chart data." This post is about moving that sentence into code, and about where it hasn't moved yet.
Computing a chart and describing one are different jobs, and they fail differently. The computation is deterministic: the same date, time and place give the same chart every time, so it can be tested like any other function. calculate_real_chart builds a Kerykeion subject and returns the Sun, the Moon, the ascendant and eight more bodies, each with its sign, its house when the birth time is known, its absolute degree on the 360° wheel and its degree inside the sign. The ascendant, the sign rising on the eastern horizon, turns a full circle once a day, so it depends on the birth time to the minute; that matters twice below. The narration varies from run to run, can be wrong in fluent sentences, and costs money on every call.
So the model only ever sees a copy. The birth-chart endpoint serializes the placements into the prompt, next to the rules, and asks for JSON sections with a key, a title and text. The response then carries both halves side by side: astrology_context, exactly what the chart code produced, and sections, as written. Nothing the model returns is written into that context.
The Flutter app keeps the same split: the chart wheel, a CustomPainter that places each body at its absolute degree, and the placement chips under it read astrology_context.placements; only the text cards read sections. That wasn't always true. Until the change that added the birth-chart reading flow, the wheel was drawn from seven hard-coded degrees (the Sun at 30°, the Moon at 120°, Mercury at 45°), so every chart the screen showed was the same sky. The model wasn't the only part of the app making up chart data.
The request in the opening wasn't produced by a model misbehaving. It was produced by code reading a JSON shape that the pinned Kerykeion doesn't emit:
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 puts every point at the top level of its JSON (sun, moon, ascendant, first_house) and has no planets or houses collection. Every .get with a default did its defensive job perfectly: no KeyError, no crash, just empty strings and a friendly "Bilinmiyor". The horary chart, cast for the moment a question is asked, came back with blank signs the same way. The draconic chart, the birth chart measured from the Moon's north node instead of from 0° Aries, read subject.houses as an attribute. It was the only one where the missing field itself raised an error: an AttributeError, straight to a 500.
The fix gave the advanced-astrology and horary services their own point readers, and one decision in the first set matters more than the rest: when a point has no absolute degree, _point_abs raises instead of returning a default. The return searches below run on that degree, so a chart that can't be read now stops the request before any prompt is built. The sign and degree readers, in both sets, still fall back to "Bilinmiyor" and 0.0.
A default on the computed side is a fact you made up.
Reading the right fields exposed the next problem, which the old code had already written down in its own comments. For the solar return: "Just calculate chart on birthday of target_year". The lunar return took the 15th of the target month at noon: "A real lunar return needs precise ephemeris solving which is heavy. This is a MVP approx."
A return is defined by an angle: the moment the transiting Sun, or for a lunar return the Moon, reaches its natal longitude again. So the rewrite treats it as a search:
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_distanceThe distance helper handles the seam at 0°: 29.9° Pisces and 0.1° Aries come out 0.2° apart instead of almost a full circle. The search scans in coarse steps (every six hours across two days either side of the birthday for the Sun, every twelve hours across the month for the Moon), then refines around the best step every 30 minutes. Counting the natal chart and the return chart itself, that's 52 chart computations for a solar return and 105 for a lunar one in a 31-day month. It's brute force, and nothing about it depends on a model.
To see what the shortcuts had cost, I ran the old and new logic on the profile the accuracy tests use: born 1 January 1990 at 12:00 in Istanbul. For 2026 the search lands at 07:00 on 1 January, 0.01° from the natal Sun. The birthday shortcut at 12:00 is only 0.22° off in longitude, but five hours is a long time for the ascendant: between 07:00 and 12:00 it moves more than 90°, from Sagittarius to Pisces, and the solar return instruction builds the whole year on two placements, the rising sign and the Moon's sign. The lunar shortcut is worse. On 15 January at noon, the Moon is 75.6° from its natal degree, in Sagittarius instead of Pisces. Whatever that chart is, it isn't a lunar return.
Here is the opening request, rebuilt on the current code for the same profile:
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°Nothing is blank, and every value comes from the ephemeris; the Moon landing in Gemini, like the format example's, is a coincidence of this birth date. The tests check a property rather than a snapshot, a search that ends within half a degree (distance < 0.5), and the disclaimer under a return states the measured closeness, "Yakınlık: 0.01°". Both are weaker than they look; the list of open gaps below says why.
Before the fix, a reply that didn't parse as JSON raised an error that blamed the stars, and the route turned it into a 500. The one part of the work that didn't depend on a model was thrown away with it.
Now a parse failure produces a structured summary instead: one section saying the chart was computed but the interpretation didn't arrive in the expected format, the detected points from the computed chart, is_placeholder: true and, for returns, the closeness disclaimer. The three endpoints that answered 500 in my rebuild of the old code answer 200 with that summary now. Two tests pin the fallback for solar and lunar returns, and an endpoint test posts a draconic reading through the placeholder provider and expects a 200.
That's the rule I'd apply to any pipeline shaped like this one: when the narration fails, degrade to the facts. The ephemeris result is the most valuable thing in the response and the cheapest to keep.
The birth-chart prompt is where the rule is spelled out for the model:
"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."
),It tells the model to prefer exact placements and to admit uncertainty, and it pins the output to a shape the code can parse. But a prompt is a request. What makes the boundary hold is the code around it:
- The phone never talks to a model. Every model call goes through the backend.
- Code decides who may call a model at all. Each request names a tier (cheap, balanced, premium or vision) instead of a model, and a feature has to be on an allowlist before its requests reach a real provider. Everything else goes to the fallback, and the default fallback is a placeholder that makes no external call.
- Output is parsed before anyone sees it. Only sections with a key, a title and text survive; anything that doesn't parse becomes one plain section. The computed context in the response is never touched.
- The fact side is tested without a model. No test in the suite makes a real model call: the API tests run on the placeholder provider, and the provider tests stub the HTTP layer. On the current code all 120 backend tests pass, and the Flutter analyzer reports no issues. That's only possible because the facts don't depend on the model.
The vision path is the one place with nothing to compute: /birth-chart/read-image takes a photo of a chart someone already has, and the model extracts what it can read. Here its output does land in astrology_context, as extracted_chart next to requires_visual_verification: true, labelled for what it is. The wheel still draws only from computed placements, which this path never produces.
That holds for the birth chart. Returns, draconic charts and horary readings have a fact field of their own, planets_detected, which the app shows as chips. The prompts ask the model to fill it in, and the code keeps whatever comes back:
planets_detected=data.get('planets_detected', fallback_planets),The computed list is only the fallback, for a missing key or a reply that doesn't parse, and the horary service does the same. Horary can hit this as soon as a real provider is configured: of these four features, it's the only one on the allowlist. In my test, a stub in the provider's place answered with a list of its own, "Yükselen Boğa" and "Ay İkizler" (Rising Taurus, Moon Gemini), and the response shipped it with is_placeholder: false, for a sky the code had just computed as rising Gemini with the Moon in Aries. Unlike the vision path, nothing marks the list as the model's.
Solar returns, lunar returns and draconic charts aren't on the allowlist, so with the default fallback they always come back as the facts-only summary and get no real narration yet. Their prompts are primed all the same: the return format examples name concrete signs, and a stub that echoes the solar one, injected past the allowlist, ships Rising Taurus for a chart that rises in Sagittarius. The fix is a one-line change in each of the two services: the computed list, always.
Testing the line end to end for this post, with stub models standing in for real ones, turned up more gaps than that one. Two are already closed. Placements now carry their house numbers: Kerykeion names houses with words like Tenth_House, the parser only looked for digits, and every house went out as null. And a calculated chart's reading no longer ships the MVP-era disclaimer that called it temporary data; its context now tells the model to interpret only the listed placements. These are still open:
- Unknown birth time is a rule without a fact. The app sends
is_birth_time_unknown, and the chart is computed for noon. The house fix already drops houses in that case, because cusps cast for a guessed time would be invented. The ascendant is a cusp too, and it still goes out: for a 1 January 1990 birth in Istanbul, rising Aries at 16.92° at noon, Sagittarius at 06:30, with the same ephemeris disclaimer a known birth time gets. Nothing in the context says the time was unknown, so the instruction to state uncertainty has nothing to fire on. - The context still carries canned text. Next to the placements, the chart code adds four summary lines, and three of them are the same for every chart. One credits "your chart's wonderful aspects", and the chart code never computes any.
- Two readers still default. The sign and degree readers should raise the way
_point_absdoes. - The solar test would pass the old shortcut. Its window opens at noon on the birthday, so the search it checks returns the shortcut itself, 12:00 at 0.22°, and passes
< 0.5; only the lunar test, with its shortcut 75.6° away, would catch a guess. The solar one should use the production window, two days either side, and a bound the shortcut can't meet. - The closeness is measured on the Sun. The search refines in 30-minute steps. For the test profile the Sun's true crossing is at 06:47, thirteen minutes before the reported 07:00, and the ascendant, which the reading leans on, moves 2.8° in that time: the same sign here, not necessarily for a chart near a sign boundary.
- The engine call is deprecated. Kerykeion 5 routes
AstrologicalSubjectthrough its backward-compatibility module and warns on every call: 312 of the 314 warnings in the current suite are that notice.
None of this is specific to astrology. Anything that pairs a deterministic engine with a model that explains it (pricing, routing, medication schedules, sports statistics) has the same two sides and the same temptation to let the prose side touch the numbers.
A prompt can ask a model not to invent the sky. Only code can make sure it never has to: hand it a complete sky, keep the original where it can't write, and when the narration fails, ship the sky anyway. The ephemeris computes, the model narrates, and the line between them belongs where a test can see it.


