Как устроены Гималаи
Веб-приложение и Telegram Mini App для подготовки к ЕГЭ. Лента подбирает следующую задачу по уровню ученика, после ошибки ведёт по подсказкам, а помощник Химо объясняет именно его попытку. Учитель видит, кто занимался и где ошибается.
Система
Все запросы проходят через nginx. API — единственное место с логикой и данными: бот не ходит в базу, а обращается к API по внутреннему HTTP с общим секретом. Telegram Bot API с российских серверов недоступен, поэтому бот ходит к нему через туннель, который пропускает только сети Telegram. Модель, почта и всё остальное идут напрямую.
Четыре решения, на которых всё держится
- Модель не ставит оценок. Проверка ответа и рейтинг ученика — детерминированный код. Модель только объясняет, опираясь на проверенное решение.
- Подсказки готовы заранее. Решения и четыре ступени подсказок написаны и проверены до показа. Подсказка открывается мгновенно и не может оказаться выдумкой.
- Один процесс API — сознательно. На текущей нагрузке один сервер проще и дешевле кластера. Запас измерен: около 40 RPS при контрольных 10.
- Данные в России. База, API и модели рантайма размещены в России. Через туннель идёт только трафик Telegram, обращений к модели в нём нет.
Компоненты
Монорепозиторий pnpm на TypeScript 6 и Node 24. Доменная логика вынесена в пакет
core без ввода-вывода: им пользуются и сервер, и клиент, а тестируется он без базы и
сети.
Приложения
api- Fastify 5, Drizzle, postgres-jsСессии, практика, Химо, кабинет учителя, домашние задания, метрики. Фоновые задачи: рассылки, снимки прогресса, очистка удалённых аккаунтов.
web- React 19, Vite 8, react-router 8Сайт, приложение ученика и учителя, панель метрик. Та же сборка открывается как Telegram Mini App, с темой и кнопками Telegram.
bot- grammYВход через Telegram, привязка аккаунта, приглашения учителя, кнопка Mini App, доставка уведомлений. В базу не ходит.
Библиотеки
core- Рейтинг Elo, подбор задачи, проверка ответа, определения событий и метрик.
contracts- Zod-схемы API, общие для веба, бота и сервера.
db- Схема Drizzle, 22 миграции, аналитические запросы панели, CLI для операций.
llm- Провайдер Cloud.ru с OpenAI-совместимым API, таймауты, повтор, промпты, учёт токенов и рублей.
log- JSON-логи pino, алерты в Telegram без данных пользователей, пульс, обработка падений.
mail- Отправка кодов входа через Unisender Go.
Что происходит, когда ученик нажимает «Дальше»
Решение задачи
- клиент → API
POST /api/next - API → базарейтинг по номерам, история показов
- coreподбор задачи с вероятностью решения около 0.7
- клиент → API
GET /api/serves/:id, условие и файлы - клиент → API
POST /api/serves/:id/attempts, ответ - coreпроверка по спецификации номера, новый рейтинг
- API → базапопытка, освоение, событие, опыт
- API → клиентверно или нет, лестница подсказок
Подсказка и вопрос Химо
- клиент → API
POST /api/serves/:id/hints - API → базаследующая из четырёх ступеней, готовый текст
- клиент → APIвопрос Химо,
Accept: text/event-stream - API → базадневной лимит, условие, попытки, открытые ступени
- API → LLMпока ответ не раскрыт, ключа в контексте нет
- API → клиенттокены модели стримом SSE
- APIответ модели проверяется на утечку ключа
- API → базареплика и строка
llm_calls: токены, задержка, рубли
Вход: три пути к одной учётной записи
- Почта
- Шестизначный код живёт 10 минут, даёт 5 попыток. В базе хранится только HMAC кода.
- Mini App
- Подпись
initDataпроверяется по HMAC-SHA256 и должна быть не старше часа. - Telegram с сайта
- Сайт открывает бота с одноразовым кодом, ученик жмёт «Да, войти», сайт узнаёт об этом опросом.
- Сессия
- На вебе httpOnly-cookie на 30 дней с продлением и CSRF-заголовком. В Mini App Bearer-токен, только в памяти.
Задача, которую ученик решит с вероятностью 70%
У ученика свой рейтинг по каждому номеру экзамена, по схеме Elo. Вероятность решить задачу зависит от разницы между рейтингом и сложностью. Лента выбирает задачу, где эта вероятность ближе всего к 0.7: достаточно трудно, чтобы учиться, и достаточно посильно, чтобы не бросить.
p = 1 / (1 + 10(сложность − рейтинг) / 400)
рейтинг += K · (результат − p), K снижается с 40 до 16 за первые 30 попыток
Как выбирается следующая задача
- Пул. Задачи, которых ученик не видел, и нерешённые, показанные больше 14 дней назад.
- Тренировка. Если ученик выбрал один номер, берём из него.
- Возврат. После ошибки лента возвращается к этому номеру.
- Старт. Пока попыток меньше пяти — по задаче каждого номера.
- Номер. Слабость, давность, вес в экзамене; один номер не идёт трижды подряд.
- Задача. p ближе всего к 0.7, случайно из трёх ближайших.
Это одна чистая функция, случайность передаётся ей снаружи, поэтому в тестах подбор детерминирован. Номер считается освоенным после трёх попыток при p ≥ 0.7.
Проверка ответа
У каждого номера своя типизированная спецификация ответа: целое число, цифры, кортеж, строки таблицы, слово, набор цифр, выбор варианта, десятичная дробь. Ответ не того формата отклоняется с пояснением и не засчитывается как ошибка.
Химо объясняет, но не оценивает
Модели и ограничения
- Шлюз
- Sber500, каталог Cloud.ru, OpenAI-совместимый API
- Чат
deepseek-v4-pro, без рассуждений- Фото задач
kimi-k2.6- Закрепление
- с чужим адресом или моделью API не стартует
- Ответ
- до 800 токенов в чате
- Таймаут
- 60 с на попытку и между чанками стрима
- Повтор
- один, и только до первого байта ответа
- Лимит
- 20 вопросов и 40 фото на ученика в день
Что модель видит
| До раскрытия | После | |
|---|---|---|
| Условие задачи | да | да |
| Попытки ученика | да | да |
| Открытые ступени | да | да |
| Ключ ответа | нет | да |
| Проверенное решение | нет | да |
Текст ученика отделён случайной границей против инъекций, ответ модели проверяется на утечку ключа.
LLM-cost / DAU
Каждый вызов модели пишет строку в llm_calls: назначение, токены на
входе, выходе и из кеша, задержку, время до первого токена, исход и стоимость в рублях. Панель
метрик считает стоимость на DAU, на вызов и на действие и шлёт алерт, если порог превышен.
| Величина | Оценка | Как получена |
|---|---|---|
| Один ответ Химо | ≈ 1 ₽ | сверху: 3000 входных токенов × 0.183 ₽ и 800 выходных × 0.732 ₽ за тысячу |
| Потолок на ученика в день | ≈ 20 ₽ | дневной лимит, 20 вопросов |
| На DAU | ≈ 1 ₽ | если 30% активных спрашивают Химо по 3 раза в день |
| 1000 DAU в месяц | ≈ 30 000 ₽ | при той же доле |
Цены DeepSeek V4 Pro на октябрь 2026. Фактическое значение — на вкладках «ИИ» и «Техника» панели метрик.
Данные
PostgreSQL 18, схема в Drizzle, 22 миграции.
- Люди и вход
usersidentitiessessionsemail_challengesconsents- Банк
subjectstask_typestaskstask_keystask_solutionshint_rungs- Практика
servesattemptsmasteryrecovery_duehint_opensxp_ledger- Химо
himo_threadshimo_turnshimo_days- Учитель
teacher_groupsteacher_linksassignmentssubmissionsreviews- Метрики
eventsllm_callsapi_loadmastery_snapshotsmetrics_daily
Уведомления через outbox
Раз в минуту планировщик API записывает уведомления с ключом дедупликации: напоминание, серия под угрозой, срок домашнего задания, итоги недели. Бот забирает их пачками под аренду и подтверждает доставку, поэтому повтор после сбоя не создаёт дубля.
Логи и метрики
Логи и алерты
- Логи
- JSON-строки в journald, срок хранения не ограничен; access-логи nginx за год
- Ошибки
- алерт в Telegram: тип, код, статус, число повторов за 10 минут
- Браузер
- ошибки клиента пишутся в лог API, алерт от 20 за 10 минут
- Пульс
- healthchecks.io раз в минуту, пока свежи проба базы и планировщик
- Падение
- запись в лог, алерт, выход; systemd перезапускает процесс
В лог не попадают строки запроса, заголовки, тела, почта, коды и токены, в алерт — никакие данные пользователей.
Панель метрик
- Аудитория
- DAU, новые и вернувшиеся, целевое действие, воронка онбординга
- Активность
- взаимодействия на DAU по типам
- Удержание
- возврат на 1, 3 и 7 день
- ИИ
- вызовы, медиана и p95 задержки, ошибки, стоимость на DAU
- Техника
- средний и пиковый RPS, токены в секунду, нагрузочный тест
DAU — люди хотя бы с одним взаимодействием за московские сутки, без аккаунтов команды. Есть выгрузка в CSV и публичная страница по ссылке с токеном.
Около 40 RPS на основном цикле
Прогон 13 октября 2026 на боевом сервере: отдельный экземпляр API со своей базой и заглушкой модели, 300 синтетических учеников, k6 на той же машине. Шаг ученика — открыть приложение, взять задачу, ответить; в среднем это 4.35 запроса. Уровень засчитан, если p95 меньше 500 мс, а ошибок меньше 1%.
| Цель | Факт | p50 | p95 | p99 | Итог |
|---|---|---|---|---|---|
| 5 | 4.8 | 23 | 299 | 641 | засчитан |
| 10 | 10.9 | 29 | 472 | 899 | засчитан |
| 20 | 19.6 | 20 | 77 | 514 | засчитан |
| 30 | 29.9 | 21 | 88 | 444 | засчитан |
| 40 | 39.7 | 19 | 58 | 165 | засчитан |
| 60 | 59.9 | 47 | 935 | 1517 | ✕ p95 |
Запросы в секунду и миллисекунды; ошибок 0% на всех уровнях. Первые два уровня медленнее, пока процесс прогревается. Упирается всё в CPU: API, Postgres и k6 делят два ядра, так что для одного API цифра занижена. Вызовы модели в тест не входят: их задержка и скорость генерации — на вкладке «Техника».
Развёртывание и безопасность
- Ветка SBH-N задача в Jira, тест до кода, слияние в main локально
- GitHub Actions lint, typecheck и тесты на Postgres 18
- Выкладка архив main на сервер, установка, сборка веба, миграции, перезапуск
- Наблюдение пульс, алерты, панель метрик
Сервер
- ОС
- Ubuntu 24.04 в Москве; swap; BBR для медленных мобильных сетей
- Сервисы
- API и бот под systemd с автоперезапуском; Postgres слушает только localhost
- Веб
- nginx отдаёт сборку с хешами в именах файлов, кеш на год; TLS от certbot
- Секреты
- только в файлах окружения на сервере, в репозитории их нет
Безопасность и 152-ФЗ
- Данные
- база, API и модели рантайма размещены в России
- Согласия
- фиксируются до регистрации, отдельно для веба и Telegram
- Удаление
- 30 дней на восстановление, затем очистка и обезличивание событий
- Загрузки
- фото перекодируются, метаданные удаляются
- Заголовки
- HSTS, nosniff; встраивание страницы разрешено только Telegram
- Лимиты
- на вход, гостевой режим, события, загрузки и вопросы Химо