DEV Community

Дмитрий Глухов
Дмитрий Глухов

Posted on

Как я собрал астрологическую платформу из трёх сервисов: FastAPI, Next.js, Telegram и LLM

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

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

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

Архитектура: одно ядро, три направления использования

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

                  ┌─────────────────────────┐
                  │     Astrologer-API      │
                  │   FastAPI + Swiss       │
                  │        Ephemeris        │
                  │        :8000            │
                  └────────────┬────────────┘
                               │ HTTP / JSON
                   ┌───────────┴───────────┐
                   │                       │
          ┌────────▼────────┐     ┌────────▼────────┐
          │ AstrologerStudio│     │ AstrologerBot   │
          │ Next.js         │     │ Bun + grammY    │
          │ веб-платформа   │     │ Telegram        │
          └────────┬────────┘     └────────┬────────┘
                   │                       │
                   └───────────┬───────────┘
                               │
                       ┌───────▼────────┐
                       │  DeepSeek API  │
                       │  интерпретации │
                       └────────────────┘
Enter fullscreen mode Exit fullscreen mode

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

В такой схеме у каждого компонента своя ответственность:

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

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

Сколько стоит отдельный API

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

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

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

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

Расчётное ядро на FastAPI и Swiss Ephemeris

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

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

Группа методов Назначение
/chart-data/* Структурированные данные карт
/chart/* Те же карты с визуализацией в SVG
/context/* Контекст, подготовленный для языковой модели
/now/chart, /now/context, /now/subject Данные для текущего момента по Гринвичу

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

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

Контракт ответа

Для одиночных карт используется модель SingleChartDataModel, для парных — DualChartDataModel.

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

Позиция астрологического объекта описывается не одним числом:

{
  "name": "Sun",
  "sign": "Lib",
  "position": 26.893554814425187,
  "abs_pos": 206.8935548144252,
  "house": "Twelfth_House",
  "retrograde": false,
  "speed": 0.9935835852381062,
  "declination": -12.41
}
Enter fullscreen mode Exit fullscreen mode

abs_pos хранит абсолютную долготу — те же 26°53′ Весов, только в непрерывной шкале от 0 до 360. Именно она удобна для расчёта аспектов: разница двух abs_pos даёт дугу между объектами, а position нужна только для отображения человеку.

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

Ошибка, которая выглядит незначительной

Одна из важных деталей обнаружилась при обработке аспектов транзитной карты.

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

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

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

Веб-платформа: не только построение карты

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

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

Отдельная часть системы — админка с метриками использования AI и лимитами.

Как отлаживать интеграцию с LLM

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

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

const debugContext = Buffer.from(anonymizedContext)
const debugUserPrompt = Buffer.from(userPrompt)
if (debugContext.byteLength <= maxDebugHeaderBytes) {
  headers.set('X-AI-Context', debugContext.toString('base64'))
}
if (debugUserPrompt.byteLength <= maxDebugHeaderBytes) {
  headers.set('X-AI-User-Prompt', debugUserPrompt.toString('base64'))
}
Enter fullscreen mode Exit fullscreen mode

Их можно открыть через кнопку информации рядом с удалением интерпретации. В диалоге две вкладки: «Chart Context» — ровно тот XML, что ушёл в модель, и «Full User Prompt» — ровно тот промпт, что был собран.

Это помогает разбирать проблему по этапам:

  1. Корректно ли собраны данные расчёта?
  2. Правильно ли сформирован контекст?
  3. Какие инструкции попали в итоговый промпт?
  4. Где именно возникает ошибка — в подготовке данных или в ответе модели?

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

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

Telegram-бот: календарь как серия расчётов

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

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

Самый интересный сценарий — персональный календарь транзитов.

Недельный обзор

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

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

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

Месячный календарь

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

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

score = Σ (
    aspect_weight
    × (1 - orb / 3)
    × planet_speed_factor
)
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

Состояние диалога в stateless-боте

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

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

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

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

Кеширование LLM: почему исправление кода не помогло

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

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

Причина оказалась двойной.

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

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

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

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

Версия промпта — часть ключа кеша

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

const PROMPT_VERSION = 'bot-short-v2'

const key = `${PROMPT_VERSION}:${hash(
  data?.subject ?? data?.first_subject ?? data
)}`
Enter fullscreen mode Exit fullscreen mode

Время жизни кеша зависит от типа данных:

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

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

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

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

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

Контент-конвейер: от эфемерид до публикации

Отдельное направление проекта — автоматизация контента для Дзена.

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

Обложки собираются с помощью Pillow в размере 1200 × 800 пикселей. Для них предусмотрены шесть цветовых палитр, а оформление выбирается по типу события с использованием регулярных выражений:

RULES = [
    (r'полнолун', 'violet'),
    (r'новолун', 'violet'),
    (r'нептун',   'teal'),
    (r'трин',     'forest'),
    (r'квадратур', 'warm'),
    (r'соединени', 'rose'),
    (r'ретроград', 'deep'),
]
Enter fullscreen mode Exit fullscreen mode

Для полнолуния выбирается фиолетовая палитра, для соединения — розовая, для трина — лесная. Тем же способом подбирается короткая рубрика вроде «Полнолуние» или «Квадратура».

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

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

Иными словами, автоматизация здесь касается не только производства материалов, но и правил, которым они должны соответствовать.

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

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

Вместо заключения

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

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

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

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

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

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

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

Ссылки

Top comments (0)