27.08.2026 Технический разбор RU

Tea Taste: дегустация голосом — расшифровка речи и словарь, который её удерживает

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

Никита Конкин

Проект Репозиторий (фронтенд)
Языковые версии: RU EN

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

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

Путь звука

Запись уходит на распознавание в Yandex SpeechKit — асинхронный режим третьей версии:

const STT_URL = 'https://stt.api.cloud.yandex.net/stt/v3';
const OPERATION_URL = 'https://operation.api.cloud.yandex.net/operations';

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

Версия модели закреплена явно, и комментарий в коде объясняет, почему именно так:

// Pinned rather than left to the default. Measured on the same recording:
// unset, `deferred-general` and `deferred-general:rc` returned byte-identical
// results, so this is not a quality lever — it is a guard against the
// default moving under us later.
model: 'deferred-general',

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

Две половины расшифровки

Самое поучительное в обновлении — исправление точности, и оно не про качество распознавания.

SpeechKit умеет приводить сказанное к читаемому виду: убирать слова-паразиты, восстанавливать пунктуацию. Эта обработка (literatureText) включена намеренно — именно её результат показывается пользователю. Ошибка была в том, как из ответа собирался текст: предполагалось, что если уточнённые фрагменты пришли, то они заменяют исходные целиком.

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

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

// Kept ON deliberately, and it is what produces the readable transcript
// the user is shown. Do NOT turn it off to "fix" the numbers: it is also
// what makes SpeechKit send the refinement alongside the raw final, and
// extractRawTranscript below already keeps the unrewritten text for the
// field extraction.

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

От текста к полям

Расшифровку в поля превращает YandexGPT со структурированным выводом по JSON-схеме. Существенно здесь не обращение к модели, а то, чем оно ограничено.

Словари ароматов и вкусов живут в базе (AromaDB, TasteDB) в виде дерева «категория → подкатегория → дескриптор». Перед вызовом дерево разворачивается в текст и передаётся в системную подсказку, а ответ модели проверяется обратно по нему: validPaths собирает множество допустимых путей, keepKnown отбрасывает всё, чего в нём нет.

Смысл конструкции в том, что модель не может расширить словарь. Придуманный дескриптор не попадёт в структурированное поле — он не пройдёт сверку. При этом сказанное не выбрасывается: то, что не удалось выразить путём в дереве, переносится в свободное описание (withUnmatched). Пользователь видит и распознанное, и то, что распознать не удалось.

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

Лучше честно ничего не заполнить, чем выдумать.

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

Квота как защита от чужого счёта

Распознавание оплачивается владельцем аккаунта SpeechKit, а не тем, кто записал дорожку. Комментарий в voiceQuota.js формулирует это прямо:

// Every second of audio sent for recognition is billed to whoever owns the
// SpeechKit account — not to the person who recorded it. Without a ceiling, one
// user with a four-hour file spends real money, and nothing about the content
// tells you that in advance.

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

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

Фотографии и порядок, который нельзя нарушать

Дегустация получила фотографии — классическую триаду: сухой лист, настой, разварка. Порядок задан константой и, что важнее, объяснён:

// Mirrors PHOTO_SLOTS / orderPhotos in tea-taste-frontend/src/components/TeaPhotos.jsx:
// the sitemap, the preview image and the crawler HTML all have to pick the same
// first photo the card does, or a shared link shows a picture that is not on
// the page.
const PHOTO_KINDS = ['dry', 'liquor', 'wet'];

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

Страницы для роботов

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

// Server-rendered HTML for crawlers. nginx maps known bot user-agents onto
// /render<path>; a browser never reaches these routes.

Маршруты монтируются по одному разу на каждый язык, а язык страницы читается из req.originalUrl, а не передаётся параметром — чтобы единственным источником истины оставался URL, как и в приложении. Метаданные на стороне приложения при этом переехали с react-helmet на собственный компонент PageMeta.

Язык интерфейса

Локализация решена посредником, выбирающим язык ответа. Порядок предпочтений неочевиден и потому прокомментирован:

// `X-Locale` first, because the app knows something the browser does not: which
// language the page is actually being read at. A reader on a Russian-configured
// browser looking at /en/blog wants the English message, and Accept-Language
// would give them Russian.

Заголовок считается недоверенным вводом: засчитывается только точное совпадение с известным списком языков, не сырая строка. Ответ помечается Vary по обоим заголовкам — иначе общий кэш отдал бы английское сообщение следующему русскому читателю.

Сухой лист без отдельной коллекции

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

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

Модерация и приём новых пользователей

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

Выводы

  1. Голосовой ввод полезен ровно настолько, насколько ограничен его выход. Сопоставимость дегустаций сохраняет не модель, а словарь, по которому её ответ сверяется.
  2. Литературная обработка речи и извлечение фактов из неё — разные задачи; текст для чтения и текст для разбора стоит хранить раздельно.
  3. Внешний платный сервис требует лимита, взятого до вызова: отказ после ответа уже оплачен.
  4. Компромисс, названный и объяснённый в комментарии, дешевле в сопровождении, чем тихо правильное решение.
  5. Согласованность порядка данных между независимыми потребителями проявляется позже всего — и потому фиксируется явно.

Доступность

Сервис открыт: teaform.ru. Голосовая заметка доступна в форме дегустации; исходный код фронтенда и API — на GitHub.

Теги

программирование react nodejs express mongodb продукт

Другие обновления по проекту

23.07.2026 Технический разбор Tea Taste — цифровой журнал дегустации чая

Tea Taste: восемь скрытых отказов бэкенда, которые нашло код-ревью

Разбор восьми стабильностных дефектов в API Tea Taste, найденных при код-ревью после июльского релиза: двойной HTTP-сервер, ошибки, которые молча проваливались мимо обработчиков...

21.07.2026 Технический разбор Tea Taste — цифровой журнал дегустации чая

Tea Taste: вход через VK, публичная лента и админ-панель — большое обновление

Разбор крупного обновления Tea Taste (teaform.ru): авторизация через VK ID, редактирование дегустаций, восстановление пароля, публичная лента с профилями авторов, обратная связь...