Ворклоги

Прогресс: проверка архитектуры haih.site на реальном проекте

Изучили текущую реализацию haih.site и уточнили критерий минимальности архитектуры. Важный вывод: минимализм нельзя измерять количеством технологий или зависимостей. Нужно минимизировать суммарный friction разработки, эксплуатации и дальнейших изменений, включая feedback loop человека и AI.

Development stack

React/Vite сами по себе не являются избыточностью даже для сайта, который в production в основном отдаёт статический контент.

Vite оправдан реальным требованием к разработке: быстрый edit → HMR → browser feedback loop без ручных reload/cache-reset действий пользователя.

React может быть оправдан компонентной моделью и локальностью изменений растущего UI. Для AI это также даёт предсказуемую, хорошо известную структуру кода.

Уточнённый принцип:

Не минимизировать число зависимостей. Минимизировать friction. Каждая добавленная технология должна уменьшать суммарную сложность сильнее, чем увеличивает её.

Production serving: baseline

После build запущен npm run start и проведён synthetic benchmark:

ab -c 1000 -n 100000 http://localhost:3000/

Результат:

  • 100 000 запросов;
  • concurrency 1000;
  • 0 failed requests;
  • 11 515.51 req/s;
  • p50 27 ms;
  • p95 37 ms;
  • p99 67 ms;
  • max 8658 ms.

Типичная latency низкая, но присутствовали единичные большие outliers.

Docker + Traefik + Varnish

Затем тот же workload запущен через production-like цепочку:

client → Traefik → Varnish → app

Benchmark:

ab -c 1000 -n 100000 http://localhost:8088/

Результат:

  • 100 000 запросов;
  • concurrency 1000;
  • 0 failed requests;
  • 15 871.02 req/s;
  • p50 62 ms;
  • p95 74 ms;
  • p99 88 ms;
  • max 129 ms.

По сравнению с прямым app-server throughput вырос примерно на 38%, а распределение latency стало существенно более стабильным: исчез многосекундный tail.

Поведение origin

docker stats показал, что во время 100k запросов app практически не участвовал в обработке повторных запросов.

До теста app имел примерно:

NET I/O 9.56kB / 126B
MEM 25.73 MiB

После:

NET I/O 10.9kB / 3.68kB
MEM 26.16 MiB

При этом Traefik обработал порядка 370MB / 394MB, Varnish — 41MB / 328MB.

Это подтверждает полезную архитектурную границу: cacheable delivery-нагрузка заканчивается на Varnish и почти не доходит до application layer.

Почему Varnish нужен проекту

Критически важное требование: сайт использует динамический resize/processing изображений через Node + Sharp.

Без cache повторный запрос одного варианта изображения потенциально повторяет дорогой цикл:

request
→ Node
→ Sharp
→ decode
→ resize
→ encode
→ response

С Varnish вычисление выполняется на cache miss, после чего одинаковый вариант может обслуживаться из cache без повторной работы Sharp:

request
→ Varnish
   ├─ HIT → response
   └─ MISS → Node → Sharp → result → cache → response

Поэтому Varnish оправдан не просто увеличением RPS статического HTML, а изоляцией application layer и кешированием результатов дорогих повторяемых вычислений.

Архитектурный вывод

Текущий стек следует оценивать не как список технологий:

React + Vite + Node + Sharp + Varnish + Traefik + Docker

а как набор решений конкретных требований:

  • Vite → быстрый development feedback loop;
  • React → композиция и локальность изменений UI;
  • Node + Sharp → on-demand подготовка изображений нужного размера/формата;
  • Varnish → не повторять дорогую обработку и не пропускать cacheable нагрузку до origin;
  • Traefik → routing/deployment boundary;
  • Docker → воспроизводимый runtime/deployment.

Хороший критерий для каждого архитектурного компонента:

Какую измеримую стоимость этот компонент уменьшает и превышает ли выигрыш стоимость самого компонента?

Это уточнение нужно использовать при разработке showcase и best practices HAIH: не навязывать минимальное количество технологий, а показывать минимально достаточную суммарную стоимость решения с evidence.

Следующая полезная проверка

Для чистого сравнения cache layer имеет смысл позже получить третий datapoint:

A. app
B. Traefik → app
C. Traefik → Varnish → app

с одинаковым workload и отдельной метрикой количества запросов, реально дошедших до origin.

Маркетинговую статью по этим выводам в рамках данного worklog не публикуем — оформить отдельно позже.

2026-09-27 — Проксирование трафика в DinD через Traefik

Проверена и доведена до рабочего состояния маршрутизация к дочернему проекту, запущенному через Docker Compose внутри DinD.

Результат

Рабочая цепочка: внешний Traefik → DinD:2015 → socat → внутренний Traefik:80 → app:3000. Проект nextjs-test успешно доступен по http://nextjs-test.localhost:2015/.

В Dockerfile дочернего проекта добавлены socat и bash, а внешний Traefik настроен на порт 2015 DinD-контейнера. socat после запуска Compose пробрасывает этот порт на внутренний Traefik.

Управление Docker-сетями

Первоначально подключение DinD к docker_default было добавлено непосредственно в buildAndRunProject, но принято решение не оставлять этот хардкод. Сеть должна управляться явно через API.

Добавлены:

  • тип DockerContainerNetwork (name, networkID, ipAddress, gateway, macAddress);
  • ленивое поле DockerContainer.networks;
  • helper mapContainerNetworks;
  • query containerNetworks;
  • mutation connectContainerToNetwork;
  • mutation disconnectContainerFromNetwork.

Теперь контейнер можно программно подключать к сети внешнего Traefik и отключать от неё без Docker CLI.

Выявленный нюанс

Если контейнер подключить к сети уже после появления маршрута, внешний Traefik может продолжить использовать старый endpoint. В проведённом тесте понадобился restart Traefik. Это следует учесть при дальнейшем lifecycle запуска и маршрутизации проектов.

Сайт уже переписан на новых технологиях. Вот так он выглядел раньше:

Так он выглядит теперь.

Внешний аудит подтверждает текущий приоритет SEO/GEO: коммерческие страницы должны стать центром структуры и навигации, а информационный legacy-контент — связываться с конкретными бизнес-задачами, услугами, кейсами и обращением. Массово удалять wisdom/теги/новости ради «чистоты» не следует до оценки трафика и внешних ссылок. Основные KPI нового сайта предлагается связывать с квалифицированными обращениями, встречами и договорами; глубину просмотра использовать как вспомогательную метрику. Основной SEO-контент должен оставаться доступным на странице без открытия ИИ-чата.

Аудит существующего ИИ-виджета показал слишком общий вход «Спросите что угодно». Рекомендуется превратить агента в инструмент первичного разбора бизнес-задачи: дать понятный вопрос о препятствии развитию, быстрые сценарии (продажи, ручное управление, сайт, применение ИИ, комплексный разбор), а на выходе формировать структурированное описание ситуации, предварительные гипотезы с оговорками, релевантный кейс/направление и возможность передать резюме специалисту. Не требовать телефон до первой пользы; перед передачей контактов/переписки получать понятное согласие; сохранять обычную форму и прямой контакт с человеком. Контекст входа должен зависеть от страницы.

Получен аудит главной и ключевых входов. Главная проблема первого экрана: часы/метафора бизнеса не объясняют конкретное предложение и следующий шаг; при ширине 390 px предложение фактически не видно. Рекомендуемый смысл: помощь собственникам в поиске причин потерь в продажах, процессах и управлении с внедрением решений от изменений работы команды до веб-систем и ИИ-агентов. Главный CTA — разбор бизнес-задачи, альтернативный — обсуждение с экспертом. Предлагаемая последовательность главной: предложение → типовые проблемы → 2–3 кейса → подход диагностика/план/внедрение/измерение → 4 направления → веб/ИИ-инструменты → руководитель/команда → форматы сотрудничества → финальный CTA.

Аудит выявил несогласованные публичные контактные данные: в разных местах сайта одновременно указаны разные адреса и телефоны. Нужно определить актуальный набор реквизитов и привести контакты, шаблоны и доверительные блоки к единому варианту. Для блока эксперта предлагается показывать полное имя, фото, специализацию, образование/MBA и личную роль в проектах; квалификацию подкреплять практическими кейсами.

По аудиту sitemap сейчас не отражает коммерческие приоритеты: в просмотренных картах 726 адресов, 631 (~87%) относятся к /wisdom/, при этом основные коммерческие разделы /services, /experiences, /contacts, /price, /employees, /about_us в просмотренных XML-картах отсутствуют. Также обнаружены слабые/несоответствующие Title, отсутствие H1 на /services и нескольких услугах, отсутствие Description на стоимости, контактах и сотрудниках. В SEO-слое нужно добавить канонические коммерческие URL в sitemap, подготовить уникальные Title/Description/H1 по типам страниц, обновить internal links/canonical и сохранить ценные legacy URL.

Получен внешний аудит текущего сайта. Подтверждены пять старых URL из текста главной, которые возвращают 404: /pages/management, /pages/marketing, /pages/anticrisis, /pages/wholesale, /pages/business-processes. Предполагаемые соответствия: управленческий консалтинг, управление маркетингом, антикризисное управление, управление продажами, управление бизнес-процессами. Перед редиректами нужно сверить смысл старых материалов. Для безопасного переноса нужен реестр старый URL → новый/сохранение → статус → трафик → внешние ссылки → решение; не сводить старые URL массово на главную, исключить цепочки/циклы, значимые редиректы сохранять долгосрочно.

Полностью переписан портал «Городские бани» на haih-agent

В рамках задачи сайт был не просто перенесён на другой движок, а по сути полностью переписан с нуля на архитектуре haih-agent / haih CMS.

Репозиторий движка: https://github.com/haih-net/agent/

Старая версия портала: https://old.gorodskie-bani.ru/

Новая версия: https://gorodskie-bani.ru/

Главная цель переработки — отказаться от тяжёлой и фрагментированной архитектуры старого Next.js-проекта и перевести портал на значительно более простую, унифицированную и AI-first модель, где контент, структура страниц и дальнейшее развитие сайта максимально удобны для работы через ИИ.

Что было сделано

Фактически выполнены четыре крупных блока работ:

  • полностью собрана новая публичная версия сайта на haih-agent;
  • переработана архитектура хранения контента;
  • вся историческая база перенесена из MySQL в PostgreSQL;
  • сайт переведён на модель, в которой значительная часть управления и развития может выполняться ИИ-агентом через API и унифицированные контентные сущности.

Поэтому корректнее рассматривать этот этап не как обычный «редизайн» или «смену CMS», а как полную архитектурную переработку продукта.

Миграция MySQL → PostgreSQL

Старая версия сайта хранила данные в MySQL и использовала большое количество отдельных сущностей и таблиц под разные типы контента.

Для перехода на новую систему были написаны отдельные импортёры, которые:

  • читали данные из старой MySQL-базы;
  • преобразовывали старые структуры в новую унифицированную модель;
  • переносили данные в PostgreSQL;
  • сохраняли связи, исторический контент и существующие URL там, где это было необходимо;
  • адаптировали старый контент под новую систему файлов, изображений и страниц.

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

Вместо множества сущностей — один Concept

Одно из ключевых архитектурных изменений — отказ от большого количества специализированных сущностей.

В старом проекте отдельно существовали, например:

  • города;
  • бани и сауны;
  • отзывы;
  • комментарии;
  • статьи;
  • другие типы контента.

Для каждого такого типа обычно требовались собственные модели, запросы, шаблоны, логика отображения, API и административные сценарии.

В новой архитектуре большая часть контента приведена к одной универсальной сущности — Concept.

Concept содержит всего несколько базовых полей:

  • Название;
  • SEO-описание;
  • Интро для списков;
  • Контент;
  • Картинка;
  • Список файлов для галереи;
  • Координаты для карты.

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

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

Почему унификация сильно упрощает кодовую базу

Такой подход заметно сокращает объём прикладного кода.

Раньше каждый новый тип контента потенциально требовал:

  • новой модели данных;
  • отдельных CRUD-операций;
  • отдельных API-контрактов;
  • отдельной административной формы;
  • отдельных шаблонов или React-компонентов;
  • дополнительной логики выборки и отображения.

После перехода на Concept большая часть этой инфраструктуры становится общей.

Это уменьшает:

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

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

В результате архитектура становится проще как для сервера, так и для разработчика.

Меньше нагрузки на шаблонизацию

Унификация контента влияет не только на структуру базы, но и на рендеринг.

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

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

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

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

Главное изменение — сайт стал AI-first

Самое важное в этой переработке — не новый дизайн и даже не сама смена стека.

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

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

Для агента больше не требуется отдельно понимать:

  • как устроена таблица города;
  • чем API статьи отличается от API бани;
  • где лежат поля отзыва;
  • каким отдельным методом обновляется конкретный тип материала;
  • какая административная форма отвечает за тот или иной объект.

Большая часть контента обрабатывается одинаково.

Это означает, что агент может использовать одни и те же операции для:

  • создания материалов;
  • обновления текстов;
  • изменения SEO-описаний;
  • добавления изображений;
  • редактирования интро;
  • работы с координатами;
  • подготовки галерей;
  • перестройки страниц;
  • массового обновления контента;
  • поиска и анализа информации внутри сайта.

Полное управление контентом через ИИ

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

Контент доступен через API, а ИИ-агент может выполнять изменения по задаче человека.

Фактически рабочий процесс теперь выглядит так:

человек формулирует задачу → ИИ анализирует сайт и данные → выполняет изменения через API → человек проверяет результат.

Это принципиально отличается от традиционной CMS, где человек вынужден вручную проходить по административным формам и редактировать каждую сущность отдельно.

В результате ИИ становится не дополнительным «чатиком» поверх сайта, а полноценным интерфейсом управления и развития проекта.

ИИ-помощник для конечных пользователей

Та же унификация контента полезна и для агента, который общается с посетителями портала.

Поскольку города, заведения, публикации и другие материалы представлены в согласованном формате, агенту проще:

  • искать нужную информацию;
  • понимать связи между объектами;
  • находить заведения в конкретном городе;
  • использовать координаты и географические данные;
  • работать с описаниями и публикациями;
  • формировать ответы на основе существующего контента.

То есть новая архитектура одновременно улучшает две стороны работы с ИИ:

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

Обновление интерфейса и публичной части

Параллельно с архитектурной миграцией полностью обновлена публичная часть портала.

В новой версии реализованы:

  • обновлённая главная страница;
  • новая навигация и типографика;
  • каталог заведений;
  • индивидуальные страницы бань и саун;
  • фотогалереи;
  • пагинация;
  • справочник городов;
  • алфавитная навигация и поиск;
  • городские страницы с подборками заведений;
  • карта с маркерами и группировкой объектов;
  • публикации по историческим адресам;
  • интерфейс обращения к ИИ-помощнику.

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

SEO и сохранение исторического контента

При миграции важно было не потерять поисковую ценность существующего сайта.

Поэтому при переносе учитывались:

  • исторические URL;
  • старые публикации;
  • страницы заведений;
  • страницы городов;
  • внутренняя перелинковка;
  • SEO-описания;
  • существующий контент.

Сохранение старых маршрутов там, где это возможно, уменьшает риск массового выпадения проиндексированных страниц после смены движка.

При этом сам переход на haih-agent не означает автоматического роста поисковых позиций. SEO-эффект новой архитектуры заключается прежде всего в том, что сайт становится проще поддерживать, быстрее изменять, легче масштабировать по контенту и удобнее системно улучшать с помощью ИИ.

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

Почему это важно для дальнейшего развития

Главный результат миграции — не только уменьшение технического долга.

Портал теперь находится в архитектуре, которая позволяет гораздо быстрее выполнять будущие задачи.

Новый рабочий цикл выглядит так:

развернуть движок → настроить тему и компоненты → собрать страницы → загрузить или создать контент → дальше развивать сайт через ИИ и API.

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

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

Срок выполнения

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

При этом значительная часть времени ушла именно на совместимость со старым сайтом и импорт существующей базы.

Новый проект без необходимости переносить legacy-структуру на уже подготовленном движке мог бы быть собран существенно быстрее.

Итог

В рамках задачи сайт «Городские бани» был фактически создан заново на haih-agent.

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

Главное достижение этой работы — переход к AI-driven архитектуре сайта.

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

В результате сайт стал проще по архитектуре, дешевле в дальнейшем развитии и значительно лучше приспособлен к разработке, наполнению и сопровождению с помощью искусственного интеллекта.

haih CMS / haih-agent в этом проекте используется не просто как новый движок, а как основа для сайта, где ИИ является полноценным участником всего жизненного цикла — от разработки до управления контентом и общения с конечным пользователем.

Настроено кеширование статических ресурсов через Varnish

Для сайта gorodskie-bani.ru настроено проксирование статических ресурсов через Varnish с TTL 7 дней. Через кеш проходят JS, CSS, шрифты, изображения, иконки и ресурсы /styles/ от tileserver. Динамические страницы намеренно не кешируются и продолжают обрабатываться приложением напрямую (return (pass)).

Зачем это нужно

Основная задача кеширования — отдавать неизменяемые и редко меняющиеся ресурсы не из Node.js-приложения и tileserver при каждом запросе, а непосредственно из памяти/кеша Varnish.

Это даёт сайту несколько практических преимуществ:

  • Снижается время отдачи статики. После первого запроса ресурс попадает в кеш, последующие запросы обслуживаются Varnish без обращения к backend.
  • Уменьшается нагрузка на приложение и tileserver. JS, CSS, изображения, шрифты и стили карты не создают повторную нагрузку на backend-контейнеры.
  • Повышается стабильность под нагрузкой. При одновременных заходах пользователей большая часть запросов к статике обслуживается на уровне кеширующего прокси.
  • Ускоряется загрузка страниц для пользователей. Быстрая отдача CSS, JS, шрифтов и изображений уменьшает ожидание ресурсов, необходимых для отрисовки страницы.
  • Создаётся положительный технический эффект для SEO. Скорость загрузки и пользовательские метрики производительности, в том числе связанные с Core Web Vitals, являются частью технического качества сайта. Varnish не меняет позиции в поиске напрямую, но помогает уменьшить сетевые задержки и нагрузку на origin, что создаёт более стабильные условия для быстрой загрузки и обхода сайта поисковыми роботами.

Конфигурация Varnish

Настроены два backend:

  • основной backend приложения — gorodskie-bani--agent-app-1:3000;
  • отдельный backend tileserver — gorodskie-bani-ru-tileserver-1:80.
vcl 4.1;

backend default {
    .host = "gorodskie-bani--agent-app-1";
    .port = "3000";
}

backend tileserver {
    .host = "gorodskie-bani-ru-tileserver-1";
    .port = "80";
}

В vcl_recv запросы к /styles/ отправляются в tileserver. Для кешируемых ресурсов Cookie удаляются, чтобы пользовательские cookies не дробили кеш и не мешали повторному использованию одного объекта разными запросами.

sub vcl_recv {
    if (req.url ~ "^/styles/") {
        set req.backend_hint = tileserver;
        unset req.http.Cookie;
        return (hash);
    }
    if (req.url ~ "\.(js|css|woff2?|ttf|eot|svg|ico|png|jpg|jpeg|gif|webp|avif)(\?.*)?$") {
        unset req.http.Cookie;
        return (hash);
    }
    return (pass);
}

Все остальные запросы идут через pass, то есть HTML и динамические ответы приложения этим правилом не кешируются. Это снижает риск отдачи устаревшего персонализированного или динамического контента.

Ключ кеша строится по URL и host:

sub vcl_hash {
    hash_data(req.url);
    hash_data(req.http.host);
    return (lookup);
}

Это позволяет раздельно хранить ресурсы разных URL и не смешивать кеш между хостами. Query string остаётся частью req.url, поэтому версии ассетов с cache-busting параметрами получают отдельные записи кеша.

Для /styles/ и статических файлов задан TTL 7 дней, а Set-Cookie удаляется из кешируемых backend-ответов:

sub vcl_backend_response {
    if (bereq.url ~ "^/styles/") {
        set beresp.ttl = 7d;
        unset beresp.http.Set-Cookie;
    }
    if (bereq.url ~ "\.(js|css|woff2?|ttf|eot|svg|ico|png|jpg|jpeg|gif|webp|avif)(\?.*)?$") {
        set beresp.ttl = 7d;
        unset beresp.http.Set-Cookie;
    }
}

TTL 7 дней позволяет долго обслуживать повторные запросы из Varnish, при этом стандартный подход с версионированными именами файлов или query-параметрами позволяет получать новую версию ресурса после деплоя без ожидания истечения старого кеша.

Для диагностики добавлены служебные заголовки X-Cache и X-Cache-TTL:

sub vcl_deliver {
    if (obj.hits > 0) {
        set resp.http.X-Cache = "HIT";
    } else {
        set resp.http.X-Cache = "MISS";
    }
    set resp.http.X-Cache-TTL = obj.ttl;
}

По ним можно быстро проверить фактическую работу кеша: первый запрос обычно отдаётся как MISS, последующие — как HIT, а X-Cache-TTL показывает оставшееся время жизни объекта.

Маршрутизация Traefik

Чтобы нужные запросы действительно попадали в Varnish, в Traefik добавлены отдельные роутеры с высоким приоритетом.

Статические файлы:

gorodskie-bani.ru-static:
  rule: 'Host(`gorodskie-bani.ru`) && PathRegexp(`^.*\.(js|css|woff2?|ttf|eot|svg|ico|png|jpg|jpeg|gif|webp|avif)$`)'
  entryPoints:
    - websec
  service: gorodskie-bani.ru-varnish
  tls:
    certResolver: letsencrypt
  priority: 200

Ресурсы /styles:

gorodskie-bani.ru-styles:
  rule: "Host(`gorodskie-bani.ru`) && PathPrefix(`/styles`)"
  entryPoints:
    - websec
  middlewares: []
  service: gorodskie-bani.ru-varnish
  tls:
    certResolver: letsencrypt
  priority: 200

Таким образом, Traefik отделяет кешируемые запросы на входе и передаёт их в сервис Varnish, после чего Varnish либо отдаёт объект из кеша, либо получает его с соответствующего backend и сохраняет на 7 дней.

Итог для сайта и SEO

В результате статическая часть gorodskie-bani.ru обслуживается через отдельный кеширующий слой. Это уменьшает количество обращений к приложению и tileserver, ускоряет повторную загрузку ресурсов и делает время ответа более стабильным при росте трафика.

Для SEO это полезно прежде всего как инфраструктурная оптимизация производительности: браузер быстрее получает критические CSS/JS/шрифты/изображения, backend меньше конкурирует за ресурсы со статическими запросами, а сайт устойчивее сохраняет нормальную скорость ответа под нагрузкой. В совокупности это помогает техническому качеству сайта и пользовательскому опыту, которые важны для поисковой видимости и эффективности органического трафика.

Диагностика аномального потребления памяти MySQL 5.7 в Docker и устранение причины

В процессе работы с Docker-окружением была обнаружена нетипичная проблема при старте контейнера mysql:5.7: перед нормальным запуском MySQL контейнер в течение нескольких минут резко увеличивал потребление памяти, доходя примерно до четверти доступной RAM хоста, затем освобождал память и повторял цикл. На машине с ~64 ГБ RAM всплески доходили примерно до 16 ГБ; в среде с ~2 ГБ RAM наблюдалось порядка 500 МБ. После нескольких таких циклов потребление стабилизировалось на нормальном уровне.

Исходная конфигурация MySQL была практически минимальной:

mysql:
  image: mysql:5.7
  environment:
    - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-your_mysql_password}

Отдельных настроек innodb_buffer_pool_size или memory limit не было.

Первичное наблюдение

По графику docker stats было видно характерную «пилу»: процесс быстро набирал гигабайты памяти, доходил до некоторого предела, затем память почти полностью освобождалась, после чего цикл повторялся. При этом в начале старта логов почти не было, что создавало впечатление, будто MySQL просто долго инициализируется без каких-либо сообщений.

После запуска с очищенным datadir удалось увидеть точную последовательность событий. До исправления лог выглядел так:

03:33:05 Entrypoint script for MySQL Server 5.7.44 started
        ... 37 секунд тишины ...
03:33:42 Switching to dedicated user 'mysql'
03:33:42 Entrypoint script for MySQL Server 5.7.44 started
        ... ещё 37 секунд тишины ...
03:34:19 Initializing database files

То есть практически всё аномальное время уходило не на инициализацию InnoDB и не на создание файлов базы, а на действия docker-entrypoint.sh, выполняемые до полноценного запуска сервера.

При этом сам MySQL явно сообщал:

InnoDB: Initializing buffer pool, total size = 128M

Это исключило гипотезу, что огромный расход памяти связан с InnoDB buffer pool. По умолчанию в данной конфигурации он составлял всего 128 МБ.

Причина

Проблема оказалась связана с известным поведением MySQL 5.7 при чрезмерно большом лимите открытых файлов (RLIMIT_NOFILE).

Официальный Docker entrypoint для MySQL перед реальным запуском сервера делает служебные вызовы mysqld, в частности в режиме вида:

mysqld --verbose --help

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

В MySQL 5.7 есть известная проблема: если RLIMIT_NOFILE аномально большой, служебный запуск mysqld --verbose --help может инициировать очень большую временную аллокацию памяти. В Docker такое проявляется особенно заметно, если контейнер наследует огромный nofile от Docker daemon / systemd.

Из-за этого происходила следующая последовательность:

docker-entrypoint.sh
    ↓
служебный запуск mysqld --verbose --help
    ↓
огромная временная аллокация из-за RLIMIT_NOFILE
    ↓
рост RAM и CPU в течение десятков секунд
    ↓
служебный процесс завершается
    ↓
память освобождается
    ↓
entrypoint выполняет следующий аналогичный вызов
    ↓
повторный всплеск

Это объяснило сразу несколько наблюдений:

  • большие всплески памяти происходили до нормального запуска MySQL;
  • память после каждого всплеска возвращалась системе;
  • было несколько одинаковых по длительности циклов;
  • в этот момент почти не было обычных серверных логов;
  • после завершения bootstrap-процедуры MySQL работал с нормальным потреблением памяти.

Решение

В Docker Compose был явно задан нормальный предел количества открытых файлов:

mysql:
  image: mysql:5.7
  environment:
    - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-your_mysql_password}

  ulimits:
    nofile:
      soft: 65536
      hard: 65536

Важно: этот параметр не является ограничением памяти контейнера. Он ограничивает только число файловых дескрипторов, которые процесс может одновременно открыть. В дескрипторы входят файлы, сокеты и соединения.

То есть nofile: 65536 не означает 64 МБ, 64 ГБ или какой-либо иной объём памяти и никак напрямую не задаёт memory limit контейнера.

Значение 65536 выбрано как нормальный и достаточно большой предел для обычного MySQL в веб-проекте. Оно оставляет большой запас по количеству файлов/сокетов, но не позволяет MySQL 5.7 попасть в проблемный сценарий с огромным RLIMIT_NOFILE.

Проверка результата на существующей базе

После добавления ulimits.nofile повторный запуск существующего datadir изменился радикально:

03:38:54 Entrypoint started
03:38:54 Switching to dedicated user 'mysql'
03:38:54 Entrypoint started
03:38:54 mysqld starting
03:38:54 mysqld: ready for connections

Обе прежние паузы примерно по 37 секунд исчезли полностью. Сервер стал готов к подключениям практически мгновенно.

Проверка на полностью чистом datadir

Для корректной проверки первичной инициализации данные MySQL были реально очищены с учётом того, что использовались bind mounts:

volumes:
  - ./mysql/data:/var/lib/mysql
  - ./mysql/conf.d:/etc/mysql/conf.d

После этого был выполнен полный cold start. Новый лог:

03:41:42 Entrypoint started
03:41:42 Switching to dedicated user 'mysql'
03:41:42 Entrypoint started
03:41:43 Initializing database files
03:41:45 Database files initialized
03:41:45 Starting temporary server
03:41:49 MySQL init process done. Ready for start up
03:41:49 mysqld: ready for connections

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

При этом InnoDB по-прежнему использовал штатный буфер:

InnoDB: Initializing buffer pool, total size = 128M

То есть исправление не уменьшало рабочую память MySQL искусственным лимитом — оно устранило именно ошибочную временную аллокацию на bootstrap-этапе.

Фактическое потребление памяти после исправления

После нормального запуска docker stats показывал:

173.4MiB / 61.5GiB

То есть контейнер реально использовал около 173 МБ RAM. Число 61.5GiB справа — это доступная контейнеру память хоста при отсутствии отдельного memory limit, а не резерв MySQL.

ulimits.nofile на это значение напрямую не влияет. Для ограничения RAM контейнера потребовался бы отдельный Docker memory limit (mem_limit, resources limits и т. п.), но в рамках данной проблемы это не требовалось.

Итог

Причина долгого старта и огромных всплесков памяти была не в реальном рабочем потреблении MySQL, не в InnoDB buffer pool и не в объёме данных. Проблему вызывал MySQL 5.7 на служебных запусках из Docker entrypoint при чрезмерно большом RLIMIT_NOFILE.

Исправление:

ulimits:
  nofile:
    soft: 65536
    hard: 65536

Результат:

  • исчезли повторные гигабайтные всплески RAM;
  • исчезли паузы по ~37 секунд на служебных вызовах entrypoint;
  • cold start пустой базы сократился примерно до 7 секунд;
  • рабочее потребление памяти стабилизировалось примерно на 170–180 МБ;
  • лимит памяти контейнера при этом не вводился и не изменялся;
  • nofile=65536 оставлен как постоянный workaround для mysql:5.7 в текущем Docker-окружении.

Доработка контактов в ИИ-чате: WhatsApp и MAX

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

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

Как выглядит блок связи

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

Контакты открыты

Если обращение к консультанту пока неактуально, блок можно свернуть. При этом доступ к контактам сохраняется и остаётся под рукой в интерфейсе чата.

Контакты закрыты

Что реализовано

  • добавлен компактный блок связи с сотрудниками магазина;
  • доступны быстрые переходы в WhatsApp и MAX;
  • блок появляется после начала диалога, когда пользователь уже вовлечён в поиск товара;
  • предложение можно свернуть, если связь с консультантом пока не нужна;
  • кнопка контактов остаётся доступной рядом с полем ввода;
  • оформление адаптировано под интерфейс чата: компактные иконки, читаемый текст и плавное раскрытие без перегрузки экрана.

Польза для потенциальных клиентов

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

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

поиск товара → консультация → уточнение наличия → подбор подходящего варианта → потенциальная покупка.

SEO и коммерческий эффект

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

Это помогает лучше отрабатывать запросы с высоким намерением к покупке — например, когда пользователь хочет купить товар, узнать наличие, подобрать аналог, уточнить цену или характеристики.

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

Пока что просто отключил его, так как много возникает пограничных случаев, когда из-за него поведение неудобное.

Перенос старого сайта на новый движок и унификация модели данных

Основная работа на этом этапе — не просто перенос отдельных страниц или ресурсов, а фактически пересборка старого сайта на новом движке haih-agent с окончательным отказом от старой структуры данных, которая исторически сложилась ещё во времена MODX.

Проблема здесь по сути такая же, как и при обновлении других старых сайтов: со временем вокруг первоначальной CMS накопилась собственная структура данных, множество шаблонов, TV-полей, специальных типов ресурсов и прикладной логики, завязанной на особенности старого движка. Поэтому обычного «обновления» приложения недостаточно — новый сайт нужно переносить архитектурно, сохраняя существующий контент, но избавляясь от старых технических ограничений.

Что было в старой версии

Изначально сайт работал на MODX, и значительная часть структуры базы данных до сих пор наследовала эту модель. Разные смысловые сущности существовали отдельно друг от друга и зачастую имели собственные шаблоны и наборы TV-полей.

В частности, отдельно существовали:

  • города;
  • компании;
  • бани и сауны;
  • отзывы;
  • статьи и публикации;
  • обычные ресурсы/страницы;
  • блоги и другие типы контента.

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

Переход на KbConcept

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

Ключевое архитектурное изменение состоит в том, что практически весь контент теперь приводится к одной базовой сущности — KbConcept. Семантическое различие между объектами определяется не отдельной таблицей или отдельным классом модели, а полем type.

Например:

  • city:default — город;
  • company:default — компания;
  • resource:default — обычная веб-страница;
  • review:company — отзыв о компании;
  • blog:default — публичный блог;
  • blog:personal — персональный блог;
  • topic:default — публикация.

Таким образом, вместо большого набора исторически разросшихся сущностей получается единая модель данных с понятной системой типов. Это заметно упрощает GraphQL-схему, фронтенд, повторное использование компонентов, выборки, импорт данных и дальнейшее развитие сайта.

При этом унификация не означает потери типизации. Наоборот, TypeScript позволяет поверх общего KbConcept достаточно строго описать конкретные подтипы и безопасно работать с ними в прикладном коде.

Типизация KbConcept через template literal types

Особенно удачно здесь пригодилась возможность TypeScript использовать шаблонные строковые литералы в типах (template literal types). Сейчас код типов выглядит так:

import { EnumValueConfigMap, SchemaTypes } from '@pothos/core'
import { KbConceptFragment } from 'src/gql/generated'

export const CustomKbConceptType = {
  City: {
    value: 'city:default',
    description: 'Город',
  },
  Company: {
    value: 'company:default',
    description: 'Компания',
  },
  ResourceDefault: {
    value: 'resource:default',
    description: 'Веб-страница',
  },
  ReviewCompany: {
    value: 'review:company',
    description: 'Отзыв о компании',
  },
  BlogDefault: {
    value: 'blog:default',
    description: 'Публичный блог',
  },
  BlogPersonal: {
    value: 'blog:personal',
    description: 'Персональный блог',
  },
  TopicDefault: {
    value: 'topic:default',
    description: 'Публикация',
  },
} as const satisfies EnumValueConfigMap<SchemaTypes>

export type MapItemCompany = KbConceptFragment & {
  type: `company:${string}`
  lat: number
  lng: number
}

export function isMapItemCompany(
  concept: KbConceptFragment,
): concept is MapItemCompany {
  return concept.type?.startsWith('company:') && concept.lat && concept.lng
    ? true
    : false
}

export type Company = KbConceptFragment & {
  type: `company:${string}`
}

export function isCompany(concept: KbConceptFragment): concept is Company {
  return concept.type?.startsWith('company:') ? true : false
}

export type City = KbConceptFragment & {
  type: `city:${string}`
}

export function isCity(concept: KbConceptFragment): concept is City {
  return concept.type?.startsWith('city:') ? true : false
}

export type ReviewCompany = KbConceptFragment & {
  type: typeof CustomKbConceptType.ReviewCompany.value
}

export function isReviewCompany(
  concept: KbConceptFragment,
): concept is ReviewCompany {
  return concept.type === CustomKbConceptType.ReviewCompany.value
}

Здесь есть несколько особенно полезных моментов.

as const satisfies ...

Конструкция:

} as const satisfies EnumValueConfigMap<SchemaTypes>

решает сразу две задачи.

as const не даёт TypeScript расширить значения вроде 'city:default' до общего типа string. В результате конкретные строки сохраняются как литеральные типы. Например, CustomKbConceptType.ReviewCompany.value имеет тип именно 'review:company', а не просто string.

При этом satisfies EnumValueConfigMap<SchemaTypes> проверяет, что весь объект соответствует контракту, ожидаемому Pothos, но не уничтожает точную информацию о литеральных значениях внутри объекта. Получается удобное сочетание строгой проверки структуры и максимально точного вывода типов.

Шаблонные литералы в типах

Самая интересная часть:

type: `company:${string}`

и аналогично:

type: `city:${string}`

Это позволяет выразить на уровне системы типов сам принцип устройства KbConcept.type: объект считается компанией не только при одном конкретном значении company:default, а при любом типе из пространства company:*.

Например, если в дальнейшем появятся company:premium, company:branch или другие специализированные варианты, тип Company уже сможет описывать их без создания отдельного union вручную.

То есть соглашение об именовании типов вида:

<группа>:<подтип>

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

Type guards

Функции вида:

export function isCompany(concept: KbConceptFragment): concept is Company

являются пользовательскими type guard'ами. После проверки isCompany(concept) TypeScript уже знает, что внутри соответствующей ветки concept.type имеет форму company:${string}.

То же самое используется для городов и отзывов.

Отдельно полезен isMapItemCompany: он не только проверяет префикс company:, но и сужает объект до типа, в котором гарантированно доступны координаты lat и lng как числа. Благодаря этому код карты дальше работает не с «возможно компанией с возможно координатами», а уже с нормальным типизированным объектом карты.

Для точных специализированных вариантов можно использовать ещё более строгую проверку:

type: typeof CustomKbConceptType.ReviewCompany.value

Здесь тип ReviewCompany привязан непосредственно к значению из центрального объекта CustomKbConceptType. Если строковое значение типа будет изменено там, тип не придётся дублировать вручную в нескольких местах.

В итоге общая сущность KbConcept не превращает приложение в набор нетипизированных объектов. Наоборот, за счёт соглашения о type, template literal types и type guards получается сохранить удобство единой модели данных и одновременно получить строгую типизацию конкретных сценариев на фронтенде.

Импорт старых данных

На текущий момент импортёры старых сущностей уже написаны и работают. Импортированы основные данные, в том числе ресурсы, компании и связанные типы контента.

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

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

Карта

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

Для карты как раз используется специализированный тип MapItemCompany, чтобы после фильтрации на уровне TypeScript были гарантированы и принадлежность концепта к company:*, и наличие координат.

Текущий результат

На данный момент:

  • новая архитектура сайта уже строится вокруг KbConcept;
  • основные старые сущности больше не требуют отдельных моделей в новом приложении;
  • написаны и работают импортёры данных из старой базы;
  • ресурсы, компании и другие основные сущности импортированы;
  • типы контента приведены к единой схеме <группа>:<подтип>;
  • на фронтенде добавлены type guards и строгая типизация конкретных разновидностей KbConcept;
  • перенесена карта;
  • восстановлена кластеризация объектов на карте.

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

Пока сделал только админскую страницу со списком правил и возможностью создавать правала.

Первичный рабочий результат

Реализован GraphQL-резолвер для локального вьетнамского TTS. Также сделана админская страница, которая даёт общую картину по буквам, сочетаниям, слогам и их значениям/озвучке.

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

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

Найден рабочий локальный вариант TTS

Локально в Docker поднят VieNeu-TTS, сейчас используется версия 2.

Главный практический результат: модель стабильно озвучивает одиночные вьетнамские графемы, включая формы с тоновыми знаками. Это закрывает ключевую проблему, из-за которой OpenRouter/Gemini не подходил для букваря: минимальные входы вроде одной буквы там давали HTTP 400 или нестабильный результат.

Проверен набор из 18 форм группы a / ă / â с шестью тонами (a á à ả ã ạ, ă ắ ằ ẳ ẵ ặ, â ấ ầ ẩ ẫ ậ). VieNeu-TTS умеет адресно генерировать такие единицы, поэтому его можно использовать как генератор фонетического словаря: графема/слог → сохранённый аудиофайл.

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

Связанная родительская задача: «Разработать собственный пайплайн озвучки вьетнамского языка и словарь аудио».

Полный промежуточный итог по TTS и локальной озвучке вьетнамского

Работа относится к задаче «Разработать собственный пайплайн озвучки вьетнамского языка и словарь аудио».

Исходная проблема

Изначально рассматривались browser speechSynthesis и затем генерация через OpenRouter + Google Gemini.

Browser speechSynthesis для вьетнамского оказался непригоден как базовый механизм курса: установка голоса непрозрачна, зависит от конкретного браузера/поставщика, может зависать на Downloading voices ..., а требовать от пользователя вручную устанавливать языковые голоса нельзя.

Далее был реализован TTS-пайплайн через OpenRouter. В целом он работает для обычного текста, но на минимальных входах обнаружилось критичное ограничение: при попытке озвучить одну отдельную букву OpenRouter/Gemini возвращает HTTP 400 или не даёт пригодного результата. Для обычного TTS это может быть допустимо, но для букваря — блокирующая проблема, потому что нам нужны отдельные буквы, графемы, короткие слоги и минимальные фонетические единицы.

Из-за этого была вынесена отдельная техническая ветка: «Исследовать и реализовать локальную TTS-озвучку через локальный сервер».


Переход к локальной генерации

Локально в Docker поднят проект VieNeu-TTS.

Пока используется версия 2, с которой уже были предыдущие эксперименты; существует и более новая версия 3.

Ключевой практический результат: VieNeu-TTS стабильно и чётко умеет озвучивать отдельные вьетнамские буквы/графемы. Это принципиально отличает его от текущего Gemini-пайплайна.

Почему это важно именно для букваря

Для вьетнамского нам нужен не просто универсальный «читатель текста», а генератор минимальных учебных единиц:

  • отдельные гласные;
  • отдельные графемы с тонами;
  • короткие слоги;
  • дифтонги/трифтонги;
  • минимальные контрасты;
  • отдельные слова;
  • затем короткие фразы.

То есть TTS должен поддерживать адресный запрос вида: «озвучь именно эту графему/слог», а не требовать контекста предложения.

На этом критерии VieNeu-TTS уже показывает существенное преимущество.

Теоретическая база по тому, какие именно единицы нужно уметь озвучивать, зафиксирована в концептах:

Пример: 18 форм группы A

Для базовой латинской формы a во вьетнамском есть три разные гласные:

  • a;
  • ă;
  • â.

Каждая из них может нести один из шести тонов, что даёт 18 письменных форм:

  • a á à ả ã ạ;
  • ă ắ ằ ẳ ẵ ặ;
  • â ấ ầ ẩ ẫ ậ.

VieNeu-TTS способен отдельно озвучивать такие минимальные единицы.

Для курса это означает естественную структуру:

графема → сохранённая аудиозапись.

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

Акустическая ценность синтетического набора

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

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

  1. качество гласного: a / ă / â;
  2. тон: ngang / sắc / huyền / hỏi / ngã / nặng.

Теоретически это позволяет анализировать признаки раздельно:

  • F1/F2 и длительность → какой гласный;
  • F0-контур, длительность, энергия, voicing и признаки фонации → какой тон;
  • затем объединять результат в конкретную графему.

Это может быть полезно не только для генерации, но и как основа синтетического ground truth для будущих экспериментов «звук → фонетические признаки/графема».

Почему VieNeu-TTS выглядит перспективно

Проект специализирован именно на вьетнамском языке, разработан во Вьетнаме и обучался на большом объёме вьетнамской речи. По имеющейся информации, версия 2 использовала более 10 000 часов вьетнамских записей.

Это не отменяет валидацию, но делает модель гораздо более релевантной для нашей задачи, чем универсальный мультиязычный TTS.

Валидация качества

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

Проверять нужно прежде всего:

  • различимость тонов;
  • корректность hỏi/ngã;
  • краткость ă/â;
  • естественность отдельных букв/слогов вне контекста;
  • региональный акцент;
  • отсутствие артефактов на очень коротких входах.

Таким образом, архитектура доверия сейчас видится так:

VieNeu-TTS = основной локальный генератор кандидатов

носители языка = валидаторы качества

наша база = хранит подтверждённые/принятые аудиозаписи

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

Архитектура хранения

Озвучку не нужно генерировать каждый раз.

Для каждой учебной единицы нужно хранить:

  • текст/графему/слог;
  • тип единицы;
  • аудиофайл;
  • движок и версию модели;
  • параметры генерации;
  • вариант произношения/диалект при необходимости;
  • статус проверки;
  • возможность перегенерации;
  • возможность заменить синтетическую запись живой записью носителя.

Статусы разумно оставить в духе:

  • generated;
  • needs_review;
  • approved;
  • rejected.

Текущий практический вывод

Для Vietnamguru локальный VieNeu-TTS сейчас выглядит намного более подходящим базовым механизмом для букваря, чем Google Gemini через OpenRouter, потому что он стабильно работает на минимальных входах и умеет озвучивать отдельные буквы/графемы.

Даже если позднее другой движок окажется качественнее на длинной речи, возможность надёжно синтезировать минимальные единицы — отдельный критичный критерий для нашего проекта.

Вся методическая рамка проекта собрана в концепте «Вьетнамский язык: система и методика изучения», а практическая разработка курса ведётся в задаче «Разработать систему изучения вьетнамского языка для взрослой аудитории».

Проблема с озвучкой одиночных букв через OpenRouter

Базовая механика TTS через OpenRouter в целом реализована и работает для обычных текстовых единиц. Однако обнаружена техническая проблема на минимальных входах: если отправлять на озвучку только одну букву, OpenRouter возвращает HTTP 400.

Для vietnamguru.ru это критично, поскольку букварю нужна отдельная озвучка букв, звуков и других очень коротких фонетических единиц.

Текущее решение

Не блокировать весь TTS-пайплайн из-за этого ограничения, а отдельно исследовать локальную генерацию через собственный локальный сервер и локальную TTS-модель. Цель — получить механизм, который стабильно принимает одиночные буквы/слоги и сохраняет результат в уже предусмотренный словарь аудио.

Связанная задача

Создана дочерняя задача «Исследовать и реализовать локальную TTS-озвучку через локальный сервер», taskId cmu90kv7v0wm0qw0qnov80p68.

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

Page 1 of 13