<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Dskr.dev</title><description>Личный блог Дмитрия Скрыльникова о веб-разработке, TypeScript, self-hosted проектах и практических экспериментах.</description><link>https://dskr.dev/</link><item><title>Как правильно установить node.js окружение?</title><link>https://dskr.dev/blog/how-to-install-node/</link><guid isPermaLink="true">https://dskr.dev/blog/how-to-install-node/</guid><description>Расскажу зачем и почему нужны fnm и corepack</description><pubDate>Mon, 18 Sep 2023 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Установка Node.JS&lt;/h2&gt;
&lt;p&gt;Существует несколько способов установки Node.JS и сложно понять какой из них лучше. В этой статье мы сравним разные способы (прямая установка, системные пакетные менеджеры, nvm, n, nodenv, volta, fnm). TLDR: я предпочитаю использовать fnm&lt;/p&gt;
&lt;p&gt;Самый простой вариант установить Node.JS это пойти на &lt;a href=&quot;https://nodejs.org&quot;&gt;nodejs.org&lt;/a&gt; и скачать установщик. Но это не лучшая идея. Вариант чуть получше, воспользоваться системным пакетным менеджером(&lt;code&gt;brew&lt;/code&gt;, &lt;code&gt;apt&lt;/code&gt;, &lt;code&gt;dnf&lt;/code&gt;). В обоих случаях нельзя быстро сменить версию Node.JS. Часто актуальный проект требует свежую LTS версию, легаси проект работает под предыдущим LTS, а так же хочется попробовать новые фишки из Current версии.&lt;/p&gt;
&lt;p&gt;Самое популярное решение это &lt;a href=&quot;https://github.com/nvm-sh/nvm&quot;&gt;Node Version Manager &lt;code&gt;nvm&lt;/code&gt;&lt;/a&gt;. Но, во-первых, он невероятно медленный. Каждое открытие консоли с установленным &lt;code&gt;nvm&lt;/code&gt; будет занимать до нескольких секунд. Во-вторых, он не поддерживает fish и windows. В-третьих, глобально установленные пакеты прийдётся ставить каждый раз заново, либо нужно прописывать их в специальном файле. &lt;a href=&quot;https://github.com/tj/n&quot;&gt;n&lt;/a&gt; и &lt;a href=&quot;https://github.com/nodenv/nodenv&quot;&gt;nodenv&lt;/a&gt; имеют примерно те же проблемы.&lt;/p&gt;
&lt;p&gt;Следующее решение это &lt;a href=&quot;https://volta.sh/&quot;&gt;Volta&lt;/a&gt;. Написано на Rust, а значит должно быть быстрым. Поддерживает fish и даже windows. Глобально установленные библиотеки работают даже после смены версии. Но не поддерживается &lt;code&gt;.nvmrc&lt;/code&gt;/&lt;code&gt;.node-version&lt;/code&gt; файлы, хотя на замену тут предлагается писать версию ноды в &lt;code&gt;package.json&lt;/code&gt;. Ещё из недостатков, поддержка &lt;code&gt;pnpm&lt;/code&gt; находится в экспериментальном режиме.&lt;/p&gt;
&lt;p&gt;И наконец &lt;a href=&quot;https://fnm.vercel.app&quot;&gt;Fast Node Manager &lt;code&gt;fnm&lt;/code&gt;&lt;/a&gt;. Написан также на Rust. Работает быстро и поддерживает всё что надо. Устанавливается fnm одной командой:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -fsSL https://fnm.vercel.app/install | bash
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Далее можно установить версию Node.JS которая будет использоваться по умолчанию:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;fnm default 18
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Теперь можно добавить в проект файл &lt;code&gt;.node-version&lt;/code&gt; с указанием версии Node.JS, которая будет использоваться в этом проекте.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;echo 18.17.0 &amp;gt; .node-version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;При открытии консоли в папке с проектом, fnm автоматически включит нужную версию Node.JS.&lt;/p&gt;
&lt;p&gt;Есть небольшой недостаток, если в проекте указана версия Node.JS которая не установлена, автоматически она не установится, но при открытии консоли отобразится ошибка. Для установки нужной версии нужно будет ввести целых две команды&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;fnm install
fnm use
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Установка pnpm/yarn&lt;/h2&gt;
&lt;p&gt;Node.JS, начиная с версии 16.13, получил поддержку &lt;a href=&quot;https://github.com/nodejs/corepack&quot;&gt;Corepack&lt;/a&gt;. Эта утилита позволяет использовать любой поддерживаемый пакетный менеджер без ручной установки. Всё что нужно сделать, это включить corepack:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;corepack enable
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;После чего при первом использовании &lt;code&gt;pnpm&lt;/code&gt;/&lt;code&gt;yarn&lt;/code&gt; они автоматически установятся и будут работать.&lt;/p&gt;
</content:encoded></item><item><title>Photo lib share - фото галерея своими руками</title><link>https://dskr.dev/blog/photo-lib-part-1/</link><guid isPermaLink="true">https://dskr.dev/blog/photo-lib-part-1/</guid><description>Попытка написать selfhosted Google Photos без фатального недостатка 😀</description><pubDate>Mon, 12 Jun 2023 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Идея написать свою фото галерею появилась у меня пару лет назад. И даже начал что-то писать, но довольно быстро забил. С тех времён у меня была почти готовая библиотека для создания красивого лэйаута. &lt;a href=&quot;https://github.com/skrylnikov/photo-flex-layout&quot;&gt;Photo flex layout&lt;/a&gt; - это имплементация &lt;a href=&quot;https://medium.com/google-design/google-photos-45b714dfbed1&quot;&gt;Google Photo Flex Layout&lt;/a&gt;. Выпустил версию 1.0.0, подробнее про неё писал в &lt;a href=&quot;https://t.me/dskr_dev/140&quot;&gt;Telegram канале&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;В планах написать два модуля:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Photo lib&lt;/code&gt; - приложение для хранение и организации фоточек. Ставится он должен в идеале на NAS. Пока он не написан, использую synology photos. Но единственный его плюс, это неплохая синхронизация через мобильное приложение, в остальном одни минусы. Нет возможности посмотреть все фото наа карте, raw и jpeg не объединяются в одно фото, нет архива, нельзя поставить условие &lt;code&gt;and&lt;/code&gt; при фильтрации по тегам. И это первое что пришло в голову&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Photo lib share&lt;/code&gt; - галерея для шеринга фоточек и альбомов. Ставится он должен куда-нибудь на публичный сервер, чтоб можно было смотреть на фоточки даже если &lt;code&gt;Photo lib&lt;/code&gt; не доступен. Начать я решил именно с этого модуля.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;В данный момент готов MVP. Посмотреть на работу можно на &lt;a href=&quot;https://dskr.photos/&quot;&gt;dskr.photos&lt;/a&gt;. Исходники на &lt;a href=&quot;https://github.com/skrylnikov/photo-lib&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Нулевая версия&lt;/h2&gt;
&lt;p&gt;Начал с того что взял старый код, выкинул всё что было не нужно, обновил зависимости и заставил это всё хоть как-то работать. Превью подготавливал с помощью &lt;a href=&quot;https://sharp.pixelplumbing.com/&quot;&gt;sharp&lt;/a&gt;, mozjpeg, 80%, 1000 пикселей по длиной стороне. Превью генерировались во время индексации, что не очень удобно. Между беком и фронтом, просто http, на сервере koa. Метадата хранится в &lt;a href=&quot;https://www.npmjs.com/package/nedb-promises&quot;&gt;nedb&lt;/a&gt;. Эту версию так никуда и не выложил.&lt;/p&gt;
&lt;h2&gt;Первая версия&lt;/h2&gt;
&lt;p&gt;Добавил docker compose для нормального деплоя. Пришлось изучить как правильно писать докерфайлы. Долго матерился.&lt;/p&gt;
&lt;p&gt;Базу поменял на postgres, взял orm &lt;a href=&quot;https://www.prisma.io/&quot;&gt;prisma&lt;/a&gt;. До этого обычно использовал sequelize, это был первый опыт работы с призмой. В целом понравилось, из косяков, при кодогенерации нужно задавать валидную переменную окружения с строкой подключения к базе, что не очень хорошо ложится на логику использования докера. Описал одну единственную таблицу с нужными и не очень полями.&lt;/p&gt;
&lt;p&gt;Sharp поменял на imgproxy. Но это оказалось плохое решение. В целом imgproxy клёвый, но он явно сделан не для этого. Из косяков, imgproxy не умеет в кеширование, всё что он делает, это выставляет правильные заголовки кеширования. Для решения этой проблемы пришлось настроит кеширующий nginx. Но проблему первого открытия это всё равно не решило, большие фоточки конвертировались медленно. Превью запрашивал под необходимый размер, но чтоб работало кеширование, округлял до 25 в большую сторону.&lt;/p&gt;
&lt;p&gt;Lightbox быстро написал сам, получилось плохо не удобно и не красиво.&lt;/p&gt;
&lt;p&gt;Сделал нормальный монорепозиторий. Для управления взял &lt;a href=&quot;https://turbo.build/repo&quot;&gt;turborepo&lt;/a&gt;. Сразу вынес отдельно будущий общий код &lt;code&gt;Photo lib&lt;/code&gt; и &lt;code&gt;Photo lib share&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Задеплоил, собрал фидбек. Ожидаемо всё было плохо с lightbox. Так же загружались оригиналы в lightbox, что тоже не оч хорошая идея.&lt;/p&gt;
&lt;h2&gt;Вторая версия&lt;/h2&gt;
&lt;p&gt;Поменял схему базы. Теперь у меня есть &lt;code&gt;Image&lt;/code&gt; с основной информацией вроде времени создания и exif. &lt;code&gt;File&lt;/code&gt; с ссылкой на оригинальное фото. &lt;code&gt;Thumbnail&lt;/code&gt; с ссылкой на всевозможные превью.&lt;/p&gt;
&lt;p&gt;Выкинул imgproxy, вернул sharp. Теперь генерю webp и avif. Качество пока выставил 80 для webp и 65 для avif. Хотел добавить ещё heif и jxl, но для них нужно руками собирать плюсовую либу. Генерирую два размера, полноразмерную превью для показал в lightbox и 720px по длинной стороне для галереи. Так как avif генерируются медленно, использую очередь и отдельный поток для генерации. Для очереди пока что взял &lt;a href=&quot;https://docs.bullmq.io/&quot;&gt;bullmq&lt;/a&gt;. Если никакой превью ещё не сгенерировано, то загружаю оригинальную фотку.&lt;/p&gt;
&lt;p&gt;Выкинул самописный lightbox и взял &lt;a href=&quot;https://photoswipe.com/&quot;&gt;photoswipe&lt;/a&gt;. Выбрал его в первую очередь из-за красивой анимации открытия фотки. Photoswipe написан без фреимворков, но его можно подключить например к реакту. Из косяков, у меня не подтягиваются типы. Так же взял &lt;a href=&quot;https://www.framer.com/motion/&quot;&gt;framer-motion&lt;/a&gt; для красивой анимации появления фоток в галерее. Не уверен что это хорошее решение, но пока решил оставить так.&lt;/p&gt;
&lt;p&gt;Взял &lt;a href=&quot;https://trpc.io/&quot;&gt;tRPC&lt;/a&gt; вместо koa. Хотел попробовать &lt;a href=&quot;https://garph.dev/&quot;&gt;Garph&lt;/a&gt;, но он пока что очень сырой, писал про это в &lt;a href=&quot;https://t.me/dskr_dev/146&quot;&gt;Telegram канале&lt;/a&gt;. tRPC достаточно стабильный и удобный. Но мне не нравится что фронтенд проект импортирует бэкенд проект. Как минимум это замедляет билд. В идеале хотелось бы иметь возможность вынести схему отдельно, но пока не понял как это сделать удобно.&lt;/p&gt;
&lt;p&gt;Добавил telegram бота для администрирования и загрузки фоток. Для этого взял &lt;a href=&quot;https://grammy.dev/&quot;&gt;grammY&lt;/a&gt;. Пока что бот умеет только загружать фотки, и запускать принудительную переиндексацию. Из ограничений, нельзя загружать фото больше 20мб, это можно обойти если самому хостить bot api, но пока что мне не хочется с этим разбираться.&lt;/p&gt;
&lt;p&gt;Добавил &lt;a href=&quot;https://reproxy.io/&quot;&gt;Reproxy&lt;/a&gt; для простенькой реверспрокси. Nginx я настраивать не умею, а caddy кажется overkill для таких задач. Хотел чтоб всё красиво работало через лейблы, но почему-то не смог завести на маке docker sock. Но, меня и статичный конфиг устраивает.&lt;/p&gt;
&lt;h2&gt;ToDo&lt;/h2&gt;
&lt;p&gt;Планирую заменить bullmq на rabbitmq. Bullmq всем хорош, но он работает только с нодой и в экспериментальном режиме питоном. А хочется иметь больший выбор платформ.&lt;/p&gt;
&lt;p&gt;Хочется попробовать перенести генерацию превью на rust. Это должно быть быстрее. Надеюсь что там не будет проблем с поддержкой модных форматов. Но я совсем не знаю раст, и это не тот язык который можно освоить за выходные.&lt;/p&gt;
&lt;p&gt;Надо бы добавить альбомы. Но пока что не придумал как они должны выглядеть. Так же под это дело прийдётся дорабатывать бота.&lt;/p&gt;
&lt;p&gt;Ну и уже можно начинать думать о работе над &lt;code&gt;Photo lib&lt;/code&gt;. Там у меня куда больше планов.&lt;/p&gt;
</content:encoded></item><item><title>Шесть лет разработки Telegram-бота: от токенайзера до LLM, RAG и векторных баз</title><link>https://dskr.dev/blog/telegram-bot-six-years/</link><guid isPermaLink="true">https://dskr.dev/blog/telegram-bot-six-years/</guid><description>История развития Ио: от токенайзера и первых LLM до памяти, RAG и векторного поиска</description><pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Ио — это LLM-бот для Telegram-чатов. Он умеет отвечать на сообщения пользователей, распознавать изображения и голосовые сообщения, учитывать текущий тред, историю переписки и информацию о пользователе. Сейчас бот живёт в нескольких десятках чатов.&lt;/p&gt;
&lt;p&gt;Расскажу, как я пилил его несколько лет, пытался научить помнить пользователей, разбирался с RAG и векторными базами и в итоге получил систему, которая действительно работает.&lt;/p&gt;
&lt;h2&gt;Как появилась идея?&lt;/h2&gt;
&lt;p&gt;Много лет назад существовало околокосмическое комьюнити Alpha Centauri. У него был оффтоп-чат, а в нём жил чат-бот. Никаких LLM тогда ещё не существовало, бот работал на регулярках, умел искать погоду, банить пользователей, запоминать информацию и прочие приколюхи. Чат вместе с ботом канул в Лету, а люди разбежались по другим чатам. И через какое-то время мне захотелось написать похожего бота.&lt;/p&gt;
&lt;h2&gt;Прототип из эпохи до LLM&lt;/h2&gt;
&lt;p&gt;Первая версия была написана летом 2020 года. В те времена только вышла GPT-3, в русский язык она не умела, да и до чатов не добралась, поэтому пришлось изобретать велосипеды. Я не знал регулярки, поэтому решил разбить текст на токены, выделить среди них смысловые и запустить нужную функцию.&lt;/p&gt;
&lt;p&gt;Токенайзер работал так: удалял из текста ссылки, разбивал его по знакам препинания на предложения, затем предложения — на слова, переводил их в нижний регистр и применял стеммер.&lt;/p&gt;
&lt;p&gt;Например, слова «расскажи» и «рассказать» превращаются в основу «расска», а «погода» и «погоду» — в «погод». Таким образом, список искомых команд не разрастался до десятков вариантов написания одного и того же. В русском языке есть всякие склонения, падежи и прочие усложнения, поэтому работало это не идеально.&lt;/p&gt;
&lt;p&gt;Местами всё равно приходилось писать несколько вариантов:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;switch (nextWorld) {
  case PorterStemmerRu.stem(&apos;мин&apos;):
  case PorterStemmerRu.stem(&apos;минут&apos;): {
    return number * 60;
  }
  case PorterStemmerRu.stem(&apos;час&apos;): {
    return number * 60 * 60;
  }
  case PorterStemmerRu.stem(&apos;дня&apos;):
  case PorterStemmerRu.stem(&apos;дней&apos;):
  case PorterStemmerRu.stem(&apos;день&apos;): {
    return number * 60 * 60 * 24;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Среди первых функций были прогноз погоды, возможность банить пользователей, распознавание войсов и перевод текстов.&lt;/p&gt;
&lt;p&gt;С погодой тоже были сложности. Пользователь мог писать имя города в разных вариантах, поэтому, чтобы определить город, я пользовался сервисом &lt;a href=&quot;https://dadata.ru/&quot;&gt;DaData&lt;/a&gt;. Сам прогноз брал и до сих пор беру из &lt;a href=&quot;https://openweathermap.org/&quot;&gt;OpenWeather&lt;/a&gt;. Источник не лучший, зато бесплатный.&lt;/p&gt;
&lt;p&gt;Ну а для перевода и распознавания войсов использовал сервисы Яндекса. Кружки тоже хотелось распознавать, поэтому пришлось затащить &lt;a href=&quot;https://ffmpeg.org/&quot;&gt;FFmpeg&lt;/a&gt; для извлечения аудио из видео.&lt;/p&gt;
&lt;p&gt;В таком виде бот был заброшен на долгие три года. Мне, конечно же, хотелось затащить побольше фичей, но, во-первых, мешала прокрастинация, а во-вторых — сложность. Сделать что-то полезное и интересное на базе примитивного токенайзера было сложно.&lt;/p&gt;
&lt;h2&gt;Первые попытки с LLM&lt;/h2&gt;
&lt;p&gt;Сначала я попробовал написать отдельного бота. В тот момент удобного способа вызывать функции через LLM ещё не было. Получился примитивный бот на базе GPT-3.5 Turbo. Контекст хранился в оперативной памяти, поэтому после перезапуска ничего не сохранялось. Работал он только внутри треда.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/assets/telegram-bot-six-years/02-llm-commands.jpeg&quot; alt=&quot;Чтобы было веселее, бота можно было вызывать разными командами, и в зависимости от команды в системный промпт вставлялись разные хаки&quot;&gt;&lt;/p&gt;
&lt;p&gt;С полученным опытом я пошёл переписывать Ио. LLM вызывалась, если пользователь обращался к боту, но ни одна из обычных команд не срабатывала.&lt;/p&gt;
&lt;p&gt;В таком виде бот прожил до 2024 года. После этого я добавил нормальную работу с контекстом. Все сообщения хранились в базе данных. Каждое сообщение ссылается на реплай, таким образом строится дерево диалога. Такой подход позволял, например, получить другой ответ на тот же запрос: достаточно было ответить на нужное сообщение в истории, и бот продолжал диалог именно с этой точки, не затрагивая остальные ветки переписки.&lt;/p&gt;
&lt;p&gt;В это же время я перешёл на GPT-4o mini и добавил Tool Calling, чтобы модель сама выбирала и вызывала нужные функции. Соответственно, все предыдущие костыли с токенайзером больше были не нужны и были выброшены. Заодно была выпилена функция банов пользователей, так как к тому моменту её никто уже не использовал.&lt;/p&gt;
&lt;p&gt;Также я добавил поддержку изображений. Сначала бот просто передавал ссылку на картинку в каждый запрос к модели, но это оказалось слишком дорого. Поэтому обработку изображений я вынес в отдельный этап: когда пользователь отправляет фото, бот сразу прогоняет его через модель, получает текстовое описание и сохраняет его в базу. Позже, если картинка попадает в контекст диалога, используется уже готовое описание. В таком виде эта функция работает до сих пор.&lt;/p&gt;
&lt;p&gt;Поскольку бота можно было свободно добавлять в любые чаты, обработка изображений сначала работала только в доверенных. Даже с этим ограничением бот съедал на моделях около $10–20 в месяц.&lt;/p&gt;
&lt;h2&gt;Учим бота запоминать информацию&lt;/h2&gt;
&lt;h3&gt;Первая версия памяти&lt;/h3&gt;
&lt;p&gt;В 2025 году я начитался про RAG (Retrieval-Augmented Generation, метод работы с большим количеством данных), но был ещё не готов разбираться с векторными базами и прочими сложностями. Поэтому решил пойти самым простым путём. Добавил в базу поле &lt;code&gt;metaInfo&lt;/code&gt; с JSON, в котором хранилась собранная информация о пользователе. Раз в 10 сообщений обновлял это поле таким запросом:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/assets/telegram-bot-six-years/03-context-tree.jpeg&quot; alt=&quot;Запрос для обновления памяти пользователя&quot;&gt;&lt;/p&gt;
&lt;p&gt;А дальше в каждый запрос добавлял эту информацию с такими приписками:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/assets/telegram-bot-six-years/04-image-processing.jpeg&quot; alt=&quot;Информация о пользователе в контексте запроса&quot;&gt;&lt;/p&gt;
&lt;p&gt;Память заработала, теперь Ио знала что-то о пользователе.&lt;/p&gt;
&lt;p&gt;&amp;lt;details&amp;gt;
&amp;lt;summary&amp;gt;Скрытый текст&amp;lt;/summary&amp;gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/assets/telegram-bot-six-years/05-memory-json.jpeg&quot; alt=&quot;Пример сохранённых фактов о пользователе&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/assets/telegram-bot-six-years/06-memory-facts.jpeg&quot; alt=&quot;Ещё один пример сохранённых фактов&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/assets/telegram-bot-six-years/07-funny-dialogue.jpeg&quot; alt=&quot;Иногда это позволяло генерировать забавные диалоги&quot;&gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;/details&amp;gt;&lt;/p&gt;
&lt;p&gt;Но память работала не идеально. У одной девушки было гендерно-нейтральное имя, и Ио ни в какую не хотела запоминать, что она девушка. Я переписал метаинформацию и добавил фактам веса. Теперь модель оценивала важность каждого факта, а я увеличивал вес, если он повторялся. Но и это не помогло. Информация о гендере пользователя, по мнению модели, оказалась совсем не важной) В итоге проблема решилась простым ручным редактированием базы.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/assets/telegram-bot-six-years/08-gender-fact.jpeg&quot; alt=&quot;Редактирование факта о пользователе&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/assets/telegram-bot-six-years/09-unsafe-replies.jpeg&quot; alt=&quot;Неудачная попытка изменить поведение бота&quot;&gt;&lt;/p&gt;
&lt;p&gt;Эта же пользовательница очень сильно старалась заставить Ио начать писать что-нибудь неприличное. Это вылилось в то, что Ио начала отвечать неадекватно вообще на все её сообщения.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/assets/telegram-bot-six-years/10-clear-memory.jpeg&quot; alt=&quot;Пример неадекватных ответов&quot;&gt;&lt;/p&gt;
&lt;p&gt;Пришлось добавить возможность очистить память.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/assets/telegram-bot-six-years/11-memory-reset.jpeg&quot; alt=&quot;Очистка памяти пользователя&quot;&gt;&lt;/p&gt;
&lt;h2&gt;Теперь всё-таки векторы&lt;/h2&gt;
&lt;p&gt;В таком виде бот просуществовал до 2026 года. К этому времени из-за развития агентов порог входа в подобные эксперименты сильно снизился, и мне наконец захотелось попробовать векторные базы. В моём представлении RAG строился просто: взять все сообщения, положить их в векторную базу, а при новом запросе найти похожие и добавить их в контекст. Всё — модель знает историю пользователя и учитывает её в ответах. Как же сильно я ошибался.&lt;/p&gt;
&lt;p&gt;Очень быстро выяснилось, что поиск похожих сообщений почти бесполезен. Большинство найденных сообщений были просто кусками старых диалогов без какой-либо долгосрочной ценности. Модели гораздо полезнее знать устойчивые факты о пользователе, чем перечитывать его переписку. Поэтому я продолжил использовать два подхода: векторный поиск по сообщениям и отдельное извлечение фактов.&lt;/p&gt;
&lt;p&gt;После этого я переписал сбор фактов. Теперь модель извлекает факт и оценивает его важность. Затем векторный поиск находит среди сохранённых фактов похожие, а модель решает, нужно ли увеличить вес существующего факта, обновить его или сохранить новый.&lt;/p&gt;
&lt;p&gt;Такая сложность нужна потому, что один и тот же факт модель каждый раз может формулировать немного по-разному. Заодно появилась возможность актуализировать информацию, если она противоречит старым данным. При новом запросе факты пользователя ранжируются по дате и важности, после чего в контекст попадает топ-10.&lt;/p&gt;
&lt;h2&gt;Мелкие полезные фичи&lt;/h2&gt;
&lt;p&gt;Суммаризация войсов. Если войс длинный, бот делает саммари, а оригинальный текст прячет под спойлер. Также прогоняю расшифровку через LLM, чтобы расставить знаки препинания и убрать повторы.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/assets/telegram-bot-six-years/12-voice-summary.jpeg&quot; alt=&quot;Пример суммаризации голосового сообщения&quot;&gt;&lt;/p&gt;
&lt;p&gt;Guest Mode. Возможность призвать бота в любой чат и пообщаться с ним там. Это похоже на inline mode, только диалог не ограничивается одним запросом.&lt;/p&gt;
&lt;p&gt;Поиск по Википедии. Позволяет модели хоть иногда не косячить в фактах.&lt;/p&gt;
&lt;p&gt;Явная память. Возможность напрямую попросить бота что-нибудь запомнить. Это решило проблему, когда для исправления фактов приходилось руками лезть в базу.&lt;/p&gt;
&lt;h2&gt;Монетизация&lt;/h2&gt;
&lt;p&gt;Недавно запилил монетизацию. В бесплатной версии есть дневные лимиты на сообщения, распознавание картинок и войсов. Ограничение на сообщения мягкое: после достижения лимита бот переходит на более дешёвую модель. Можно купить подписку на неделю, месяц, три месяца или год. В зависимости от срока увеличивается ежедневный лимит. В качестве бонуса немного увеличиваются лимиты всем участникам чата, один из пользователей которого оформил подписку.&lt;/p&gt;
&lt;p&gt;&amp;lt;details&amp;gt;
&amp;lt;summary&amp;gt;Скрытый текст&amp;lt;/summary&amp;gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/assets/telegram-bot-six-years/13-subscription.jpeg&quot; alt=&quot;Лимиты и тарифы подписки&quot;&gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;/details&amp;gt;&lt;/p&gt;
&lt;h2&gt;Технологический стек&lt;/h2&gt;
&lt;p&gt;Изначально стек был таким: Node.js, TypeScript, Telegraf.js, OpenAI.&lt;/p&gt;
&lt;p&gt;Позже с Telegraf.js я перешёл на &lt;a href=&quot;https://grammy.dev/&quot;&gt;grammY&lt;/a&gt;. У него удобный API, хорошая типизация, быстрая поддержка новых версий Telegram Bot API и много полезных расширений.&lt;/p&gt;
&lt;p&gt;В качестве векторной базы изначально взял &lt;a href=&quot;https://qdrant.tech/&quot;&gt;Qdrant&lt;/a&gt;. Для моих масштабов это оказалось оверкилом: незачем поддерживать отдельную БД, когда всё спокойно помещается в PostgreSQL. Поэтому недавно я перешёл на расширение &lt;a href=&quot;https://github.com/pgvector/pgvector&quot;&gt;pgvector&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Для работы с LLM сначала перешёл на &lt;a href=&quot;https://www.langchain.com/&quot;&gt;LangChain&lt;/a&gt;, но он оказался слишком сложным. Поэтому в итоге мигрировал на &lt;a href=&quot;https://ai-sdk.dev/&quot;&gt;AI SDK от Vercel&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;В качестве провайдера сначала использовал OpenAI, затем перешёл на OpenRouter, а потом на RouterAI из-за проблем с оплатой.&lt;/p&gt;
&lt;p&gt;Промпты и трейсы запросов храню в &lt;a href=&quot;https://langfuse.com/&quot;&gt;Langfuse&lt;/a&gt;. Для моих задач это оказалось не лучшим решением, потому что промпт у меня динамически изменяется, а возможностей шаблонов не хватает. В итоге сейчас статическая часть хранится в Langfuse, а динамическая подставляется одним куском. Трейсинг я почти не использую: пока не возникало задач, для которых хотелось бы регулярно туда смотреть.&lt;/p&gt;
&lt;p&gt;Основная модель сейчас — Gemini 3.6 Flash, бюджетная — Gemini 2.5 Flash Lite. Для работы с фактами, суммаризации и распознавания картинок использую Nex-N2-mini.&lt;/p&gt;
&lt;p&gt;Векторы изначально генерировал через OpenRouter, но с переходом на RouterAI генерация эмбеддингов стала занимать слишком много времени — от 10 до 60 секунд. Бот векторизует текущее сообщение при каждом запросе, чтобы найти подходящий контекст, что создаёт ощущение, что бот тормозит. Поэтому я поднял &lt;a href=&quot;https://github.com/huggingface/text-embeddings-inference&quot;&gt;text-embeddings-inference&lt;/a&gt; с &lt;code&gt;multilingual-e5-small&lt;/code&gt;. Качество пока не сравнивал, зато эмбеддинги генерируются быстро даже на небольшом сервере.&lt;/p&gt;
&lt;p&gt;Отдельная история — инфраструктура. Сначала всё работало через Docker Compose, но при каждом деплое нужно было руками заходить на сервер, стягивать изменения и пересобирать бота. Это, конечно, можно было автоматизировать, но мне было лень. Поэтому потом бот переехал в Coolify. Там появились автоматическая сборка, развёртывание и бэкапы. Но сборка сильно нагружала сервер и могла вообще положить его на пару минут. Плюс возникали проблемы с обновлением базы данных.&lt;/p&gt;
&lt;p&gt;Поэтому решил перелезть на k3s. Теперь GitHub Actions собирает образ бота, после чего он деплоится в k3s. Бэкапы отправляются в S3-хранилище на домашнем NAS. В планах дома поднять ещё один k3s и развернуть там мониторинг, чтобы было красиво. Тем более что с агентами это делать стало очень просто и быстро (хотя есть риск, что очередной агент когда-нибудь снесёт мне и базу, и бэкапы).&lt;/p&gt;
&lt;h2&gt;Планы&lt;/h2&gt;
&lt;p&gt;Настроить нормальный мониторинг. Починить баги, сейчас в Guest mode не сохраняется контекст например. Реализовать веб-поиск, ибо сейчас есть только поиск по Википедии. Доработать инфраструктуру: сейчас бот во время деплоя несколько минут недоступен.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Ио до сих пор остаётся для меня большим полигоном для экспериментов. Многие новые идеи из мира LLM и инфраструктуры я сначала пробую именно на нём. Пока проект продолжает приносить удовольствие — значит, всё было не зря. Попробовать бота можно в &lt;a href=&quot;https://t.me/PhoronisBot&quot;&gt;Telegram&lt;/a&gt;. Исходники лежат на &lt;a href=&quot;https://github.com/skrylnikov/Phoronis-tg-bot&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
</content:encoded></item><item><title>TypeScript - номинальная типизация примитивных типов</title><link>https://dskr.dev/blog/ts-nominal-type/</link><guid isPermaLink="true">https://dskr.dev/blog/ts-nominal-type/</guid><description>Как в typescript типизовать Lat/Lng, Rub/Usd/Eur и друге похожие друг на друга типы данных</description><pubDate>Thu, 10 Feb 2022 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Координаты&lt;/h2&gt;
&lt;p&gt;В моих задачах мне приходится много работать с библиотеками, использующими координаты.
Разные библиотеки могут сохранять/передавать значения координат в разном виде.
Например, это может быть объект с двумя полями или массив с двумя значениями.
С массивом возникают некоторые сложности. В какой последовательности — и почему
именно в такой — должны идти долгота и широта? Каждый автор библиотеки решает
эту проблему по-своему, а нам страдать.&lt;/p&gt;
&lt;p&gt;Было бы классно, написать что-то такое:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;type Lat = number;
type Lng = number;

type LatLng = [Lat, Lng];
type LngLat = [Lng, Lat];

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Но это не сработает, потому что в typescript используется структурная типизация,
и оба типа работают как &lt;code&gt;[number, number]&lt;/code&gt;. К счастью, номинальную типизацию
можно эмулировать. Для этого к &lt;code&gt;number&lt;/code&gt; нужно добавить уникальный признак:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;type Lat = number &amp;amp; { __type: &apos;Lat&apos; };
type Lng = number &amp;amp; { __type: &apos;Lng&apos; };
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Теперь типы &lt;code&gt;Lat&lt;/code&gt; и &lt;code&gt;Lng&lt;/code&gt; не будут равны друг другу, что нам и нужно было.
Такой подход называется brand/opaque type. И чтобы было удобнее,
можно написать алиас.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;type Brand&amp;lt;T, U&amp;gt; = T &amp;amp; { __type: U };
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Или взять готовую реализацию из библиотеки с утилитарными типами,
например из &lt;a href=&quot;https://github.com/piotrwitek/utility-types#brandt-u&quot;&gt;utility-types&lt;/a&gt;
или &lt;a href=&quot;https://github.com/sindresorhus/type-fest#utilities&quot;&gt;type-fest&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Тогда использование будет выглядеть как-то так:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;type Lat = Brand&amp;lt;number, &apos;Lat&apos;&amp;gt;;
type Lng = Brand&amp;lt;number, &apos;Lng&apos;&amp;gt;;

type LatLng = [Lat, Lng];

const lat = 59.57 as Lat;
const lng = 30.19 as Lng;

const fn = (coords: LatLng) =&amp;gt; {...};

fn([lat, lng]) // Всё ок

fn([lng, lat]) // Ошибка

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Email&lt;/h2&gt;
&lt;p&gt;Кажется, что с адресом электронной почты можно поступить так же:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;type Email = Brand&amp;lt;string, &apos;Email&apos;&amp;gt;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Но на самом в typescript 4.1 появились литеральные типы, и они позволяют творить
магию. Например можно написать так:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;type Email = `${string}@${string}.${string}`;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Таким образом, можно проверить что тут не случайная строка, а строка в особом
формате, немного похожем на email.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const notEmail: Email = &apos;example&apos;; // Ошибка

const email: Email = &apos;mail@example.com&apos;; // Всё ок
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Но тут нужно быть осторожным, ибо одно неверное движение — и всё сломается.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// Ошибки нет, хотя строка не правильного формата
const notEmail = &apos;example&apos; as Email;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Валюты и немного дженериков&lt;/h2&gt;
&lt;p&gt;Представим, что приложение работает с разными валютами. И хочется на уровне типов
запретить складывать доллары с рублями без конвертации.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;type Rub = Brand&amp;lt;number, &apos;Rub&apos;&amp;gt;;
type Usd = Brand&amp;lt;number, &apos;Usd&apos;&amp;gt;;
type Eur = Brand&amp;lt;number, &apos;Eur&apos;&amp;gt;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Здесь полностью запретить сложение разных валют не выйдет, но
если всегда для сложения валют использовать функцию — всё будет работать.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const sumRub = (a: Rub, b: Rub): Rub =&amp;gt; {...};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;В таком случае, нам для каждой валюты придётся писать свои функции.&lt;/p&gt;
&lt;p&gt;Но можно использовать дженерики:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const sum = &amp;lt;T extends&amp;gt;(a: T, b: T): T =&amp;gt; ...;

const aRub = 300 as Rub;
const bRub = 600 as Rub;
const cUsd = 30 as Usd;

sum(aRub, bRub) // Всё ок

sum(aRub, cUsd) // Ошибка

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;А теперь используя brand type и литеральные типы, напишем функцию для конвертации
любой валюты в usd.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const toUsd = &amp;lt;Value extends Brand&amp;lt;number, B&amp;gt;, B extends string&amp;gt;(value: Value, currency: Brand&amp;lt;number, `${Value[&apos;__type&apos;]}ToUsd`&amp;gt;): Usd =&amp;gt; {};

type RubToUsdCurrency = Brand&amp;lt;number, &apos;RubToUsd&apos;&amp;gt;; 

const rubToUsdCurrency = 30 as RubToUsdCurrency;

toUsd(rub, rubToUsd); // Всё ок
toUsd(eur, rubToUsd); // Ошибка

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Немного маги. Первый аргумент в дженерике - любой brand type.
Второй аргумент - уникальная строка из brand type.
Такое усложнение нужно, чтобы можно было вытащить эту уникальную строку.
Тип currency можно было бы задать как &lt;code&gt;Brand&amp;lt;number, `${B}ToUsd`&amp;gt;&lt;/code&gt;, но тогда
при неправильном вызове функции typescript будет ругаться на первый аргумент.
Если же написать &lt;code&gt;Brand&amp;lt;number, `${Value[&apos;__type&apos;]}ToUsd`&amp;gt;&lt;/code&gt;, то ошибка будет
во втором аргументе, что логичнее.&lt;/p&gt;
</content:encoded></item></channel></rss>