<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Luis Pham</title>
    <description>The latest articles on DEV Community by Luis Pham (@luispham).</description>
    <link>https://dev.to/luispham</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3901735%2Fbdbc03f5-9ac3-4fe0-8df9-e16707cc7167.png</url>
      <title>DEV Community: Luis Pham</title>
      <link>https://dev.to/luispham</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/luispham"/>
    <language>en</language>
    <item>
      <title>I Turned the GetBirthChart Astrology Engine Into a Python Package</title>
      <dc:creator>Luis Pham</dc:creator>
      <pubDate>Sat, 22 Aug 2026 07:20:40 +0000</pubDate>
      <link>https://dev.to/getbirthchart/i-turned-the-getbirthchart-astrology-engine-into-a-python-package-1mng</link>
      <guid>https://dev.to/getbirthchart/i-turned-the-getbirthchart-astrology-engine-into-a-python-package-1mng</guid>
      <description>&lt;p&gt;A few weeks ago, I open-sourced the Python calculation engine behind GetBirthChart.&lt;/p&gt;

&lt;p&gt;The source was public, but using it still meant cloning the repository, setting up the environment, and understanding the internal package structure.&lt;/p&gt;

&lt;p&gt;That is useful if you want to inspect the code.&lt;/p&gt;

&lt;p&gt;It is less useful if you just want to use the engine.&lt;/p&gt;

&lt;p&gt;So I packaged it properly.&lt;/p&gt;

&lt;p&gt;Today, &lt;code&gt;gbc-astro&lt;/code&gt; can be installed directly from PyPI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;gbc-astro
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The source remains open on GitHub.&lt;/p&gt;

&lt;p&gt;But now the calculation engine can also behave like what it really is: a Python library.&lt;/p&gt;

&lt;h2&gt;
  
  
  I didn't want to create a second engine
&lt;/h2&gt;

&lt;p&gt;This was the most important constraint.&lt;/p&gt;

&lt;p&gt;GetBirthChart already had a working calculation engine.&lt;/p&gt;

&lt;p&gt;Publishing to PyPI should not create another implementation.&lt;/p&gt;

&lt;p&gt;I wanted this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GetBirthChart
      ↓
  gbc-astro
      ↑
Python package
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GetBirthChart → engine A

PyPI           → engine B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the public package is a thin interface around the same calculation code used by GetBirthChart.&lt;/p&gt;

&lt;p&gt;A basic chart looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;gbc_astro&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;calculate_chart&lt;/span&gt;

&lt;span class="n"&gt;chart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculate_chart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1990-05-15&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;09:30&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;latitude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;51.5074&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;longitude&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mf"&gt;0.1278&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Europe/London&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bodies&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sun&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bodies&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;moon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;angles&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Underneath that function, the package still delegates to the existing engine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;calculate_chart(...)
        ↓
AstrologyEngine.natal(...)
        ↓
calculation providers
        ↓
Swiss Ephemeris
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No separate astrology math was introduced for PyPI.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the package calculates
&lt;/h2&gt;

&lt;p&gt;The engine currently handles things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;planetary positions&lt;/li&gt;
&lt;li&gt;zodiac placements&lt;/li&gt;
&lt;li&gt;houses&lt;/li&gt;
&lt;li&gt;Ascendant and Midheaven&lt;/li&gt;
&lt;li&gt;aspects&lt;/li&gt;
&lt;li&gt;retrograde state&lt;/li&gt;
&lt;li&gt;timezone-aware birth data&lt;/li&gt;
&lt;li&gt;derived chart data&lt;/li&gt;
&lt;li&gt;structured warnings&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Swiss Ephemeris is accessed through &lt;code&gt;pyswisseph&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One thing I wanted to preserve exactly was how the engine handles missing information.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unknown birth time is still unknown
&lt;/h2&gt;

&lt;p&gt;This has been one of the design decisions I care about most in GetBirthChart.&lt;/p&gt;

&lt;p&gt;If the birth time is unknown, the library does not silently invent one.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;gbc_astro&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;calculate_chart&lt;/span&gt;

&lt;span class="n"&gt;chart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculate_chart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1990-05-15&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;latitude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;51.5074&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;longitude&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mf"&gt;0.1278&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Europe/London&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;time=None&lt;/code&gt; means the birth time is actually unknown.&lt;/p&gt;

&lt;p&gt;The engine does not quietly substitute 12:00.&lt;/p&gt;

&lt;p&gt;Time-dependent values are omitted instead.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;birth_time_known = False
angles = {}
houses = ()
rising = None
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters because the Ascendant and houses depend on birth time.&lt;/p&gt;

&lt;p&gt;A library should not turn missing input into fake precision just because a complete-looking result is easier to return.&lt;/p&gt;

&lt;h2&gt;
  
  
  The HTTP API is optional
&lt;/h2&gt;

&lt;p&gt;GetBirthChart also uses the engine behind a FastAPI service.&lt;/p&gt;

&lt;p&gt;But someone installing a calculation library should not need FastAPI and Uvicorn unless they actually want the HTTP layer.&lt;/p&gt;

&lt;p&gt;The normal installation is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;gbc-astro
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the API dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"gbc-astro[api]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the architecture stays fairly simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Python application
       ↓
   gbc-astro

HTTP service
       ↓
FastAPI adapter
       ↓
   gbc-astro
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same engine, different interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Package version and engine version are not always the same thing
&lt;/h2&gt;

&lt;p&gt;Packaging also forced me to separate two versions that are easy to confuse.&lt;/p&gt;

&lt;p&gt;The current package version is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1.12.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The calculation engine version is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1.12.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That difference is intentional.&lt;/p&gt;

&lt;p&gt;Version &lt;code&gt;1.12.2&lt;/code&gt; was a packaging and distribution release.&lt;/p&gt;

&lt;p&gt;It did not change the underlying astrology calculations.&lt;/p&gt;

&lt;p&gt;So I did not bump the calculation engine version just to make the numbers look identical.&lt;/p&gt;

&lt;p&gt;I would rather have the versions describe what actually changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making a release traceable
&lt;/h2&gt;

&lt;p&gt;Once the package was public, I also wanted a clear path from an installed Python package back to its source.&lt;/p&gt;

&lt;p&gt;The project now has three public representations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub
  ↓
source code

PyPI
  ↓
installable package

Zenodo
  ↓
archived release
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;1.12.2&lt;/code&gt; source release is archived on Zenodo with this DOI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10.5281/zenodo.22052875
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A DOI does not validate the astrology.&lt;/p&gt;

&lt;p&gt;It does not prove that a calculation is correct.&lt;/p&gt;

&lt;p&gt;It simply gives this particular software release a persistent, citable identifier.&lt;/p&gt;

&lt;p&gt;For open-source infrastructure, that is useful.&lt;/p&gt;

&lt;p&gt;Someone looking at the package later can trace it back to a specific archived release instead of relying only on whatever happens to be on the repository's main branch at that time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packaging exposed assumptions
&lt;/h2&gt;

&lt;p&gt;The interesting part of turning the engine into a package wasn't uploading a wheel.&lt;/p&gt;

&lt;p&gt;It was deciding what the public contract should be.&lt;/p&gt;

&lt;p&gt;Which functions should be public?&lt;/p&gt;

&lt;p&gt;Which exceptions should developers rely on?&lt;/p&gt;

&lt;p&gt;What happens when birth time is missing?&lt;/p&gt;

&lt;p&gt;Which dependencies belong in the core package?&lt;/p&gt;

&lt;p&gt;Should the HTTP layer be installed by default?&lt;/p&gt;

&lt;p&gt;Can a clean Python environment reproduce a real chart?&lt;/p&gt;

&lt;p&gt;Can a published package be traced back to its source?&lt;/p&gt;

&lt;p&gt;Those questions already existed when the engine was private infrastructure.&lt;/p&gt;

&lt;p&gt;Publishing it just made them harder to ignore.&lt;/p&gt;

&lt;p&gt;And I think that's one of the useful side effects of open sourcing internal systems.&lt;/p&gt;

&lt;p&gt;It forces implicit assumptions to become explicit interfaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Install the package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;gbc-astro
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PyPI:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pypi.org/project/gbc-astro/1.12.2/" rel="noopener noreferrer"&gt;https://pypi.org/project/gbc-astro/1.12.2/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Source code:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/getbirthchart-com/gbc-astro-engine" rel="noopener noreferrer"&gt;https://github.com/getbirthchart-com/gbc-astro-engine&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Archived release:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://zenodo.org/records/22052875" rel="noopener noreferrer"&gt;https://zenodo.org/records/22052875&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;DOI:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://doi.org/10.5281/zenodo.22052875" rel="noopener noreferrer"&gt;https://doi.org/10.5281/zenodo.22052875&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GetBirthChart:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://getbirthchart.com/" rel="noopener noreferrer"&gt;https://getbirthchart.com/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'm going to keep documenting the less obvious parts of building this engine — especially time handling, house calculations, testing, uncertainty, and the boundary between deterministic calculations and AI interpretation.&lt;/p&gt;

</description>
      <category>python</category>
      <category>opensource</category>
      <category>astrology</category>
      <category>programming</category>
    </item>
    <item>
      <title>Separating Deterministic Calculations from AI Interpretation</title>
      <dc:creator>Luis Pham</dc:creator>
      <pubDate>Fri, 21 Aug 2026 06:24:18 +0000</pubDate>
      <link>https://dev.to/getbirthchart/separating-deterministic-calculations-from-ai-interpretation-85f</link>
      <guid>https://dev.to/getbirthchart/separating-deterministic-calculations-from-ai-interpretation-85f</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3qsbu7f9iqq058flmaj1.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3qsbu7f9iqq058flmaj1.jpg" alt=" " width="800" height="676"&gt;&lt;/a&gt;One rule in GetBirthChart has become more important as I’ve added more AI features:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The AI does not get to invent the chart.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It can explain the chart.&lt;/p&gt;

&lt;p&gt;It can compare things in the chart.&lt;/p&gt;

&lt;p&gt;It can synthesize several placements into a larger pattern.&lt;/p&gt;

&lt;p&gt;But the underlying facts come from code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The easiest architecture is also the one I didn't want
&lt;/h2&gt;

&lt;p&gt;You could build an astrology AI product like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User:
"I was born on Nov 3, 1992 at 2:35 PM in Hanoi."

        ↓

LLM:
"Calculate the birth chart and explain it."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Very little infrastructure.&lt;/p&gt;

&lt;p&gt;Very impressive demo.&lt;/p&gt;

&lt;p&gt;The problem is that one model is now responsible for two completely different jobs.&lt;/p&gt;

&lt;p&gt;First:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Where was Saturn?
What was the Ascendant?
Which house contained Venus?
Is Mars retrograde?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What might those things mean together?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first group has deterministic answers under a given calculation methodology.&lt;/p&gt;

&lt;p&gt;The second group is interpretation.&lt;/p&gt;

&lt;p&gt;I don't want them mixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture is deliberately less magical
&lt;/h2&gt;

&lt;p&gt;GetBirthChart works more like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;birth input
    ↓
Python calculation engine
    ↓
canonical chart facts
    ↓
evidence/context selection
    ↓
LLM
    ↓
interpretation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Python engine uses Swiss Ephemeris and produces structured chart data.&lt;/p&gt;

&lt;p&gt;The model receives that data.&lt;/p&gt;

&lt;p&gt;So instead of asking the LLM:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which sign is the Moon in?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I can give it something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"moon"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aquarius"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"degreeInSign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;21.29&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"house"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The language model has a much more appropriate job now:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Explain this placement in the context of the other available chart evidence.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The goal is not to turn each placement into an isolated personality statement.&lt;/p&gt;

&lt;p&gt;A useful interpretation looks at how placements, houses and aspects interact — where they reinforce one another, where they create tension, and where similar themes appear repeatedly across the chart.&lt;/p&gt;

&lt;p&gt;The model can then synthesize those relationships into a more coherent reading instead of simply stitching together separate descriptions for the Sun, Moon, Venus or other placements.&lt;/p&gt;

&lt;h2&gt;
  
  
  This gives the system a source of truth
&lt;/h2&gt;

&lt;p&gt;Imagine the engine returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Moon: Aquarius
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the generated interpretation starts talking about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;your Scorpio Moon
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s a contradiction I can detect.&lt;/p&gt;

&lt;p&gt;Without a deterministic chart model, there isn’t a clean authority to compare the output against.&lt;/p&gt;

&lt;p&gt;The LLM becomes the calculator, database and writer at the same time.&lt;/p&gt;

&lt;p&gt;That makes QA much harder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Missing data is even more important
&lt;/h2&gt;

&lt;p&gt;Unknown birth time is a good example.&lt;/p&gt;

&lt;p&gt;If birth time is unknown, the engine intentionally omits time-dependent fields.&lt;/p&gt;

&lt;p&gt;The model might receive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"birthTimeKnown"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"angles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"houses"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"warnings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UNKNOWN_BIRTH_TIME"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no Rising sign in the evidence.&lt;/p&gt;

&lt;p&gt;There is no house placement.&lt;/p&gt;

&lt;p&gt;That’s much stronger than putting this sentence in the prompt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Do not make up an Ascendant.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Prompt instructions are useful.&lt;/p&gt;

&lt;p&gt;Structural absence is better.&lt;/p&gt;

&lt;h2&gt;
  
  
  I like evidence-based prompts more than giant context dumps
&lt;/h2&gt;

&lt;p&gt;Another pattern I’ve been using is selecting chart facts as evidence instead of dumping every available field into every generation.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"moon.sign"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Aquarius"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"moon.house"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"moon.square.saturn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"orb"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;3.2&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a piece of interpretation can be tied back to actual chart facts.&lt;/p&gt;

&lt;p&gt;This is useful for the UI too.&lt;/p&gt;

&lt;p&gt;A user can eventually ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Why are you saying this?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and the product has something better to show than:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The AI thought so.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It can show the relevant calculated evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI gets better when I give it less authority
&lt;/h2&gt;

&lt;p&gt;This was a little counterintuitive.&lt;/p&gt;

&lt;p&gt;The instinct with LLMs is to give them more.&lt;/p&gt;

&lt;p&gt;More context.&lt;/p&gt;

&lt;p&gt;More freedom.&lt;/p&gt;

&lt;p&gt;More responsibility.&lt;/p&gt;

&lt;p&gt;But I’ve found the product gets more reliable when the model owns fewer deterministic decisions.&lt;/p&gt;

&lt;p&gt;I want code to handle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;timezone conversion
planet positions
houses
aspect geometry
retrograde
schema validation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I want the LLM to handle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;explanation
synthesis
comparison
follow-up reasoning
language
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That division is much easier to test.&lt;/p&gt;

&lt;h2&gt;
  
  
  This isn't specific to astrology
&lt;/h2&gt;

&lt;p&gt;The same architecture makes sense in other domains.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SQL query
   ↓
actual metrics
   ↓
AI explanation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;compiler / AST
   ↓
structured findings
   ↓
AI explanation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deterministic pricing engine
   ↓
quote
   ↓
AI explains the quote
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If something important can be calculated or retrieved deterministically, I increasingly prefer doing that before the model sees it.&lt;/p&gt;

&lt;p&gt;Then the AI can work with evidence instead of pretending to be the evidence source.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I use now
&lt;/h2&gt;

&lt;p&gt;The rule is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Code determines what is in the chart.&lt;br&gt;
AI helps explain what the chart may mean.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That doesn’t eliminate hallucinations.&lt;/p&gt;

&lt;p&gt;It doesn’t solve every quality problem.&lt;/p&gt;

&lt;p&gt;But it gives the system a stable foundation.&lt;/p&gt;

&lt;p&gt;And once you have that, everything from testing to UI explanations gets easier.&lt;/p&gt;

&lt;p&gt;The calculation engine behind this architecture is open source here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/getbirthchart-com/gbc-astro-engine" rel="noopener noreferrer"&gt;https://github.com/getbirthchart-com/gbc-astro-engine&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The hosted product using it is:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://getbirthchart.com/" rel="noopener noreferrer"&gt;https://getbirthchart.com/&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How I Model Aspects and Orbs in a Python Astrology Engine</title>
      <dc:creator>Luis Pham</dc:creator>
      <pubDate>Fri, 21 Aug 2026 06:18:47 +0000</pubDate>
      <link>https://dev.to/getbirthchart/how-i-model-aspects-and-orbs-in-a-python-astrology-engine-pob</link>
      <guid>https://dev.to/getbirthchart/how-i-model-aspects-and-orbs-in-a-python-astrology-engine-pob</guid>
      <description>&lt;p&gt;I like working on aspect calculations because the astrology terminology disappears pretty quickly once you get into the code.&lt;/p&gt;

&lt;p&gt;At the calculation level, an aspect is basically:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How close are two points on a circle to a configured angle?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That turns the problem into geometry, tolerances and a few interesting edge cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with angular distance
&lt;/h2&gt;

&lt;p&gt;Suppose two planets have longitudes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;12°
102°
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Their separation is 90°.&lt;/p&gt;

&lt;p&gt;That’s easy.&lt;/p&gt;

&lt;p&gt;But this pair is more interesting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;358°
2°
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A normal absolute difference gives you 356°.&lt;/p&gt;

&lt;p&gt;On a circle, they’re actually 4° apart.&lt;/p&gt;

&lt;p&gt;So one of the basic utilities looks conceptually like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;angular_distance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;angular_distance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;358&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That simple normalization is the base of the rest of the aspect system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then define target angles
&lt;/h2&gt;

&lt;p&gt;For the major aspects, you’re comparing against angles such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0°    conjunction
60°   sextile
90°   square
120°  trine
180°  opposition
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If everything had to be exact, the implementation would be trivial.&lt;/p&gt;

&lt;p&gt;But astrology uses orbs.&lt;/p&gt;

&lt;p&gt;So a separation of 92° can still be treated as a square depending on the calculation profile.&lt;/p&gt;

&lt;p&gt;The orb is basically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;orb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual_distance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;target_angle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;orb&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;allowed_orb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# aspect matched
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  I keep orb rules in a profile
&lt;/h2&gt;

&lt;p&gt;This is one of those places where hidden constants are really tempting.&lt;/p&gt;

&lt;p&gt;Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;MAX_ORB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and move on.&lt;/p&gt;

&lt;p&gt;I prefer putting this kind of behavior into an explicit calculation profile.&lt;/p&gt;

&lt;p&gt;That way the result isn’t just:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Venus square Saturn
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It can be understood as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Venus square Saturn
under this aspect profile
with this orb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That makes the methodology easier to inspect and makes future changes much less messy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The engine reports geometry, not sentiment
&lt;/h2&gt;

&lt;p&gt;This was another boundary I wanted to keep clean.&lt;/p&gt;

&lt;p&gt;The core can calculate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;planet A
planet B
aspect type
orb
phase
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It should not calculate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;good
bad
easy
terrible relationship
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are interpretation-layer concepts.&lt;/p&gt;

&lt;p&gt;I don’t want this in the chart model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aspect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"square"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"meaning"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"negative"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The geometry doesn’t know that.&lt;/p&gt;

&lt;p&gt;It knows there is a 90-degree relationship within the configured tolerance.&lt;/p&gt;

&lt;p&gt;That separation makes the output usable by different interpretation systems later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applying vs separating
&lt;/h2&gt;

&lt;p&gt;Once the static aspect is identified, there’s another useful piece of information: motion.&lt;/p&gt;

&lt;p&gt;Is the pair moving toward exactness or away from it?&lt;/p&gt;

&lt;p&gt;For that you need more than longitude.&lt;/p&gt;

&lt;p&gt;You need the bodies’ motion as well.&lt;/p&gt;

&lt;p&gt;The ephemeris provides longitudinal speed, which lets the engine derive whether the aspect is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;applying
exact
separating
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the structured result can look conceptually like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"square"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"orb"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"phase"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"applying"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application can decide whether and how to interpret that.&lt;/p&gt;

&lt;p&gt;Again, the core just reports the calculated state.&lt;/p&gt;

&lt;h2&gt;
  
  
  This code is nice to test
&lt;/h2&gt;

&lt;p&gt;Aspect math gives you some good invariants.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;distance(a, b) == distance(b, a)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The distance must always be in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0° &amp;lt;= distance &amp;lt;= 180°
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And wraparound behavior needs direct tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;359° vs 1° = 2°
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Orb boundaries are useful too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;orb == maximum → match
orb &amp;gt; maximum → no match
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are the kinds of tests I trust much more than checking a few screenshots from the frontend.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pattern I keep using
&lt;/h2&gt;

&lt;p&gt;This part of the engine is a good example of how I like to structure domain logic.&lt;/p&gt;

&lt;p&gt;Start with the smallest deterministic representation.&lt;/p&gt;

&lt;p&gt;Instead of:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Venus square Saturn means relationship restriction.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;start with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Venus longitude
Saturn longitude
angular separation
target angle
allowed tolerance
motion
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Get that part right first.&lt;/p&gt;

&lt;p&gt;Then let another layer decide what, if anything, the relationship means.&lt;/p&gt;

&lt;p&gt;It keeps the calculation code boring.&lt;/p&gt;

&lt;p&gt;I mean that as a compliment.&lt;/p&gt;

&lt;p&gt;The aspect implementation is part of the open-source GetBirthChart Python engine:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/getbirthchart-com/gbc-astro-engine" rel="noopener noreferrer"&gt;https://github.com/getbirthchart-com/gbc-astro-engine&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>programming</category>
    </item>
    <item>
      <title>The Hard Part of Birth Chart Calculations Isn't the Zodiac. It's Time.</title>
      <dc:creator>Luis Pham</dc:creator>
      <pubDate>Fri, 21 Aug 2026 06:16:17 +0000</pubDate>
      <link>https://dev.to/getbirthchart/the-hard-part-of-birth-chart-calculations-isnt-the-zodiac-its-time-5h06</link>
      <guid>https://dev.to/getbirthchart/the-hard-part-of-birth-chart-calculations-isnt-the-zodiac-its-time-5h06</guid>
      <description>&lt;p&gt;The most annoying bugs I’ve dealt with while building a birth-chart engine were not about zodiac signs.&lt;/p&gt;

&lt;p&gt;They were about time.&lt;/p&gt;

&lt;p&gt;And the deeper I got into it, the more I realized that “birth time” is a much less simple input than it looks on a form.&lt;/p&gt;

&lt;h2&gt;
  
  
  A local datetime isn't enough
&lt;/h2&gt;

&lt;p&gt;Take this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1990-05-15 09:30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks precise.&lt;/p&gt;

&lt;p&gt;But precise where?&lt;/p&gt;

&lt;p&gt;Without a timezone, it doesn’t identify an instant.&lt;/p&gt;

&lt;p&gt;So the calculation API I use takes both the local datetime and the IANA timezone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;chart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;natal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;local_datetime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1990-05-15T09:30:00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Europe/London&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;latitude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;51.5074&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;longitude&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mf"&gt;0.1278&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I prefer an IANA zone like &lt;code&gt;Europe/London&lt;/code&gt; over something like &lt;code&gt;UTC+1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The former describes a real timezone with historical rules.&lt;/p&gt;

&lt;p&gt;The latter is just an offset.&lt;/p&gt;

&lt;h2&gt;
  
  
  DST makes this more interesting
&lt;/h2&gt;

&lt;p&gt;During a daylight-saving fall-back transition, the same local clock time can occur twice.&lt;/p&gt;

&lt;p&gt;So a value like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;01:30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;may correspond to two different UTC instants.&lt;/p&gt;

&lt;p&gt;That means the input looks exact to the user while still being ambiguous to the calculation engine.&lt;/p&gt;

&lt;p&gt;There’s a strong temptation to quietly choose one.&lt;/p&gt;

&lt;p&gt;I don’t like doing that.&lt;/p&gt;

&lt;p&gt;If the input is genuinely ambiguous, I’d rather make the ambiguity explicit.&lt;/p&gt;

&lt;p&gt;The same thing happens in the opposite direction during spring transitions.&lt;/p&gt;

&lt;p&gt;Some local clock times never existed.&lt;/p&gt;

&lt;p&gt;If the clock jumped directly from 01:59 to 03:00, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;02:30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;isn’t a valid local instant.&lt;/p&gt;

&lt;p&gt;Again, silently “fixing” it is convenient.&lt;/p&gt;

&lt;p&gt;But now the software has changed the user’s data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then there’s the bigger problem: no birth time
&lt;/h2&gt;

&lt;p&gt;A lot of people simply don’t know what time they were born.&lt;/p&gt;

&lt;p&gt;This creates a product decision.&lt;/p&gt;

&lt;p&gt;You can say:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unknown → 12:00 PM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and suddenly everything works.&lt;/p&gt;

&lt;p&gt;You get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ascendant&lt;/li&gt;
&lt;li&gt;houses&lt;/li&gt;
&lt;li&gt;house cusps&lt;/li&gt;
&lt;li&gt;Midheaven&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The object looks complete.&lt;/p&gt;

&lt;p&gt;But none of those values are based on a birth time the person actually supplied.&lt;/p&gt;

&lt;p&gt;That bothered me enough that I made unknown birth time a first-class state in the engine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;chart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;natal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;local_datetime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1992-11-03&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Asia/Ho_Chi_Minh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;latitude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;21.0285&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;longitude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;105.8542&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;unknown_time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result intentionally omits time-dependent fields.&lt;/p&gt;

&lt;p&gt;No fake Rising sign.&lt;/p&gt;

&lt;p&gt;No fake houses.&lt;/p&gt;

&lt;p&gt;No fake precision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incomplete output can be more correct
&lt;/h2&gt;

&lt;p&gt;Developers generally like complete data structures.&lt;/p&gt;

&lt;p&gt;Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ascendant"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Cancer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"house"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;feels satisfying.&lt;/p&gt;

&lt;p&gt;This:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ascendant"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"house"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;feels like a failure.&lt;/p&gt;

&lt;p&gt;But if the user doesn’t know the time, the second object is actually saying something more truthful.&lt;/p&gt;

&lt;p&gt;The system is distinguishing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I know the value.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;from:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I have a field available where a value could go.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Those aren’t the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Moon makes it even more interesting
&lt;/h2&gt;

&lt;p&gt;Even without a birth time, you can still calculate a lot from the date.&lt;/p&gt;

&lt;p&gt;But not everything has the same level of certainty.&lt;/p&gt;

&lt;p&gt;The Moon moves fast enough that its exact degree — and occasionally even its sign near a boundary — can depend on the time of day.&lt;/p&gt;

&lt;p&gt;So unknown-time handling can’t just be:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;remove the Ascendant and everything else is exact.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You need to communicate uncertainty where it actually exists.&lt;/p&gt;

&lt;p&gt;That’s one reason I like carrying warnings as structured output from the core rather than bolting them onto the UI later.&lt;/p&gt;

&lt;p&gt;The UI should be reflecting the calculation state, not inventing its own policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  This changed how I think about UX
&lt;/h2&gt;

&lt;p&gt;Initially, I thought timezone and missing-time behavior were backend concerns.&lt;/p&gt;

&lt;p&gt;Now I think they’re part of the product experience.&lt;/p&gt;

&lt;p&gt;When the site tells someone:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Rising sign requires a reliable birth time.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;that should not be marketing copy.&lt;/p&gt;

&lt;p&gt;It should describe an actual engine constraint.&lt;/p&gt;

&lt;p&gt;That connection is important to me.&lt;/p&gt;

&lt;p&gt;The best trust messages in software are usually backed by behavior.&lt;/p&gt;

&lt;p&gt;Not promises.&lt;/p&gt;

&lt;h2&gt;
  
  
  This isn’t really an astrology-specific lesson
&lt;/h2&gt;

&lt;p&gt;The same problem appears whenever software works with incomplete or uncertain inputs.&lt;/p&gt;

&lt;p&gt;Geolocation.&lt;/p&gt;

&lt;p&gt;Financial records.&lt;/p&gt;

&lt;p&gt;Sensor data.&lt;/p&gt;

&lt;p&gt;AI-extracted structured fields.&lt;/p&gt;

&lt;p&gt;User demographics.&lt;/p&gt;

&lt;p&gt;There’s always a temptation to turn missing data into complete-looking data because downstream code becomes easier.&lt;/p&gt;

&lt;p&gt;Sometimes the better API is the one willing to say:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unknown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I’ve become much more comfortable with that while building this engine.&lt;/p&gt;

&lt;p&gt;The Python core is open source here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/getbirthchart-com/gbc-astro-engine" rel="noopener noreferrer"&gt;https://github.com/getbirthchart-com/gbc-astro-engine&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you work with timezone-heavy applications, the astrology part is probably the least interesting thing in that repo. The time-handling decisions are where a lot of the real engineering lives.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>python</category>
    </item>
    <item>
      <title>Building a Natal Chart Engine in Python: From Birth Data to Planetary Positions</title>
      <dc:creator>Luis Pham</dc:creator>
      <pubDate>Thu, 20 Aug 2026 09:24:22 +0000</pubDate>
      <link>https://dev.to/getbirthchart/building-a-natal-chart-engine-in-python-from-birth-data-to-planetary-positions-4156</link>
      <guid>https://dev.to/getbirthchart/building-a-natal-chart-engine-in-python-from-birth-data-to-planetary-positions-4156</guid>
      <description>&lt;p&gt;When I started working on a natal-chart engine, I assumed the interesting part would be zodiac calculations.&lt;/p&gt;

&lt;p&gt;It wasn't.&lt;/p&gt;

&lt;p&gt;Mapping a longitude to a zodiac sign is one of the easier parts.&lt;/p&gt;

&lt;p&gt;The harder problem is building a pipeline where every step has an explicit meaning.&lt;/p&gt;

&lt;p&gt;The input a user gives you is usually something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;November 3, 1992
2:35 PM
Hanoi, Vietnam
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The astronomical layer needs something much less human:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UTC instant
latitude
longitude
calculation profile
ephemeris
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The job of the engine is to get from one to the other without quietly making decisions the caller didn't ask for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: keep local time and timezone separate
&lt;/h2&gt;

&lt;p&gt;One thing I deliberately avoid is treating a local datetime as if it already describes an absolute instant.&lt;/p&gt;

&lt;p&gt;This:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1992-11-03 14:35
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is incomplete.&lt;/p&gt;

&lt;p&gt;You still need a timezone.&lt;/p&gt;

&lt;p&gt;In the engine interface, they're separate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;chart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;natal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;local_datetime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1992-11-03T14:35:00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Asia/Ho_Chi_Minh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;latitude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;21.0285&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;longitude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;105.8542&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timezone uses an IANA identifier rather than a fixed UTC offset.&lt;/p&gt;

&lt;p&gt;That matters because historical timezone and DST rules aren't always equivalent to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UTC+7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: convert the local civil time to an astronomical instant
&lt;/h2&gt;

&lt;p&gt;Once the local datetime has been resolved, the calculation layer can convert it to UTC and then to the time representation expected by the ephemeris.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;local civil time
      ↓
timezone rules
      ↓
UTC
      ↓
Julian day
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are edge cases here that are easy to miss.&lt;/p&gt;

&lt;p&gt;During a DST fall-back transition, the same clock time may occur twice.&lt;/p&gt;

&lt;p&gt;During spring-forward, some clock times don't exist at all.&lt;/p&gt;

&lt;p&gt;I prefer rejecting an ambiguous input to silently choosing one possibility.&lt;/p&gt;

&lt;p&gt;This is a general engineering principle I've become more attached to:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Ambiguity should be represented in the API, not hidden inside a default.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 3: ask an ephemeris for planetary positions
&lt;/h2&gt;

&lt;p&gt;The engine uses Swiss Ephemeris through &lt;code&gt;pyswisseph&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For each supported body, the provider gives us values including ecliptic longitude and longitude speed.&lt;/p&gt;

&lt;p&gt;A longitude might conceptually look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;221.1415°
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now we're finally at the familiar astrology part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: longitude → sign + degree
&lt;/h2&gt;

&lt;p&gt;A zodiac is a 360° circle divided into 12 sections of 30°.&lt;/p&gt;

&lt;p&gt;So the basic mapping is simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;sign_index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;longitude&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;degree_in_sign&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;longitude&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;221.1415°
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;lands in the eighth 30° segment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Scorpio 11.1415°
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This part is deterministic and uncomplicated.&lt;/p&gt;

&lt;p&gt;Which is exactly why I don't want an LLM doing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: calculate houses and angles
&lt;/h2&gt;

&lt;p&gt;Now things get more sensitive to time and location.&lt;/p&gt;

&lt;p&gt;For a known birth time, the engine can calculate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ascendant&lt;/li&gt;
&lt;li&gt;Midheaven&lt;/li&gt;
&lt;li&gt;Descendant&lt;/li&gt;
&lt;li&gt;IC&lt;/li&gt;
&lt;li&gt;house cusps&lt;/li&gt;
&lt;li&gt;planet-to-house assignments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The default profile uses Placidus, although the core supports multiple house systems.&lt;/p&gt;

&lt;p&gt;This is where latitude also becomes important.&lt;/p&gt;

&lt;p&gt;Some house systems have geographic limits.&lt;/p&gt;

&lt;p&gt;For example, I prefer raising an explicit calculation error in unsupported polar conditions rather than silently switching the user to another house system.&lt;/p&gt;

&lt;p&gt;Again: no invisible fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: derive aspects
&lt;/h2&gt;

&lt;p&gt;Once the body positions are available, aspects become a circular-distance problem.&lt;/p&gt;

&lt;p&gt;For two longitudes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;358.0&lt;/span&gt;
&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the distance is not 356°.&lt;/p&gt;

&lt;p&gt;It's 4°.&lt;/p&gt;

&lt;p&gt;A common normalization is conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;
&lt;span class="n"&gt;distance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the engine compares the distance to configured aspect angles.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;conjunction   0°
sextile      60°
square       90°
trine       120°
opposition  180°
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with different orb allowances.&lt;/p&gt;

&lt;p&gt;The engine can then publish structured facts such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"square"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"orb"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"phase"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"applying"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;instead of forcing the interpretation layer to rediscover the geometry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: normalize everything into one chart model
&lt;/h2&gt;

&lt;p&gt;This is one of the parts I underestimated.&lt;/p&gt;

&lt;p&gt;The astronomy isn't enough.&lt;/p&gt;

&lt;p&gt;The application needs a stable contract.&lt;/p&gt;

&lt;p&gt;The engine eventually turns the calculations into a normalized chart object containing things like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;subject
meta
bodies
angles
houses
aspects
derived facts
warnings
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That model is what the rest of GetBirthChart consumes.&lt;/p&gt;

&lt;p&gt;The frontend shouldn't need to know how Swiss Ephemeris works.&lt;/p&gt;

&lt;p&gt;The AI layer shouldn't need to know how to normalize circular angles.&lt;/p&gt;

&lt;p&gt;The API shouldn't implement its own astrology math.&lt;/p&gt;

&lt;p&gt;They all consume the same chart model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this separation is useful
&lt;/h2&gt;

&lt;p&gt;It creates a very clear dependency graph:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Swiss Ephemeris
      ↓
Python calculation core
      ↓
canonical chart model
      ↓
API / UI / interpretation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a planet position is wrong, I debug the core.&lt;/p&gt;

&lt;p&gt;If the API serialized it incorrectly, I debug the transport.&lt;/p&gt;

&lt;p&gt;If an interpretation is poor, I debug the interpretation layer.&lt;/p&gt;

&lt;p&gt;Without those boundaries, bugs become much harder to classify.&lt;/p&gt;

&lt;h2&gt;
  
  
  The surprising lesson
&lt;/h2&gt;

&lt;p&gt;The interesting part of an astrology engine isn't really “how do I calculate Scorpio?”&lt;/p&gt;

&lt;p&gt;It's how you handle everything around the calculation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;civil time&lt;/li&gt;
&lt;li&gt;DST ambiguity&lt;/li&gt;
&lt;li&gt;unknown inputs&lt;/li&gt;
&lt;li&gt;geographic limitations&lt;/li&gt;
&lt;li&gt;calculation profiles&lt;/li&gt;
&lt;li&gt;structured errors&lt;/li&gt;
&lt;li&gt;deterministic output&lt;/li&gt;
&lt;li&gt;test fixtures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The zodiac mapping is maybe ten lines.&lt;/p&gt;

&lt;p&gt;The trustworthiness of the software lives in everything around those ten lines.&lt;/p&gt;

</description>
      <category>python</category>
      <category>programming</category>
      <category>tutorial</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Why I Open-Sourced the Python Calculation Engine Behind GetBirthChart</title>
      <dc:creator>Luis Pham</dc:creator>
      <pubDate>Thu, 20 Aug 2026 09:21:32 +0000</pubDate>
      <link>https://dev.to/getbirthchart/why-i-open-sourced-the-python-calculation-engine-behind-getbirthchart-4cb2</link>
      <guid>https://dev.to/getbirthchart/why-i-open-sourced-the-python-calculation-engine-behind-getbirthchart-4cb2</guid>
      <description>&lt;p&gt;I’ve been building GetBirthChart for a while, and one thing kept bothering me.&lt;/p&gt;

&lt;p&gt;From the outside, a birth chart app can look like one big black box.&lt;/p&gt;

&lt;p&gt;You enter a date, time and place. A chart appears. Then an interpretation appears.&lt;/p&gt;

&lt;p&gt;But those are actually two very different problems.&lt;/p&gt;

&lt;p&gt;One is calculation.&lt;/p&gt;

&lt;p&gt;The other is interpretation.&lt;/p&gt;

&lt;p&gt;I wanted that boundary to be obvious, not just in the UI, but in the architecture itself.&lt;/p&gt;

&lt;p&gt;So I open-sourced the Python calculation engine behind GetBirthChart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part AI shouldn’t be doing
&lt;/h2&gt;

&lt;p&gt;GetBirthChart uses AI for interpretation.&lt;/p&gt;

&lt;p&gt;But I don’t want an LLM deciding where the Moon was when someone was born.&lt;/p&gt;

&lt;p&gt;That belongs in deterministic code.&lt;/p&gt;

&lt;p&gt;The architecture is roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;birth data
   ↓
Python calculation engine
   ↓
structured chart data
   ↓
application / interpretation layer
   ↓
AI + UI
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Python layer calculates the chart.&lt;/p&gt;

&lt;p&gt;The AI layer receives the result and explains it.&lt;/p&gt;

&lt;p&gt;That distinction sounds simple, but it changes a lot.&lt;/p&gt;

&lt;p&gt;If the interpretation says someone has a Scorpio Moon, there should be a calculated Moon placement behind that statement.&lt;/p&gt;

&lt;p&gt;The model shouldn’t “know” the placement from training data or try to reconstruct it itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the engine handles
&lt;/h2&gt;

&lt;p&gt;The core is written in Python and uses Swiss Ephemeris for the astronomical calculations.&lt;/p&gt;

&lt;p&gt;It handles things such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;planetary positions&lt;/li&gt;
&lt;li&gt;zodiac placements&lt;/li&gt;
&lt;li&gt;houses&lt;/li&gt;
&lt;li&gt;Ascendant and Midheaven&lt;/li&gt;
&lt;li&gt;aspects and orbs&lt;/li&gt;
&lt;li&gt;retrograde state&lt;/li&gt;
&lt;li&gt;normalized chart output&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A basic calculation looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;gbc_astro&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AstrologyEngine&lt;/span&gt;

&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AstrologyEngine&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;chart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;natal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;local_datetime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1992-11-03T14:35:00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Asia/Ho_Chi_Minh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;latitude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;21.0285&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;longitude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;105.8542&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bodies&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sun&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bodies&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;moon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part is not really that you can turn 221 degrees of longitude into Scorpio.&lt;/p&gt;

&lt;p&gt;That part is easy.&lt;/p&gt;

&lt;p&gt;The difficult parts tend to be around the edges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;timezone conversion&lt;/li&gt;
&lt;li&gt;daylight saving transitions&lt;/li&gt;
&lt;li&gt;unknown birth times&lt;/li&gt;
&lt;li&gt;house-system limits&lt;/li&gt;
&lt;li&gt;circular angle math&lt;/li&gt;
&lt;li&gt;aspect tolerances&lt;/li&gt;
&lt;li&gt;making every assumption explicit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are also the things I wanted people to be able to inspect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why make it public?
&lt;/h2&gt;

&lt;p&gt;Partly because I think calculation software should be inspectable.&lt;/p&gt;

&lt;p&gt;If someone sees a chart result and asks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Why did this calculator give me this Ascendant?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;“Trust the website” isn’t a very useful answer.&lt;/p&gt;

&lt;p&gt;With the engine public, the actual calculation path can be inspected and tested.&lt;/p&gt;

&lt;p&gt;But there was another benefit I didn’t expect as much.&lt;/p&gt;

&lt;p&gt;Open-sourcing it forced me to clean up the architecture.&lt;/p&gt;

&lt;p&gt;Private code can survive with assumptions that only the original developer understands.&lt;/p&gt;

&lt;p&gt;Public code is much less forgiving.&lt;/p&gt;

&lt;p&gt;I had to make things like calculation profiles, errors, warnings and output structures clearer.&lt;/p&gt;

&lt;p&gt;That made the production system better too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unknown birth time is a good example
&lt;/h2&gt;

&lt;p&gt;This was one of the design choices I cared about from the beginning.&lt;/p&gt;

&lt;p&gt;If someone says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I don’t know what time I was born.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;the easiest implementation is to pick a default.&lt;/p&gt;

&lt;p&gt;Noon is common.&lt;/p&gt;

&lt;p&gt;Midnight is common too.&lt;/p&gt;

&lt;p&gt;That gives you a nice complete chart.&lt;/p&gt;

&lt;p&gt;It also creates a Rising sign and houses from a birth time the user never gave you.&lt;/p&gt;

&lt;p&gt;I didn’t want to do that.&lt;/p&gt;

&lt;p&gt;The engine has an explicit unknown-time mode instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;chart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;natal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;local_datetime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1992-11-03&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Asia/Ho_Chi_Minh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;latitude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;21.0285&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;longitude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;105.8542&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;unknown_time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Time-dependent parts of the chart are then omitted rather than silently fabricated.&lt;/p&gt;

&lt;p&gt;That means the resulting object may be less complete.&lt;/p&gt;

&lt;p&gt;I think that’s a feature.&lt;/p&gt;

&lt;p&gt;I’d rather return:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ascendant: unavailable
Houses: unavailable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;than return confident-looking information based on a fake noon birth time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open source isn’t a claim that astrology is science
&lt;/h2&gt;

&lt;p&gt;This is another boundary I want to keep clear.&lt;/p&gt;

&lt;p&gt;Publishing the code makes the software inspectable.&lt;/p&gt;

&lt;p&gt;It doesn’t prove astrology.&lt;/p&gt;

&lt;p&gt;The engine answers a technical question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Given these inputs and these calculation rules, what chart does the software produce?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Interpretation is a different layer.&lt;/p&gt;

&lt;p&gt;GetBirthChart treats that layer as a reflective framework rather than a scientific prediction system.&lt;/p&gt;

&lt;p&gt;I think the product becomes more honest when those two things are separated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bigger lesson for me
&lt;/h2&gt;

&lt;p&gt;The part I like most about this architecture has very little to do with astrology.&lt;/p&gt;

&lt;p&gt;It’s this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deterministic domain logic
        ↓
structured evidence
        ↓
AI interpretation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I’m using the same idea more and more in products that involve LLMs.&lt;/p&gt;

&lt;p&gt;If something has a deterministic answer, calculate it outside the model.&lt;/p&gt;

&lt;p&gt;Give the model good evidence.&lt;/p&gt;

&lt;p&gt;Then let the model do what it’s actually good at: explaining, comparing and synthesizing.&lt;/p&gt;

&lt;p&gt;The Python engine is now public here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/getbirthchart-com/gbc-astro-engine" rel="noopener noreferrer"&gt;https://github.com/getbirthchart-com/gbc-astro-engine&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the hosted implementation is what powers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GetBirthChart:&lt;/strong&gt; &lt;a href="https://getbirthchart.com/" rel="noopener noreferrer"&gt;https://getbirthchart.com/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I’ll probably write more about the less obvious parts of building it, especially timezone handling, unknown birth times and aspect calculations. Those turned out to be much more interesting engineering problems than I expected.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>python</category>
    </item>
    <item>
      <title>What I Learned About Latency While Building a Real-Time Voice AI Agent</title>
      <dc:creator>Luis Pham</dc:creator>
      <pubDate>Fri, 08 May 2026 13:10:00 +0000</pubDate>
      <link>https://dev.to/luispham/what-i-learned-about-latency-while-building-a-real-time-voice-ai-agent-g6o</link>
      <guid>https://dev.to/luispham/what-i-learned-about-latency-while-building-a-real-time-voice-ai-agent-g6o</guid>
      <description>&lt;h1&gt;
  
  
  What I Learned About Latency While Building a Real-Time Voice AI Agent
&lt;/h1&gt;

&lt;p&gt;When I started building a real-time voice AI agent, I thought about latency mostly as an engineering problem.&lt;/p&gt;

&lt;p&gt;Reduce the delay.&lt;br&gt;&lt;br&gt;
Make the response faster.&lt;br&gt;&lt;br&gt;
Stream audio as quickly as possible.&lt;/p&gt;

&lt;p&gt;That is still true.&lt;/p&gt;

&lt;p&gt;But after working on &lt;a href="https://ringbooker.com" rel="noopener noreferrer"&gt;RingBooker&lt;/a&gt;, an AI receptionist for salons, spas, med spas, and beauty clinics, I started to think about latency differently.&lt;/p&gt;

&lt;p&gt;Latency is not only a technical metric.&lt;/p&gt;

&lt;p&gt;On a phone call, latency is part of the user experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small delay feels bigger on the phone
&lt;/h2&gt;

&lt;p&gt;In a web app, a short delay is usually fine.&lt;/p&gt;

&lt;p&gt;A button can show a loading state.&lt;br&gt;&lt;br&gt;
A page can display a spinner.&lt;br&gt;&lt;br&gt;
A chatbot can show typing dots.&lt;/p&gt;

&lt;p&gt;The user understands that something is happening.&lt;/p&gt;

&lt;p&gt;A phone call does not have that same visual feedback.&lt;/p&gt;

&lt;p&gt;When the caller stops talking and the AI does not respond, even a short pause can feel strange.&lt;/p&gt;

&lt;p&gt;The caller may wonder:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Did it hear me?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;or:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Is the call still connected?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That emotional reaction matters.&lt;/p&gt;

&lt;p&gt;The system might be working perfectly in the background, but the caller does not see that.&lt;/p&gt;

&lt;p&gt;They only hear silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  End-to-end latency matters more than one number
&lt;/h2&gt;

&lt;p&gt;At first, it is tempting to measure only one part of the system.&lt;/p&gt;

&lt;p&gt;Model response time.&lt;br&gt;&lt;br&gt;
Speech-to-text time.&lt;br&gt;&lt;br&gt;
Text-to-speech time.&lt;br&gt;&lt;br&gt;
Network delay.&lt;/p&gt;

&lt;p&gt;But the caller experiences the whole chain.&lt;/p&gt;

&lt;p&gt;For a voice AI agent, the real question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How long does it take from the moment the caller stops speaking to the moment they hear a useful response?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That path can include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;caller audio&lt;/li&gt;
&lt;li&gt;voice activity detection&lt;/li&gt;
&lt;li&gt;speech-to-text&lt;/li&gt;
&lt;li&gt;intent understanding&lt;/li&gt;
&lt;li&gt;model response&lt;/li&gt;
&lt;li&gt;tool calls or retrieval&lt;/li&gt;
&lt;li&gt;text-to-speech&lt;/li&gt;
&lt;li&gt;audio streaming back to the caller&lt;/li&gt;
&lt;li&gt;telephony network behavior&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Optimizing one piece helps, but it does not always fix the felt experience.&lt;/p&gt;

&lt;p&gt;The full loop is what matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  “Fast” is not always the same as “natural”
&lt;/h2&gt;

&lt;p&gt;This surprised me.&lt;/p&gt;

&lt;p&gt;I assumed faster would always feel better.&lt;/p&gt;

&lt;p&gt;But if the AI responds too instantly, it can feel unnatural.&lt;/p&gt;

&lt;p&gt;Humans usually leave tiny pauses in conversation.&lt;/p&gt;

&lt;p&gt;They breathe.&lt;br&gt;&lt;br&gt;
They process.&lt;br&gt;&lt;br&gt;
They acknowledge.&lt;br&gt;&lt;br&gt;
They sometimes say “okay” before moving forward.&lt;/p&gt;

&lt;p&gt;A voice AI that snaps back too quickly can feel robotic, even if the latency is technically great.&lt;/p&gt;

&lt;p&gt;So the goal is not always the lowest possible delay.&lt;/p&gt;

&lt;p&gt;The goal is a response rhythm that feels alive and useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Some delays are more acceptable than others
&lt;/h2&gt;

&lt;p&gt;Not all latency feels the same.&lt;/p&gt;

&lt;p&gt;If the caller asks a simple question, they expect a quick answer.&lt;/p&gt;

&lt;p&gt;Example:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Are you open today?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A long delay here feels bad.&lt;/p&gt;

&lt;p&gt;But if the caller asks something more complex, a short pause can feel normal.&lt;/p&gt;

&lt;p&gt;Example:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Can you help me find an appointment for a color service next week?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In that case, a small delay may be acceptable if the AI acknowledges what is happening:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Let me get a few details first.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;or:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“I can help collect that request.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The user experience depends on context.&lt;/p&gt;

&lt;p&gt;This is where product design and engineering meet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Silence needs design
&lt;/h2&gt;

&lt;p&gt;One thing I learned is that silence cannot be ignored.&lt;/p&gt;

&lt;p&gt;If the system needs time, the conversation should make that clear.&lt;/p&gt;

&lt;p&gt;This does not mean adding filler everywhere.&lt;/p&gt;

&lt;p&gt;Too much filler is annoying.&lt;/p&gt;

&lt;p&gt;But the AI needs ways to keep the caller oriented.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;acknowledge the request&lt;/li&gt;
&lt;li&gt;ask one clear follow-up question&lt;/li&gt;
&lt;li&gt;avoid long unexplained pauses&lt;/li&gt;
&lt;li&gt;do not overtalk while processing&lt;/li&gt;
&lt;li&gt;hand off when the request is too specific&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A lot of voice UX is about making the caller feel that the system is still present.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool calls make latency more complicated
&lt;/h2&gt;

&lt;p&gt;For a simple conversation, the AI can respond directly.&lt;/p&gt;

&lt;p&gt;But real products often need tools.&lt;/p&gt;

&lt;p&gt;For local businesses, a voice agent may need to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;check business hours&lt;/li&gt;
&lt;li&gt;understand service rules&lt;/li&gt;
&lt;li&gt;collect appointment details&lt;/li&gt;
&lt;li&gt;look up knowledge base information&lt;/li&gt;
&lt;li&gt;prepare a call summary&lt;/li&gt;
&lt;li&gt;decide whether to hand off&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every tool call can add delay.&lt;/p&gt;

&lt;p&gt;This creates a tradeoff.&lt;/p&gt;

&lt;p&gt;More context can make the answer better.&lt;/p&gt;

&lt;p&gt;But too much waiting can make the call feel worse.&lt;/p&gt;

&lt;p&gt;The question becomes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Does this tool call improve the caller experience enough to justify the delay?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is not only an engineering question.&lt;/p&gt;

&lt;p&gt;It is a product question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Call summaries changed how I thought about latency
&lt;/h2&gt;

&lt;p&gt;Early on, I wanted the AI to answer as much as possible during the call.&lt;/p&gt;

&lt;p&gt;But for local businesses, the after-call summary is often just as important.&lt;/p&gt;

&lt;p&gt;The caller needs a fast, useful interaction.&lt;/p&gt;

&lt;p&gt;The business needs structured context after the call.&lt;/p&gt;

&lt;p&gt;That means the AI does not always need to solve everything live.&lt;/p&gt;

&lt;p&gt;Sometimes it is better to keep the call simple, collect the right information, and give the business a clear next step.&lt;/p&gt;

&lt;p&gt;This reduces pressure on the live conversation.&lt;/p&gt;

&lt;p&gt;It also avoids forcing the caller to wait while the AI tries to do too much.&lt;/p&gt;

&lt;h2&gt;
  
  
  Latency affects trust
&lt;/h2&gt;

&lt;p&gt;This is probably the most important lesson.&lt;/p&gt;

&lt;p&gt;When a voice AI pauses awkwardly, talks over the caller, or responds too slowly, the problem is not just speed.&lt;/p&gt;

&lt;p&gt;The problem is trust.&lt;/p&gt;

&lt;p&gt;The caller may start to feel:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“This is not reliable.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For salons, spas, med spas, and other appointment-based businesses, that matters.&lt;/p&gt;

&lt;p&gt;A caller might be trying to book a same-day service, ask about a consultation, reschedule, or decide whether the business feels responsive.&lt;/p&gt;

&lt;p&gt;The phone call is part of the trust-building process.&lt;/p&gt;

&lt;p&gt;If the AI feels slow or confused, the business may feel slow or confused too.&lt;/p&gt;

&lt;p&gt;That is why latency is not just a backend concern.&lt;/p&gt;

&lt;p&gt;It is a brand experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would measure
&lt;/h2&gt;

&lt;p&gt;If I were starting from scratch, I would measure latency in layers.&lt;/p&gt;

&lt;p&gt;Not only:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How fast did the model respond?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;how long until speech was detected&lt;/li&gt;
&lt;li&gt;how long until the caller’s intent was understood&lt;/li&gt;
&lt;li&gt;how long until the first useful audio came back&lt;/li&gt;
&lt;li&gt;how often the AI talked over the caller&lt;/li&gt;
&lt;li&gt;how often the caller interrupted&lt;/li&gt;
&lt;li&gt;how often the AI needed to recover&lt;/li&gt;
&lt;li&gt;how often a human handoff was needed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The raw timing is useful.&lt;/p&gt;

&lt;p&gt;But the conversation outcome matters more.&lt;/p&gt;

&lt;p&gt;A fast bad answer is still a bad answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thought
&lt;/h2&gt;

&lt;p&gt;Latency in voice AI is not just about making the system faster.&lt;/p&gt;

&lt;p&gt;It is about making the conversation feel responsive.&lt;/p&gt;

&lt;p&gt;The caller should feel heard.&lt;br&gt;&lt;br&gt;
The AI should avoid awkward silence.&lt;br&gt;&lt;br&gt;
The system should not overtalk.&lt;br&gt;&lt;br&gt;
The business should receive useful context.&lt;/p&gt;

&lt;p&gt;That is the hard part.&lt;/p&gt;

&lt;p&gt;The best voice AI products will not only optimize milliseconds.&lt;/p&gt;

&lt;p&gt;They will optimize the feeling of the call.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>performance</category>
      <category>ux</category>
    </item>
    <item>
      <title>The Hidden UX Problem in Voice AI: When Should the AI Stop Talking?</title>
      <dc:creator>Luis Pham</dc:creator>
      <pubDate>Sun, 03 May 2026 13:08:00 +0000</pubDate>
      <link>https://dev.to/luispham/the-hidden-ux-problem-in-voice-ai-when-should-the-ai-stop-talking-4a62</link>
      <guid>https://dev.to/luispham/the-hidden-ux-problem-in-voice-ai-when-should-the-ai-stop-talking-4a62</guid>
      <description>&lt;h1&gt;
  
  
  The Hidden UX Problem in Voice AI: When Should the AI Stop Talking?
&lt;/h1&gt;

&lt;p&gt;One of the hardest parts of building a voice AI product is not making the AI talk.&lt;/p&gt;

&lt;p&gt;It is knowing when the AI should stop talking.&lt;/p&gt;

&lt;p&gt;I did not fully appreciate this at the beginning.&lt;/p&gt;

&lt;p&gt;When I started building &lt;a href="https://ringbooker.com" rel="noopener noreferrer"&gt;RingBooker&lt;/a&gt;, an AI receptionist for salons, spas, med spas, beauty clinics, I was focused on the obvious problems:&lt;/p&gt;

&lt;p&gt;Latency.&lt;br&gt;&lt;br&gt;
Speech recognition.&lt;br&gt;&lt;br&gt;
Call routing.&lt;br&gt;&lt;br&gt;
Booking intent.&lt;br&gt;&lt;br&gt;
Call summaries.&lt;/p&gt;

&lt;p&gt;Those are all important.&lt;/p&gt;

&lt;p&gt;But the more I worked on real phone-call flows, the more I realized that silence, interruption, and timing are part of the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Text AI can explain. Voice AI has to pace itself.
&lt;/h2&gt;

&lt;p&gt;In a text interface, long answers can still work.&lt;/p&gt;

&lt;p&gt;The user can skim.&lt;br&gt;&lt;br&gt;
They can scroll.&lt;br&gt;&lt;br&gt;
They can reread.&lt;br&gt;&lt;br&gt;
They can ignore parts of the answer.&lt;/p&gt;

&lt;p&gt;On a phone call, the user cannot skim.&lt;/p&gt;

&lt;p&gt;They have to listen in real time.&lt;/p&gt;

&lt;p&gt;That means every extra sentence costs attention.&lt;/p&gt;

&lt;p&gt;A voice agent that gives a complete answer may still feel bad if it talks for too long.&lt;/p&gt;

&lt;p&gt;This is especially true for local business calls.&lt;/p&gt;

&lt;p&gt;A caller usually does not want a long explanation. They want to know what to do next.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI should not fill every silence
&lt;/h2&gt;

&lt;p&gt;This was one of my early mistakes.&lt;/p&gt;

&lt;p&gt;I assumed silence was always bad.&lt;/p&gt;

&lt;p&gt;So I wanted the AI to respond quickly, keep the conversation moving, and avoid awkward pauses.&lt;/p&gt;

&lt;p&gt;But not every pause needs to be filled.&lt;/p&gt;

&lt;p&gt;Sometimes the caller is thinking.&lt;br&gt;&lt;br&gt;
Sometimes they are checking their calendar.&lt;br&gt;&lt;br&gt;
Sometimes they are asking someone next to them.&lt;br&gt;&lt;br&gt;
Sometimes they are about to correct themselves.&lt;/p&gt;

&lt;p&gt;If the AI jumps in too quickly, it feels pushy.&lt;/p&gt;

&lt;p&gt;If it waits too long, it feels broken.&lt;/p&gt;

&lt;p&gt;That middle ground is harder than it sounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Interruptions are not edge cases
&lt;/h2&gt;

&lt;p&gt;In voice AI, interruption handling is not a feature you add later.&lt;/p&gt;

&lt;p&gt;It is core UX.&lt;/p&gt;

&lt;p&gt;People interrupt naturally.&lt;/p&gt;

&lt;p&gt;They say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Actually, wait...”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;or:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“No, I meant tomorrow.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;or:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Can I ask something else?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the AI ignores that and keeps talking, the user immediately feels that they are not being heard.&lt;/p&gt;

&lt;p&gt;This is different from text.&lt;/p&gt;

&lt;p&gt;In text, the assistant can finish its response and the user can reply after.&lt;/p&gt;

&lt;p&gt;On a call, the timing itself communicates whether the AI is listening.&lt;/p&gt;

&lt;h2&gt;
  
  
  A good voice agent needs shorter answers
&lt;/h2&gt;

&lt;p&gt;This is one of the product rules I keep coming back to.&lt;/p&gt;

&lt;p&gt;For phone calls, shorter is usually better.&lt;/p&gt;

&lt;p&gt;Not because the user is impatient, but because voice is linear.&lt;/p&gt;

&lt;p&gt;The caller cannot jump ahead.&lt;/p&gt;

&lt;p&gt;For example, if someone calls a salon and asks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Do you have anything today?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The AI does not need to explain the entire booking process.&lt;/p&gt;

&lt;p&gt;It probably needs to say something like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“I can help check the request. What service are you looking for?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then collect the useful details.&lt;/p&gt;

&lt;p&gt;The goal is not to sound smart.&lt;/p&gt;

&lt;p&gt;The goal is to move the call forward.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI should ask one thing at a time
&lt;/h2&gt;

&lt;p&gt;This is another lesson from phone UX.&lt;/p&gt;

&lt;p&gt;In a chatbot, you can ask multiple questions at once:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What service do you need, what day works best, and do you have a preferred provider?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That can work in text.&lt;/p&gt;

&lt;p&gt;On a call, it often fails.&lt;/p&gt;

&lt;p&gt;The caller may answer only one part.&lt;/p&gt;

&lt;p&gt;Or they may forget the first question.&lt;/p&gt;

&lt;p&gt;Or they may respond vaguely.&lt;/p&gt;

&lt;p&gt;For voice, one question at a time usually works better.&lt;/p&gt;

&lt;p&gt;Bad:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“What service are you looking for, what time do you prefer, and is there a specific stylist?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Better:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“What service are you looking for?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Do you have a preferred day or time?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Do you have a preferred stylist?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It feels slower on paper, but it is often smoother in conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Knowing when to hand off is part of the UX
&lt;/h2&gt;

&lt;p&gt;Sometimes the best thing the AI can do is stop trying to solve the call.&lt;/p&gt;

&lt;p&gt;This is especially true when the caller asks for something sensitive, complex, or very specific.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a medical-aesthetic treatment question that needs professional judgment&lt;/li&gt;
&lt;li&gt;a pricing question that depends on consultation&lt;/li&gt;
&lt;li&gt;a complaint&lt;/li&gt;
&lt;li&gt;a caller who repeatedly asks for a human&lt;/li&gt;
&lt;li&gt;a policy exception&lt;/li&gt;
&lt;li&gt;a complicated reschedule&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In those moments, continuing to talk can hurt trust.&lt;/p&gt;

&lt;p&gt;A good voice agent should be comfortable saying:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“I’ll pass this to the team so they can follow up with the right answer.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is not failure.&lt;/p&gt;

&lt;p&gt;That is good product behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  The summary should replace unnecessary talking
&lt;/h2&gt;

&lt;p&gt;Another thing I learned: the AI does not need to explain everything to the caller if the real value is in the follow-up.&lt;/p&gt;

&lt;p&gt;For local businesses, the call often has two users:&lt;/p&gt;

&lt;p&gt;The caller.&lt;br&gt;&lt;br&gt;
The business team.&lt;/p&gt;

&lt;p&gt;The caller wants a quick response.&lt;/p&gt;

&lt;p&gt;The business wants clean context.&lt;/p&gt;

&lt;p&gt;So instead of making the AI over-explain, the product can capture the details and send the team a useful summary.&lt;/p&gt;

&lt;p&gt;That means the AI can keep the call shorter while still creating value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Voice AI needs boundaries, not just intelligence
&lt;/h2&gt;

&lt;p&gt;A lot of AI products are designed to show how capable the model is.&lt;/p&gt;

&lt;p&gt;But on the phone, capability without restraint can feel uncomfortable.&lt;/p&gt;

&lt;p&gt;The AI should know:&lt;/p&gt;

&lt;p&gt;When to answer.&lt;br&gt;&lt;br&gt;
When to ask a follow-up.&lt;br&gt;&lt;br&gt;
When to pause.&lt;br&gt;&lt;br&gt;
When to stop.&lt;br&gt;&lt;br&gt;
When to hand off.&lt;/p&gt;

&lt;p&gt;Those decisions shape the user experience as much as the model quality.&lt;/p&gt;

&lt;p&gt;Maybe more.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am trying to optimize for
&lt;/h2&gt;

&lt;p&gt;For RingBooker, I am not trying to make the AI sound like the most impressive receptionist in the world.&lt;/p&gt;

&lt;p&gt;I am trying to make it useful in the calls a beauty business normally misses:&lt;/p&gt;

&lt;p&gt;After-hours calls.&lt;br&gt;&lt;br&gt;
Peak-hour overflow.&lt;br&gt;&lt;br&gt;
Same-day requests.&lt;br&gt;&lt;br&gt;
Reschedules.&lt;br&gt;&lt;br&gt;
Consultation inquiries.&lt;br&gt;&lt;br&gt;
Human handoff requests.&lt;/p&gt;

&lt;p&gt;In those moments, the AI does not need to dominate the conversation.&lt;/p&gt;

&lt;p&gt;It needs to help the caller feel heard and give the business enough context to act.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thought
&lt;/h2&gt;

&lt;p&gt;The hidden UX problem in voice AI is restraint.&lt;/p&gt;

&lt;p&gt;Knowing what to say matters.&lt;/p&gt;

&lt;p&gt;But knowing when to stop talking may matter even more.&lt;/p&gt;

&lt;p&gt;A voice AI agent should not try to win the conversation.&lt;/p&gt;

&lt;p&gt;It should help the caller get to the next step.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>voiceai</category>
      <category>startup</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Why Voice AI for Local Businesses Is Harder Than a Chatbot</title>
      <dc:creator>Luis Pham</dc:creator>
      <pubDate>Thu, 30 Apr 2026 12:06:00 +0000</pubDate>
      <link>https://dev.to/luispham/why-voice-ai-for-local-businesses-is-harder-than-a-chatbot-2d0o</link>
      <guid>https://dev.to/luispham/why-voice-ai-for-local-businesses-is-harder-than-a-chatbot-2d0o</guid>
      <description>&lt;h1&gt;
  
  
  Why Voice AI for Local Businesses Is Harder Than a Chatbot
&lt;/h1&gt;

&lt;p&gt;I used to think a voice AI agent was basically a chatbot with audio.&lt;/p&gt;

&lt;p&gt;User speaks.&lt;br&gt;&lt;br&gt;
AI understands.&lt;br&gt;&lt;br&gt;
AI replies.&lt;/p&gt;

&lt;p&gt;That was the simple version in my head.&lt;/p&gt;

&lt;p&gt;But after working on &lt;a href="https://ringbooker.com" rel="noopener noreferrer"&gt;RingBooker&lt;/a&gt;, an AI receptionist for salons, spas, med spas, and beauty clinics, I started to see voice AI very differently.&lt;/p&gt;

&lt;p&gt;A chatbot can be useful even when it feels a little slow.&lt;/p&gt;

&lt;p&gt;A phone call cannot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Text gives the user more patience
&lt;/h2&gt;

&lt;p&gt;When someone uses a chatbot, they expect a short delay.&lt;/p&gt;

&lt;p&gt;They can see the answer being generated.&lt;br&gt;&lt;br&gt;
They can reread the message.&lt;br&gt;&lt;br&gt;
They can scroll back.&lt;br&gt;&lt;br&gt;
They can pause before replying.&lt;/p&gt;

&lt;p&gt;The experience gives them space.&lt;/p&gt;

&lt;p&gt;A phone call does not.&lt;/p&gt;

&lt;p&gt;On a call, silence feels uncomfortable almost immediately.&lt;/p&gt;

&lt;p&gt;If the AI waits too long, the caller may think the call dropped.&lt;/p&gt;

&lt;p&gt;If the AI replies too quickly, it can feel unnatural.&lt;/p&gt;

&lt;p&gt;If the AI talks too much, the caller interrupts.&lt;/p&gt;

&lt;p&gt;That makes the timing much harder to get right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Voice AI has to feel alive
&lt;/h2&gt;

&lt;p&gt;In text, the user judges the quality mostly by the final answer.&lt;/p&gt;

&lt;p&gt;In voice, the user judges the whole interaction.&lt;/p&gt;

&lt;p&gt;The pause before the answer.&lt;br&gt;&lt;br&gt;
The tone.&lt;br&gt;&lt;br&gt;
The interruption handling.&lt;br&gt;&lt;br&gt;
The confidence.&lt;br&gt;&lt;br&gt;
The moment when the AI says “Let me check that.”&lt;br&gt;&lt;br&gt;
The way it handles uncertainty.&lt;/p&gt;

&lt;p&gt;Even when the underlying model is good, the experience can still feel bad if the voice flow is awkward.&lt;/p&gt;

&lt;p&gt;This was one of the first things I had to accept:&lt;/p&gt;

&lt;p&gt;The model is only one part of the product.&lt;/p&gt;

&lt;p&gt;The conversation design is just as important.&lt;/p&gt;

&lt;h2&gt;
  
  
  Interruptions are normal, not edge cases
&lt;/h2&gt;

&lt;p&gt;In a chatbot, the flow is usually clean.&lt;/p&gt;

&lt;p&gt;The user sends a message.&lt;br&gt;&lt;br&gt;
The assistant replies.&lt;br&gt;&lt;br&gt;
Then the user sends another message.&lt;/p&gt;

&lt;p&gt;Phone calls are not like that.&lt;/p&gt;

&lt;p&gt;People interrupt.&lt;/p&gt;

&lt;p&gt;They correct themselves.&lt;/p&gt;

&lt;p&gt;They ask a second question before the first one is answered.&lt;/p&gt;

&lt;p&gt;They start with one intent and change it halfway through.&lt;/p&gt;

&lt;p&gt;For local businesses, this happens all the time.&lt;/p&gt;

&lt;p&gt;A salon caller might say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Do you have anything today? Actually, tomorrow morning would be better.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A med spa caller might say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“I’m interested in laser. Wait, is that the same as IPL?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A spa caller might ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“How much is a massage? Also, do you have couples appointments?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the AI cannot handle interruptions, the caller feels trapped inside a script.&lt;/p&gt;

&lt;p&gt;That is not a good experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  The input is messy
&lt;/h2&gt;

&lt;p&gt;Most AI demos happen in clean environments.&lt;/p&gt;

&lt;p&gt;Real phone calls do not.&lt;/p&gt;

&lt;p&gt;People call from cars.&lt;br&gt;&lt;br&gt;
They call from busy rooms.&lt;br&gt;&lt;br&gt;
They speak quietly.&lt;br&gt;&lt;br&gt;
They use vague words.&lt;br&gt;&lt;br&gt;
They ask incomplete questions.&lt;br&gt;&lt;br&gt;
They may not know the correct service name.&lt;/p&gt;

&lt;p&gt;For a chatbot, messy input is annoying.&lt;/p&gt;

&lt;p&gt;For a phone agent, messy input is the default.&lt;/p&gt;

&lt;p&gt;This changes the product design.&lt;/p&gt;

&lt;p&gt;The AI has to ask follow-up questions, but not too many.&lt;/p&gt;

&lt;p&gt;It has to collect useful information, but not sound like a form.&lt;/p&gt;

&lt;p&gt;It has to be helpful, but not overconfident.&lt;/p&gt;

&lt;p&gt;That balance is difficult.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local businesses need boundaries
&lt;/h2&gt;

&lt;p&gt;One mistake I see in many AI product ideas is trying to make the AI do everything.&lt;/p&gt;

&lt;p&gt;Answer every question.&lt;br&gt;&lt;br&gt;
Book every appointment.&lt;br&gt;&lt;br&gt;
Handle every exception.&lt;br&gt;&lt;br&gt;
Replace every human step.&lt;/p&gt;

&lt;p&gt;For local businesses, I think that is the wrong starting point.&lt;/p&gt;

&lt;p&gt;A salon, spa, or med spa does not need an AI that pretends to be perfect.&lt;/p&gt;

&lt;p&gt;They need an AI that can reliably help with the calls the team cannot always answer.&lt;/p&gt;

&lt;p&gt;That might mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;answering after-hours calls&lt;/li&gt;
&lt;li&gt;collecting booking intent&lt;/li&gt;
&lt;li&gt;asking for preferred time&lt;/li&gt;
&lt;li&gt;capturing service details&lt;/li&gt;
&lt;li&gt;summarizing the call&lt;/li&gt;
&lt;li&gt;handing off when needed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The handoff is not a failure.&lt;/p&gt;

&lt;p&gt;Sometimes the handoff is the product working correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trust is more fragile on the phone
&lt;/h2&gt;

&lt;p&gt;In a chatbot, a wrong answer is bad.&lt;/p&gt;

&lt;p&gt;On a phone call, a wrong answer can feel worse.&lt;/p&gt;

&lt;p&gt;The caller is giving attention in real time. They may be trying to book something, ask about pricing, reschedule, or decide whether the business feels trustworthy.&lt;/p&gt;

&lt;p&gt;If the AI sounds too confident about something it should not promise, trust drops.&lt;/p&gt;

&lt;p&gt;If it pretends to know a policy it does not know, trust drops.&lt;/p&gt;

&lt;p&gt;If it refuses to hand off when the caller asks for a human, trust drops.&lt;/p&gt;

&lt;p&gt;For appointment-based businesses, trust matters because the call is often part of the buying decision.&lt;/p&gt;

&lt;p&gt;This is especially true for services like hair color, skin treatments, injections, laser, massage, or first-time consultations.&lt;/p&gt;

&lt;p&gt;The caller is not only asking for information.&lt;/p&gt;

&lt;p&gt;They are testing whether the business feels responsive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The transcript is not enough
&lt;/h2&gt;

&lt;p&gt;At first, I thought the transcript would be one of the most important outputs.&lt;/p&gt;

&lt;p&gt;But the more I thought about the workflow, the more I realized most business owners do not want to read long transcripts.&lt;/p&gt;

&lt;p&gt;They want the useful summary.&lt;/p&gt;

&lt;p&gt;Who called?&lt;br&gt;&lt;br&gt;
What did they want?&lt;br&gt;&lt;br&gt;
How urgent was it?&lt;br&gt;&lt;br&gt;
What service were they asking about?&lt;br&gt;&lt;br&gt;
What should the team do next?&lt;/p&gt;

&lt;p&gt;A clean summary can be more useful than a perfect transcript.&lt;/p&gt;

&lt;p&gt;This is one of the biggest differences between building a demo and building a product.&lt;/p&gt;

&lt;p&gt;The demo is about showing that the AI can talk.&lt;/p&gt;

&lt;p&gt;The product is about helping the business take action after the call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The goal is not to sound impressive
&lt;/h2&gt;

&lt;p&gt;A good voice AI product should not be measured only by how smart it sounds.&lt;/p&gt;

&lt;p&gt;For local businesses, I think the better questions are:&lt;/p&gt;

&lt;p&gt;Did it answer quickly?&lt;br&gt;&lt;br&gt;
Did it understand the caller’s intent?&lt;br&gt;&lt;br&gt;
Did it avoid making promises it should not make?&lt;br&gt;&lt;br&gt;
Did it know when to ask a follow-up question?&lt;br&gt;&lt;br&gt;
Did it know when to hand off?&lt;br&gt;&lt;br&gt;
Did it send the team something useful?&lt;/p&gt;

&lt;p&gt;That is a more practical benchmark.&lt;/p&gt;

&lt;p&gt;It is also harder than it sounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would build around first
&lt;/h2&gt;

&lt;p&gt;If I were starting again, I would not start with the most complex booking flow.&lt;/p&gt;

&lt;p&gt;I would start with the most common missed-call situations:&lt;/p&gt;

&lt;p&gt;After-hours callers.&lt;br&gt;&lt;br&gt;
Busy-hour overflow.&lt;br&gt;&lt;br&gt;
Same-day appointment requests.&lt;br&gt;&lt;br&gt;
Reschedules.&lt;br&gt;&lt;br&gt;
Basic pricing questions.&lt;br&gt;&lt;br&gt;
Consultation inquiries.&lt;br&gt;&lt;br&gt;
Human handoff requests.&lt;/p&gt;

&lt;p&gt;These are not the most glamorous flows, but they are the ones that happen every day.&lt;/p&gt;

&lt;p&gt;And for a local business, capturing one missed opportunity can matter more than having a perfect AI demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thought
&lt;/h2&gt;

&lt;p&gt;Voice AI is not just chatbot logic plus speech.&lt;/p&gt;

&lt;p&gt;It is a different product surface.&lt;/p&gt;

&lt;p&gt;The user experience is faster, messier, and less forgiving.&lt;/p&gt;

&lt;p&gt;That is what makes it hard.&lt;/p&gt;

&lt;p&gt;But that is also what makes it interesting.&lt;/p&gt;

&lt;p&gt;For local businesses, the phone is still where many high-intent customers show up. If AI can help answer those calls without pretending to replace the human team, I think there is a real product there.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>voiceai</category>
      <category>startup</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Building an AI Receptionist for Salons: What Was Harder Than I Expected</title>
      <dc:creator>Luis Pham</dc:creator>
      <pubDate>Tue, 28 Apr 2026 06:47:08 +0000</pubDate>
      <link>https://dev.to/luispham/building-an-ai-phone-agent-for-salons-what-was-harder-than-i-expected-hn6</link>
      <guid>https://dev.to/luispham/building-an-ai-phone-agent-for-salons-what-was-harder-than-i-expected-hn6</guid>
      <description>&lt;h1&gt;
  
  
  Building an AI Receptionist for Salons: What Was Harder Than I Expected
&lt;/h1&gt;

&lt;p&gt;I’m building &lt;a href="https://ringbooker.com" rel="noopener noreferrer"&gt;RingBooker&lt;/a&gt;, an AI receptionist for salons, spas, med spas, and other appointment-based businesses.&lt;/p&gt;

&lt;p&gt;When I started, I thought the product was mostly about the AI.&lt;/p&gt;

&lt;p&gt;Answer the phone.&lt;br&gt;&lt;br&gt;
Understand the caller.&lt;br&gt;&lt;br&gt;
Collect the booking details.&lt;br&gt;&lt;br&gt;
Send the business a summary.&lt;/p&gt;

&lt;p&gt;Simple enough.&lt;/p&gt;

&lt;p&gt;It turned out the hard part was not only the AI model. The hard part was the phone call itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  A phone call has no “loading state”
&lt;/h2&gt;

&lt;p&gt;With a chatbot, a user can wait.&lt;/p&gt;

&lt;p&gt;They can see the message is generating. They can scroll back. They can reread the answer.&lt;/p&gt;

&lt;p&gt;On a phone call, silence feels broken.&lt;/p&gt;

&lt;p&gt;Even a short delay can make the caller wonder:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Is this still listening?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That changed how I thought about latency.&lt;/p&gt;

&lt;p&gt;At first I was looking at latency like a normal backend metric. How fast is the response? How long does the model take? How quickly can the audio come back?&lt;/p&gt;

&lt;p&gt;But in a real phone call, the user does not care about the number.&lt;/p&gt;

&lt;p&gt;They care about whether the conversation feels alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The caller will interrupt
&lt;/h2&gt;

&lt;p&gt;This was another thing I underestimated.&lt;/p&gt;

&lt;p&gt;In text, the flow is clean. User sends a message. Assistant replies.&lt;/p&gt;

&lt;p&gt;On a call, people interrupt constantly.&lt;/p&gt;

&lt;p&gt;They start with one request, then change it halfway through.&lt;/p&gt;

&lt;p&gt;“Do you have anything today? Actually tomorrow is better.”&lt;/p&gt;

&lt;p&gt;“I need a refill. Wait, maybe a full set.”&lt;/p&gt;

&lt;p&gt;“Can I speak to someone? No, actually I just want to know the price first.”&lt;/p&gt;

&lt;p&gt;If the AI keeps talking when the caller is trying to correct something, the whole experience feels wrong.&lt;/p&gt;

&lt;p&gt;Barge-in is not a small feature. It is part of the core UX.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local business calls are messy
&lt;/h2&gt;

&lt;p&gt;A lot of AI demos assume the user gives clean input.&lt;/p&gt;

&lt;p&gt;Real callers do not.&lt;/p&gt;

&lt;p&gt;They speak from cars.&lt;br&gt;&lt;br&gt;
They call from noisy rooms.&lt;br&gt;&lt;br&gt;
They use vague phrases.&lt;br&gt;&lt;br&gt;
They ask two questions at once.&lt;br&gt;&lt;br&gt;
They sometimes do not know the exact service name.&lt;/p&gt;

&lt;p&gt;For salons and spas, this is common.&lt;/p&gt;

&lt;p&gt;A caller may say “nails” when they mean acrylic full set.&lt;br&gt;&lt;br&gt;
A med spa caller may ask about “laser” without knowing which treatment.&lt;br&gt;&lt;br&gt;
A hair salon caller may ask for “color” without knowing whether it is highlights, root touch-up, or correction.&lt;/p&gt;

&lt;p&gt;So the AI cannot just collect form fields. It has to ask enough follow-up questions without making the call feel like an interrogation.&lt;/p&gt;

&lt;p&gt;That balance is harder than I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The summary matters more than the transcript
&lt;/h2&gt;

&lt;p&gt;At first I cared a lot about the transcript.&lt;/p&gt;

&lt;p&gt;Then I realized the business owner probably does not want to read a full call transcript.&lt;/p&gt;

&lt;p&gt;They want the useful part:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;who called&lt;/li&gt;
&lt;li&gt;what they wanted&lt;/li&gt;
&lt;li&gt;how urgent it was&lt;/li&gt;
&lt;li&gt;what questions they asked&lt;/li&gt;
&lt;li&gt;what should happen next&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a busy salon owner, a clean summary is more valuable than a perfect transcript.&lt;/p&gt;

&lt;p&gt;This changed the product direction for me.&lt;/p&gt;

&lt;p&gt;The call itself is only half the product. The handoff to the business is the other half.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI should not try to handle everything
&lt;/h2&gt;

&lt;p&gt;This is probably the biggest lesson so far.&lt;/p&gt;

&lt;p&gt;It is tempting to make the AI answer every question and complete every flow.&lt;/p&gt;

&lt;p&gt;But for real businesses, that is risky.&lt;/p&gt;

&lt;p&gt;Some calls should go to a human.&lt;br&gt;&lt;br&gt;
Some questions depend on policy.&lt;br&gt;&lt;br&gt;
Some prices depend on consultation.&lt;br&gt;&lt;br&gt;
Some callers just want reassurance.&lt;/p&gt;

&lt;p&gt;A useful AI phone agent needs to know its boundary.&lt;/p&gt;

&lt;p&gt;For RingBooker, I started thinking less about “AI replacing the front desk” and more about “AI covering the calls the team cannot answer.”&lt;/p&gt;

&lt;p&gt;That framing feels much healthier.&lt;/p&gt;

&lt;h2&gt;
  
  
  The existing phone number is part of the product
&lt;/h2&gt;

&lt;p&gt;This was not obvious to me at the beginning.&lt;/p&gt;

&lt;p&gt;For many local businesses, the phone number is everywhere:&lt;/p&gt;

&lt;p&gt;Google Business Profile, website, ads, Instagram, business cards, printed signs, old customers’ phones.&lt;/p&gt;

&lt;p&gt;Asking them to change that number is a huge ask.&lt;/p&gt;

&lt;p&gt;So call forwarding became an important part of the product idea.&lt;/p&gt;

&lt;p&gt;The business should be able to keep the number customers already know, while RingBooker sits behind the front-desk line for missed, overflow, or after-hours calls.&lt;/p&gt;

&lt;p&gt;It sounds like a small detail, but for local businesses it is a big trust issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell another builder
&lt;/h2&gt;

&lt;p&gt;If you are building a voice AI product, do not start only with the model.&lt;/p&gt;

&lt;p&gt;Start with the awkward parts of the call.&lt;/p&gt;

&lt;p&gt;What happens when the caller interrupts?&lt;br&gt;&lt;br&gt;
What happens when the audio is bad?&lt;br&gt;&lt;br&gt;
What happens when the AI is unsure?&lt;br&gt;&lt;br&gt;
What happens when the caller asks for a human?&lt;br&gt;&lt;br&gt;
What happens after the call ends?&lt;/p&gt;

&lt;p&gt;Those edge cases are not edge cases for long. They become the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thought
&lt;/h2&gt;

&lt;p&gt;I still think voice AI will become very important for local businesses.&lt;/p&gt;

&lt;p&gt;But I no longer think the goal is to make the AI sound impressive.&lt;/p&gt;

&lt;p&gt;The goal is simpler and harder:&lt;/p&gt;

&lt;p&gt;Answer quickly.&lt;br&gt;&lt;br&gt;
Be clear.&lt;br&gt;&lt;br&gt;
Do not overpromise.&lt;br&gt;&lt;br&gt;
Know when to hand off.&lt;br&gt;&lt;br&gt;
Give the business a useful next step.&lt;/p&gt;

&lt;p&gt;That is the version of the product I’m trying to build.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>voiceai</category>
      <category>startup</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
