<?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: Дмитрий Глухов</title>
    <description>The latest articles on DEV Community by Дмитрий Глухов (@__ec6d0f1112).</description>
    <link>https://dev.to/__ec6d0f1112</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%2F4195847%2Ff502e7db-0e02-4744-ad60-e949046bca27.jpg</url>
      <title>DEV Community: Дмитрий Глухов</title>
      <link>https://dev.to/__ec6d0f1112</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/__ec6d0f1112"/>
    <language>en</language>
    <item>
      <title>Как я собрал астрологическую платформу из трёх сервисов: FastAPI, Next.js, Telegram и LLM</title>
      <dc:creator>Дмитрий Глухов</dc:creator>
      <pubDate>Tue, 20 Oct 2026 09:27:54 +0000</pubDate>
      <link>https://dev.to/__ec6d0f1112/kak-ia-sobral-astrologhichieskuiu-platformu-iz-triokh-siervisov-fastapi-nextjs-telegram-i-llm-20m2</link>
      <guid>https://dev.to/__ec6d0f1112/kak-ia-sobral-astrologhichieskuiu-platformu-iz-triokh-siervisov-fastapi-nextjs-telegram-i-llm-20m2</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%2Fkkcajklvb6tx90dytt3j.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%2Fkkcajklvb6tx90dytt3j.jpg" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Я разрабатываю астрологический сервис, который объединяет расчёт натальных карт, анализ транзитов, AI-интерпретации и автоматическую публикацию контента.&lt;/p&gt;

&lt;p&gt;Изначально это могло бы выглядеть как обычный сайт с гороскопами. Но за пользовательским интерфейсом постепенно выросла система из трёх приложений: отдельного расчётного API, веб-платформы и Telegram-бота. А поверх них появился конвейер, который превращает астрономические данные в статьи и готовит иллюстрации для публикации.&lt;/p&gt;

&lt;p&gt;В этой статье разберу архитектуру, интересные технические решения и несколько проблем, которые обнаружились уже во время эксплуатации.&lt;/p&gt;

&lt;h2&gt;
  
  
  Архитектура: одно ядро, три направления использования
&lt;/h2&gt;

&lt;p&gt;Главное архитектурное решение — вынести астрологические расчёты в отдельный сервис. Сайт и бот не должны самостоятельно рассчитывать положения планет и реализовывать каждый по-своему.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                  ┌─────────────────────────┐
                  │     Astrologer-API      │
                  │   FastAPI + Swiss       │
                  │        Ephemeris        │
                  │        :8000            │
                  └────────────┬────────────┘
                               │ HTTP / JSON
                   ┌───────────┴───────────┐
                   │                       │
          ┌────────▼────────┐     ┌────────▼────────┐
          │ AstrologerStudio│     │ AstrologerBot   │
          │ Next.js         │     │ Bun + grammY    │
          │ веб-платформа   │     │ Telegram        │
          └────────┬────────┘     └────────┬────────┘
                   │                       │
                   └───────────┬───────────┘
                               │
                       ┌───────▼────────┐
                       │  DeepSeek API  │
                       │  интерпретации │
                       └────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Все три компонента работают на одном VPS под управлением PM2. Сайт доступен через HTTPS на порту 443, расчётный API использует порт 8000. Для подключения Telegram-бота применяется SOCKS5-прокси на базе xray-proxy: прямой доступ к &lt;code&gt;api.telegram.org&lt;/code&gt; из сети сервера не работает.&lt;/p&gt;

&lt;p&gt;В такой схеме у каждого компонента своя ответственность:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Astrologer-API&lt;/strong&gt; рассчитывает карты и формирует структурированные данные.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AstrologerStudio&lt;/strong&gt; предоставляет веб-интерфейс, хранит пользовательские данные и организует работу с AI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AstrologerBot&lt;/strong&gt; реализует сценарии взаимодействия через Telegram: от расчёта карты до календарей транзитов.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Это позволяет развивать интерфейсы независимо. Например, изменение алгоритма формирования данных карты не требует повторной реализации расчётов в боте и на сайте.&lt;/p&gt;

&lt;h3&gt;
  
  
  Сколько стоит отдельный API
&lt;/h3&gt;

&lt;p&gt;Отдельный сервис — не бесплатное архитектурное преимущество. Появляются HTTP-вызовы, сетевые ошибки и необходимость поддерживать контракт между сервисами. Вот как это выглядит в цифрах на реальном сценарии.&lt;/p&gt;

&lt;p&gt;Месячный календарь транзитов в боте — это &lt;strong&gt;30–31 запрос&lt;/strong&gt; к &lt;code&gt;/chart-data/transit&lt;/code&gt;, по одному на каждый день месяца. Запросы идут чанками по пять, у каждого дня две попытки. То есть в худшем случае — 62 HTTP-вызова на одну команду пользователя.&lt;/p&gt;

&lt;p&gt;Недельный обзор дешевле: семь запросов, чанками по четыре.&lt;/p&gt;

&lt;p&gt;Соблазн посчитать всё в боте был, но тогда те же формулы пришлось бы дублировать на сайте, а расхождение в расчётах между ботом и сайтом заметил бы пользователь, а не тест. Поэтому цена в виде 31 вызова принята осознанно: это редкая тяжёлая операция, которая запускается только по явному нажатию кнопки.&lt;/p&gt;

&lt;h2&gt;
  
  
  Расчётное ядро на FastAPI и Swiss Ephemeris
&lt;/h2&gt;

&lt;p&gt;Расчётный сервис написан на Python с использованием FastAPI, Pydantic v2 и Swiss Ephemeris через Python-обвязку. Точная версия &lt;code&gt;pyswisseph&lt;/code&gt; в описании проекта пока не зафиксирована.&lt;/p&gt;

&lt;p&gt;API организован вокруг &lt;code&gt;/api/v5&lt;/code&gt;. Методы принимают POST-запросы с JSON и возвращают данные, которые можно использовать как в интерфейсе, так и при подготовке контекста для языковой модели.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Группа методов&lt;/th&gt;
&lt;th&gt;Назначение&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/chart-data/*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Структурированные данные карт&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/chart/*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Те же карты с визуализацией в SVG&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/context/*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Контекст, подготовленный для языковой модели&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/now/chart&lt;/code&gt;, &lt;code&gt;/now/context&lt;/code&gt;, &lt;code&gt;/now/subject&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Данные для текущего момента по Гринвичу&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Поддерживаются натальные карты, транзиты, синастрия, композит, соляр и лунар. Есть отдельные методы для оценки совместимости и фаз Луны.&lt;/p&gt;

&lt;p&gt;Такое разделение важно: интерфейсу не обязательно получать готовую картинку, если ему нужны только позиции планет и аспекты. И наоборот, генератор интерпретации не должен самостоятельно извлекать данные из SVG.&lt;/p&gt;

&lt;h3&gt;
  
  
  Контракт ответа
&lt;/h3&gt;

&lt;p&gt;Для одиночных карт используется модель &lt;code&gt;SingleChartDataModel&lt;/code&gt;, для парных — &lt;code&gt;DualChartDataModel&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;В данных одиночной карты есть субъект, аспекты, распределение по стихиям и качествам, активные точки и активные аспекты. Для парных карт предусмотрены данные обоих субъектов, сравнение домов и оценка отношений.&lt;/p&gt;

&lt;p&gt;Позиция астрологического объекта описывается не одним числом:&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sun"&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;"Lib"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"position"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;26.893554814425187&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"abs_pos"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;206.8935548144252&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="s2"&gt;"Twelfth_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;"retrograde"&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;"speed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.9935835852381062&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"declination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;-12.41&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;&lt;code&gt;abs_pos&lt;/code&gt; хранит абсолютную долготу — те же 26°53′ Весов, только в непрерывной шкале от 0 до 360. Именно она удобна для расчёта аспектов: разница двух &lt;code&gt;abs_pos&lt;/code&gt; даёт дугу между объектами, а &lt;code&gt;position&lt;/code&gt; нужна только для отображения человеку.&lt;/p&gt;

&lt;p&gt;API позволяет настраивать систему домов, тропический или сидерический зодиак с поддержкой 20 айанамш, а также геоцентрическую, топоцентрическую и гелиоцентрическую перспективы. Список активных объектов может включать более 40 точек, в том числе узлы, Хирон, Лилит, парсы и Вертекс.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ошибка, которая выглядит незначительной
&lt;/h3&gt;

&lt;p&gt;Одна из важных деталей обнаружилась при обработке аспектов транзитной карты.&lt;/p&gt;

&lt;p&gt;В модели аспекта есть поля &lt;code&gt;p1_owner&lt;/code&gt; и &lt;code&gt;p2_owner&lt;/code&gt;. Это строковое имя субъекта — &lt;code&gt;N&lt;/code&gt; для натальной карты, &lt;code&gt;T&lt;/code&gt; для транзитной, &lt;code&gt;Now&lt;/code&gt; для карты текущего момента. Не индекс в массиве и не номер объекта.&lt;/p&gt;

&lt;p&gt;Если при интерпретации ориентироваться на порядок &lt;code&gt;p1&lt;/code&gt; и &lt;code&gt;p2&lt;/code&gt;, можно перепутать натальные и транзитные планеты. Для обычного вывода списка аспектов это может быть незаметно, но для персонального анализа такая ошибка меняет смысл результата: «транзитный Марс в квадрате к вашему Солнцу» и «ваш Марс в квадрате к транзитному Солнцу» — разные утверждения.&lt;/p&gt;

&lt;p&gt;Вывод простой: типизированный ответ API — это не только набор чисел. Семантика полей не менее важна, чем их формат.&lt;/p&gt;

&lt;h2&gt;
  
  
  Веб-платформа: не только построение карты
&lt;/h2&gt;

&lt;p&gt;AstrologerStudio построен на Next.js с App Router и TypeScript. В проекте используются Tailwind CSS, shadcn/ui, TanStack Query, Prisma и PostgreSQL.&lt;/p&gt;

&lt;p&gt;Веб-интерфейс поддерживает расчёт натальных карт, транзитов, синастрии, композитов, соляров и лунаров. Пользователь может сохранять карты и субъектов, добавлять заметки, получать AI-интерпретации, экспортировать результаты в PDF и просматривать события на временной шкале.&lt;/p&gt;

&lt;p&gt;Отдельная часть системы — админка с метриками использования AI и лимитами.&lt;/p&gt;

&lt;h3&gt;
  
  
  Как отлаживать интеграцию с LLM
&lt;/h3&gt;

&lt;p&gt;В AI-функциях недостаточно проверить, что модель вернула текст. Если ответ оказался бессмысленным, нужно понять, какие именно данные и инструкции получила модель.&lt;/p&gt;

&lt;p&gt;В AstrologerStudio для этого предусмотрен дополнительный механизм диагностики. Эндпоинт &lt;code&gt;POST /api/ai/interpret&lt;/code&gt; возвращает потоковый ответ и передаёт в заголовках &lt;code&gt;X-AI-Context&lt;/code&gt; и &lt;code&gt;X-AI-User-Prompt&lt;/code&gt; отладочные данные в Base64.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;debugContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;anonymizedContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;debugUserPrompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userPrompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;debugContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;maxDebugHeaderBytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-AI-Context&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;debugContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;debugUserPrompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;maxDebugHeaderBytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-AI-User-Prompt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;debugUserPrompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&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;Их можно открыть через кнопку информации рядом с удалением интерпретации. В диалоге две вкладки: «Chart Context» — ровно тот XML, что ушёл в модель, и «Full User Prompt» — ровно тот промпт, что был собран.&lt;/p&gt;

&lt;p&gt;Это помогает разбирать проблему по этапам:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Корректно ли собраны данные расчёта?&lt;/li&gt;
&lt;li&gt;Правильно ли сформирован контекст?&lt;/li&gt;
&lt;li&gt;Какие инструкции попали в итоговый промпт?&lt;/li&gt;
&lt;li&gt;Где именно возникает ошибка — в подготовке данных или в ответе модели?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;У решения есть граница, о которой стоит сказать прямо. Заголовки уходят &lt;strong&gt;в каждом&lt;/strong&gt; ответе и содержат контекст карты в открытом Base64. Это нормально для отладки, но в продакшене их стоит отключать флагом — иначе контекст может осесть в логах reverse-proxy или в истории браузера. В коде для этого предусмотрен рубильник &lt;code&gt;DEBUG_ENABLED&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Перед отправкой в модель также используется &lt;code&gt;anonymizedContext&lt;/code&gt;. Сам факт передачи анонимизированного контекста подтверждён кодом, но точный перечень удаляемых и заменяемых полей требует отдельной проверки. Поэтому считать этот механизм полноценной гарантией анонимности без аудита нельзя.&lt;/p&gt;

&lt;h2&gt;
  
  
  Telegram-бот: календарь как серия расчётов
&lt;/h2&gt;

&lt;p&gt;AstrologerBot написан на TypeScript и работает на Bun с использованием grammY. Для работы с данными применяется Prisma, для расписаний — node-cron, для сетевых запросов — undici и socks-proxy-agent.&lt;/p&gt;

&lt;p&gt;В боте есть команды для карт, транзитов, недельного и месячного календарей, синастрии, соляра, лунара, настроек и статистики.&lt;/p&gt;

&lt;p&gt;Самый интересный сценарий — персональный календарь транзитов.&lt;/p&gt;

&lt;h3&gt;
  
  
  Недельный обзор
&lt;/h3&gt;

&lt;p&gt;Для недельного календаря бот выполняет семь запросов к API — по одному на день. Запросы отправляются группами по четыре.&lt;/p&gt;

&lt;p&gt;Для каждого дня анализируются знак и дом Луны, ретроградности и попадания транзитных объектов в натальные точки с орбом не более 2,5 градуса. Затем выбираются три наиболее значимых совпадения и формируется AI-обзор недели.&lt;/p&gt;

&lt;p&gt;Таким образом, языковая модель не должна самостоятельно вычислять астрономические позиции. Она получает уже подготовленный набор данных и интерпретирует его.&lt;/p&gt;

&lt;h3&gt;
  
  
  Месячный календарь
&lt;/h3&gt;

&lt;p&gt;Здесь запросов значительно больше: 30–31 на пользователя, по одному на каждый день месяца. Они выполняются чанками по пять, для каждого дня предусмотрены две попытки.&lt;/p&gt;

&lt;p&gt;Расчёт ведётся на 12:00 UTC. Для ранжирования дней используется эвристика, а не физическая формула:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;score = Σ (
    aspect_weight
    × (1 - orb / 3)
    × planet_speed_factor
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Здесь &lt;code&gt;aspect_weight&lt;/code&gt; и &lt;code&gt;planet_speed_factor&lt;/code&gt; — константы, заданные таблицей в коде. Множитель &lt;code&gt;(1 - orb / 3)&lt;/code&gt; гасит аспект по мере ухода от точного: при орбе 0 он равен единице, при орбе 3 и больше — нулю. &lt;code&gt;planet_speed_factor&lt;/code&gt; снижает вклад медленных планет, чтобы ретроградный Плутон не перевешивал быстрое соединение Луны с натальной точкой.&lt;/p&gt;

&lt;p&gt;Это прикладной алгоритм ранжирования, а не универсальная научная метрика. Его значения и пороги определены логикой самого сервиса.&lt;/p&gt;

&lt;p&gt;Дни со score не ниже 3 попадают в категорию «дней силы» — выбирается до десяти лучших. Дни со score ниже 2 считаются спокойными; в отдельную подборку попадают первые восемь таких дней.&lt;/p&gt;

&lt;p&gt;Важная деталь пользовательского сценария: полная карта строится только после явного нажатия кнопки. Бот не запускает этот более тяжёлый сценарий автоматически — 31 запрос не должен уходить в фоне из-за случайного нажатия.&lt;/p&gt;

&lt;h3&gt;
  
  
  Состояние диалога в stateless-боте
&lt;/h3&gt;

&lt;p&gt;Отдельная задача — не потерять контекст между сообщениями. Telegram не хранит состояние: каждое сообщение приходит само по себе, и бот должен помнить, что пользователь только что смотрел.&lt;/p&gt;

&lt;p&gt;После каждой интерпретации бот пишет отдельным сообщением: «Можете задать любой вопрос по этой карте — просто напишите его сообщением», — и кладёт в сессию запись вида &lt;code&gt;followUp: { kind, at }&lt;/code&gt;. Следующее текстовое сообщение перехватывается обработчиком вопросов и уходит в модель вместе с сохранённым XML-контекстом именно той карты, которую пользователь видел.&lt;/p&gt;

&lt;p&gt;Запись живёт час: если человек вернулся на следующий день и написал «а что там с работой», бот не станет отвечать по вчерашнему транзиту. Дополнительно ведётся дневной лимит AI-вопросов на пользователя.&lt;/p&gt;

&lt;p&gt;Мелочь, на которой легко ошибиться: &lt;code&gt;kind&lt;/code&gt; в этой записи должен совпадать с типом карты. Одно время после соляра туда записывался &lt;code&gt;natal&lt;/code&gt;, и вопрос уходил в натальную карту вместо соляра — внешне всё работало, ответ приходил, просто не про то.&lt;/p&gt;

&lt;h2&gt;
  
  
  Кеширование LLM: почему исправление кода не помогло
&lt;/h2&gt;

&lt;p&gt;Один из самых показательных багов возник при генерации месячного разбора.&lt;/p&gt;

&lt;p&gt;Бот стабильно возвращал сообщение о том, что данных нет и карта пустая. На первый взгляд проблема выглядела как ошибка расчётов или промпта.&lt;/p&gt;

&lt;p&gt;Причина оказалась двойной.&lt;/p&gt;

&lt;p&gt;Первая ошибка находилась в &lt;code&gt;buildSummaryXml&lt;/code&gt;: функция ожидала строку, а получала объект. В результате контекст формировался некорректно.&lt;/p&gt;

&lt;p&gt;Вторая проблема была в кеше. Пустой результат уже сохранился в базе данных, и после исправления функции бот продолжал возвращать старый ответ.&lt;/p&gt;

&lt;p&gt;Получалось, что код уже исправлен, но пользователь всё ещё видел результат прежней ошибки.&lt;/p&gt;

&lt;p&gt;Для устранения проблемы пришлось не только исправить преобразование данных, но и изменить версию промпта на &lt;code&gt;bot-short-v2&lt;/code&gt; и удалить 14 устаревших записей кеша.&lt;/p&gt;

&lt;h3&gt;
  
  
  Версия промпта — часть ключа кеша
&lt;/h3&gt;

&lt;p&gt;В боте используется схема, в которой ключ интерпретации учитывает версию промпта и хеш субъекта:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PROMPT_VERSION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bot-short-v2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PROMPT_VERSION&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;first_subject&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;
&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Время жизни кеша зависит от типа данных:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;соляр хранится бессрочно;&lt;/li&gt;
&lt;li&gt;лунар — до первого числа следующего месяца;&lt;/li&gt;
&lt;li&gt;транзит — до конца текущих суток UTC.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Это позволяет не генерировать одинаковые интерпретации повторно, когда результат ещё актуален.&lt;/p&gt;

&lt;p&gt;Но версия промпта нужна не только для улучшения текста. Она обеспечивает корректное обновление логики генерации. Если инструкции модели изменились, старый ответ не должен автоматически считаться результатом новой версии.&lt;/p&gt;

&lt;p&gt;Процедура, которая появилась после этого бага, звучит так: любое изменение промпта или формата контекста обязано сопровождаться повышением &lt;code&gt;PROMPT_VERSION&lt;/code&gt; и удалением затронутых строк кеша — до деплоя, а не после жалобы.&lt;/p&gt;

&lt;p&gt;В базе также сохраняется XML-контекст, который передавался модели. Это помогает восстанавливать историю проблем и сравнивать входные данные с результатом генерации.&lt;/p&gt;

&lt;h2&gt;
  
  
  Контент-конвейер: от эфемерид до публикации
&lt;/h2&gt;

&lt;p&gt;Отдельное направление проекта — автоматизация контента для Дзена.&lt;/p&gt;

&lt;p&gt;Конвейер начинается с эфемерид и списка астрологических событий месяца. Затем генерируются тексты статей, после чего программно создаются обложки и запускается автопостинг.&lt;/p&gt;

&lt;p&gt;Обложки собираются с помощью Pillow в размере 1200 × 800 пикселей. Для них предусмотрены шесть цветовых палитр, а оформление выбирается по типу события с использованием регулярных выражений:&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;RULES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;полнолун&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;violet&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="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;новолун&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;violet&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="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;нептун&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;teal&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="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;трин&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;forest&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="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;квадратур&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;warm&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="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;соединени&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rose&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="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ретроград&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;deep&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;/p&gt;

&lt;p&gt;Каждая обложка состоит из нескольких элементов: короткой рубрики, заголовка, подзаголовка и подписи. Размер шрифта подбирается автоматически: если заголовок не влезает в четыре строки, кегль уменьшается шагом в шесть пунктов, пока не влезет.&lt;/p&gt;

&lt;p&gt;Важная часть конвейера — ограничения для генерации текста. Промпты требуют не выдумывать статистику, не запугивать читателя и не давать медицинских или финансовых советов.&lt;/p&gt;

&lt;p&gt;Иными словами, автоматизация здесь касается не только производства материалов, но и правил, которым они должны соответствовать.&lt;/p&gt;

&lt;p&gt;Отдельный скрипт &lt;code&gt;monthly_calendar.py&lt;/code&gt; формирует месячный календарь по расписанию — 25-го числа.&lt;/p&gt;

&lt;p&gt;Этот конвейер связывает расчётные данные с регулярным выпуском контента: данные преобразуются в события, события становятся основой статьи, а статья получает готовую обложку.&lt;/p&gt;

&lt;h2&gt;
  
  
  Вместо заключения
&lt;/h2&gt;

&lt;p&gt;Этот проект интересен не тем, что в нём есть сайт, бот и AI-интерпретации. По отдельности такие функции уже давно не являются чем-то необычным.&lt;/p&gt;

&lt;p&gt;Интереснее то, как они соединены: расчётное ядро формирует единый контракт данных, разные клиенты используют его для собственных сценариев, языковая модель получает подготовленный контекст, а результаты и промпты сохраняются для повторного использования и отладки.&lt;/p&gt;

&lt;p&gt;Самый полезный вывод из истории с месячным календарём оказался простым: ошибки не заканчиваются в момент исправления исходного кода. Если результат кешируется, нужно учитывать жизненный цикл данных, версию логики и возможность воспроизведения старого ответа.&lt;/p&gt;

&lt;p&gt;Второй вывод касается архитектуры: вынесение расчётов в отдельный API помогает избежать дублирования, но одновременно делает качество контракта между сервисами критически важным. Цена — 31 HTTP-вызов на тяжёлый сценарий и обязанность следить за совместимостью полей вроде &lt;code&gt;p1_owner&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;А третий — автоматизация контента тоже нуждается в инженерной дисциплине. Генерация текста, оформление обложек, правила допустимого содержания и расписание публикаций должны работать как единый конвейер, а не как набор несвязанных скриптов.&lt;/p&gt;

&lt;p&gt;Сейчас у меня есть работающая система с тремя приложениями и автоматизированным выпуском материалов. Следующий логичный шаг — не добавлять функции ради количества, а измерить то, что пока известно только качественно: задержки, стоимость генерации, надёжность фоновых задач и эффективность кеширования.&lt;/p&gt;

&lt;p&gt;Именно эти измерения позволят перейти от работающего инженерного проекта к системе, качество которой можно оценивать не только по функциональности, но и по цифрам.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ссылки&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Сайт: &lt;a href="https://aiastro.space" rel="noopener noreferrer"&gt;https://aiastro.space&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Telegram-бот: &lt;a href="https://t.me/aiastro_space_bot" rel="noopener noreferrer"&gt;https://t.me/aiastro_space_bot&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>architecture</category>
      <category>fastapi</category>
      <category>llm</category>
      <category>nextjs</category>
    </item>
  </channel>
</rss>
