Özgür Işık Damar
Back to writing

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.

By Özgür Işık DamarSenior software engineer · Türkiye

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.

Two jobs, and a line between them

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.

Diagram. Top row: birth data flows into Kerykeion on the Swiss Ephemeris, and a copy of the result crosses a dashed line into the prompt, which goes to the LLM with a tier per request. Bottom left, the computed side: the fact fields of a response, astrology_context for a birth chart and planets_detected for returns, draconic charts and horary readings, feed the app's chart wheel and chips. Bottom right, the narrated side: the model's parsed JSON holds sections, which feed the text cards, and a planets_detected list. An amber arrow runs from the model's planets_detected back into the planets_detected fact field.
The model gets a copy of the chart and should return only text. The one arrow that points back across the line is the gap still open.

The quietest failure is an empty field

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.

A return is a search, not a birthday

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_distance

The 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.

Two line charts computed with the app's own code for a profile born 1 January 1990 at 12:00 in Istanbul. Left: the Sun's distance from its natal degree across the search's refine window, 31 December 22:00 to 1 January 14:00, bottoms out just before 07:00; the search result at 07:00 is 0.01 degrees away with a Sagittarius ascendant, and the old birthday shortcut at 12:00 sits on the slope at 0.22 degrees, rising in Pisces. Right: the Moon's distance from its natal degree through January 2026 bottoms out on 21 January at 14:00, 0.11 degrees, Moon in Pisces; the old shortcut on the 15th at noon sits at 75.6 degrees, Moon in Sagittarius.
A return is the bottom of a distance curve. The solar shortcut sat 0.22° up the slope, close enough to look right and five hours too late for the rising sign; the lunar one was nowhere near the bottom.

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.

When the model fails, ship the chart

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 prompt asks, the code decides

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.

The one field that still crosses the line

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.

What this doesn't do (yet)

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_abs does.
  • 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 AstrologicalSubject through its backward-compatibility module and warns on every call: 312 of the 314 warnings in the current suite are that notice.
Diagram of the unknown birth time path. Fact lane: the Flutter app sends is_birth_time_unknown true without a birth time, calculate_real_chart falls back to hour 12 and computes a noon chart with houses set to null, and the astrology_context in the prompt has no time flag, highlighted in amber. Rule lane: the prompt instruction says that if birth time is unknown or a placement is missing, the model should state uncertainty instead of inventing it. The LLM gets the chart, rising Aries at 16.92 degrees, and the rule, but not the trigger. Below: with no birth time the chart rises in Aries; with 06:30 on the same date and place it rises in Sagittarius.
The rule made it into the prompt; the fact that should trigger it never did. The house fix drops the cusps for an unknown time, but the ascendant still goes out with the confidence of a known birth time.

If you're building the same split

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.