Как я сделал бота, который сам приходит на встречи в Teams / Яндекс Телемост / МТС Линк, записывает разговор и через несколько минут отдаёт расшифровку и саммари с договорённостями. Почему не Copilot и не штатная расшифровка, почему два разных потока вместо одного, какие грабли собрал и что показали четыре месяца в проде.
TL;DR
-
Так как в компании мы используем MS Teams пришлось строить два потока. Канальные встречи в Teams командах (дейли, планирования, ретро — т.е. в основном регулярные) обрабатывает бот, установленный в команду. Он ловит системное событие «запись готова», забирает видео из SharePoint и публикует саммари в тред встречи. Все остальные встречи (частные, клиентские, чужой тенант, Телемост, МТС) обслуживает «Ассистент Встреч»: его зовут в приглашение как обычного участника, и к началу встречи туда заходит анонимный Chrome в Docker-контейнере.
-
Распознавание, диаризацию и саммари делает внутренний сервис на своих серверах и локальной модели. Наружу аудио не уходит.
-
Цифры на 06.11.2026. Ассистент сделал ~1170 записей, это ~920 часов разговоров у 93 организаторов. Канальный поток опубликовал 1136 саммари в 19 командах. Медиана от конца записи до готовых материалов — 4,6 минуты.
-
Самые дорогие грабли. Календарный ящик у нас в on-prem Exchange, и Graph API его не видит. Сессия браузерного постера тихо умирала из-за session-куки. Детектор говорящего ловил четверть речи, пока мы не перешли от событий к опросу состояния.
Как говорит наш CTO: “Какую проблему решаешь?”
Думаю как и в любой большой компании, у нас много встреч: внутренние дейли, клиентские синки, планирования, разборы. Договорённости на них проговариваются вслух и дальше живут в “записях на листочке” участников. Кто не был, тот не знает. Кто был — тот через неделю помнит 10%. Пробовали как-то вводить правило, чтобы хоть кто-то протокол вёл, ну вы поняли. Никто.
Первая боль — это договорённости встреч не доживают до следующей встречи, потому что их никто не записывает.
Вторая появилась позже и оказалась не менее важной. В компании зажила платформа цифровых сотрудников: агенты помогают инженерам поддержки, руководителям проектов, продажам. Коллега недавно подробно рассказала на Хабре, как мы внедряли цифровых сотрудников в поддержку. Главный вывод той статьи: самое сложное в агентах — данные. Агент знает только то, что записано и проиндексировано. А заметная часть знаний компании вообще нигде не записана. Её проговаривают на встречах. Короче, двигаем в сторону AI SDLC.
Т.е. вторая боль — агенты слепы ко всему, что решили голосом, и отвечают без учёта контекста.
Ассистент Встреч закрыл эту дыру с двух сторон:
-
Человек получает расшифровку, саммари и список «задача — ответственный — срок» автоматически примерно через 4.6 мин.
-
Транскрипты попадают в хранилище, которое индексирует платформа. Агенты (по клиенту, по продукту, личные) начинают отвечать и по прошедшим встречам: «что решили с клиентом X на прошлой неделе», «когда у меня следующий синк». Подробнее о связке в конце статьи. И, что немаловажно, возможно строить на этом проактивность агентов.
Почему не штатная расшифровка Teams и не Copilot
Первым делом я, разумеется, посмотрел на то, как это делает MS. Даже выбил себе и ещё 11 сотрудникам подписки на M365 Copilot. Да, было круто поначалу (год назад): сводки, настраиваемые сводки, копилот сразу на встрече отвечал на вопросы, в sharepoint прям в открытом документе можно через ии что-то делать. А потом пришли локальные харнесы, накопление своей базы знаний, скиллов и прочего, что не доступно копилоту.
Штатная расшифровка Teams на русском нас не устроила. Я сравнил её с нашим конвейером на одних и тех же внутренних встречах:
|
Метрика |
Расшифровка Teams |
Наш конвейер |
|---|---|---|
|
Слов в минуту записи |
26–37 |
99–108 |
|
Доля сегментов из 1–2 слов |
43–46% |
16–23% |
|
Латинских терминов на встречу |
1–7 |
17–284 |
|
Запятых на 1000 слов |
41–51 |
141–161 |
Выборка небольшая, несколько встреч. Это ориентир, а не бенчмарк.
Темп деловой речи около 100 слов в минуту, а Teams теряет примерно две трети текста. Технические термины он пишет кириллицей: «эд баттон» вместо add button. Но, имена говорящих у Teams точнее, чем у любой внешней диаризации, потому что он знает, кто говорил. Этим я потом воспользовался. Однако, в какой-то момент увидел расшифровку своей встречи на 4 человек, в которой одного он упорно называл «Докладчик1». Кааааак? У него же есть аудио каналы, он знает что от кого пришло! Алё, Майкрософт — вы знаете кто этот человек и что от него шёл звук…
Моя гипотеза такая: у MS то ли мощностей не хватает, то ли специально гонят на дешёвых моделях.
Copilot и Meeting AI Insights работают только у кого есть подписка. Для автоматики «у всех команд, без ручных действий» это не годится. Кроме того, аудио клиентских встреч мы хотели обрабатывать на своих серверах.
Встречи в чужом тенанте / Телемост / МТС Линк штатные средства не покрывают вообще. Некоторые клиентские встречи проходят там, и ещё в некотором зоопарке Труконф и т.п.
Отсюда требования:
-
распознавание и саммари внутри периметра;
-
для человека ноль новых действий, максимум одно привычное (добавить участника в приглашение);
-
никаких изменений политик тенанта, админ-консентов в чужих тенантах и установки приложений в каждую встречу;
-
запись только с явного согласия: позвали — пишем, не позвали или не впустили — не пишем.
Две дороги вместо одной
Я начал с одного универсального решения. Не вышло… Microsoft такой microsoft. Оказалось, что встречи Teams бывают двух сильно разных видов.
Канальные встречи привязаны к каналу команды. Там мы ведём регулярные дейли, планирования, ретро, прочее. Запись по политике включается сама и ложится на SharePoint-сайт команды. В тред встречи приходит системное сообщение о записи “Запись сохранена в SharePoint бла-бла-бла”. Здесь можно обойтись без всякого бота во встрече: достаточно бота в команде, который читает тред.
Звучит просто, но «чат» канальной встречи — это не чат. У обычной встречи есть отдельный групповой чат со своим id, его видно в /chats. У канальной встречи никакого чата нет: переписка встречи — это ветка ответов под корневым постом в канале, адрес вида 19:…@thread.tacv2;messageid=<id корневого поста>. В /chats её нет. В объекте встречи Graph отдаёт id канала и id корневого сообщения, но не говорит, какая из десятков веток канала относится к какой записи. А анонимного гостя в такую переписку не пускают вообще. Найти нужную ветку можно только одним способом: читать сообщения канала и ловить в них служебное событие записи. Поэтому бот и живёт в команде, а не во встрече.
Все остальные встречи: сотрудники создают встречи Teams из календаря Тимс или Outlook, которые по сути являются групповыми чатами со своей ссылкой для присоединения, ещё клиентские в чужом тенанте / Я.Телемосте / МТС Линк. В последних двух запись не гарантирована, доступа записи и расшифровке почти никогда нет. Поэтому единственный надёжный способ услышать разговор — в нём присутствовать и самому записывать.
К этой задаче люди подошли максимально креативно))
-
Одна сотрудница мне показала: создаёт встречу в тимсе на то же самое время, потом подключается к “внешней встрече” и одновременно в тимс встречу, в которой и ставит запись той встречи.
-
Второй осилил OBS Studio и через неё писал.
Поэтому потоков два:
|
|
Поток 1: канальные встречи |
Поток 2: Ассистент Встреч |
|---|---|---|
|
Как подключается |
бот установлен в команду |
спец-пользователь в приглашении или ссылка через API |
|
Откуда звук |
MP4 штатной записи с SharePoint |
собственная запись: Chrome во встрече |
|
Куда результат |
ответ в тред встречи + папка рядом с записью |
чат встречи, OneDrive организатора, письмо |
|
Имена говорящих |
из VTT-транскрипта Teams |
из подсветки говорящего в интерфейсе |
|
Ограничения |
только каналы, где стоит бот; приватные каналы не работают |
нужно впустить из лобби |
Потоки разделены физически: свои бот-регистрации, свои приложения, свои права. Принцип наименьших привилегий: если утечёт ключ одного потока, второй не пострадает.
Поток 1. Канальные встречи
Триггер: системное сообщение о записи
Когда канальная встреча записывается, Teams постит в её тред служебную активность CallRecording и проводит её по статусам Initial → ChunkFinished → Success. В событии Success есть всё нужное: driveId и driveItemId файла записи на SharePoint, тема, callId, ссылка на транскрипт. Из самой активности берём conversation.id вида 19:…@thread.tacv2;messageid=<root>. По нему потом отвечаем в тот же тред.
Обычный бот видит в канале только сообщения со своим @mention. Служебная активность mention не содержит. Нужно RSC-разрешение ChannelMessage.Read.Group, тогда бот получает все сообщения канала, а RSC выдаёт владелец команды при его установке в команду/канал. Админского гранта на весь тенант нет, и это осознанное решение.
Почему не готовый транскрипт Teams
Зачем качать видео и распознавать заново? Я прошёл все пути к транскриптам Тимс, и почти все оказались тупиками:
-
RSC
ChannelMeetingTranscript.Read.Group. Вроде бы то, что нужно, а на самом деле запрос транскриптов токеном бота отдавал403 … Resource specific consent grants on the request '', а чтение сообщений канала тем же токеном работало. Грант просто не работал. Рабочих подтверждений этого пути в app-only я не нашёл ни у кого. -
Ссылка на транскрипт из события ведёт в SharePoint. App-only токен там получает 401: для SPO-API нужен сертификат, секрет не годится.
-
/media/transcriptsв beta Graph отвечает400 Unsupported segment. -
Graph
onlineMeetings/{id}/transcriptsработает, но только с application access policy на организатора, которую выдаёт админ Teams через PowerShell. При смене организатора такой встречи, придётся опять править политику, что сильно бы усложнило жизнь.
Запрос с невалидным meetingId отвечает 400 not a GUID раньше, чем срабатывает проверка политики. По 400 нельзя понять, есть политика или нет. Диагностичны только 200 и 403 на валидном GUID организатора. И обратно: 403 с кодом 3003 означает «вы не организатор этой встречи», а не «нет политики».
Итого: штатная MP4-запись с SharePoint плюс собственное распознавание и диаризация. Транскрипты Teams я всё-таки подключил для конкретных людей, но как вспомогательный источник: когда он доступен, берём из него число говорящих и их имена (об этом ниже). Когда недоступен, публикуем саммари как обычно, просто в его промпте пытаемся заматчить спикеров. Часто на DSM-ах флоу такой: ведущий называет человека по имени и тот говорит, в этом случае ЛЛМ отлично привязывает реплики к нужному человеку.
Архитектура
[Teams: тред встречи] --CallRecording Success--> [listener (бот)] ^ | | claim в Postgres | v | скачать MP4 (Graph, app-only) | v | ffmpeg -> WAV 16 кГц mono | v | [сервис распознавания и саммари] | v +--------- ответ в тред <--- poller <--- статус / результат + папка «материалы» рядом с записью
Listener — небольшой сервис на Bot Framework (~330 строк). Он только принимает событие, делает claim и запускает скачивание в фоне. Распознавание и публикацию ведёт отдельный poller.
Claim в Postgres. Сначала состояние жило в JSON-файлах с claim-маркерами. На первой же неделе получил гонку: listener и страховочный крон одновременно отправили одну запись на распознавание. Поэтому переехал на Postgres: INSERT … ON CONFLICT DO NOTHING, затем SELECT … FOR UPDATE. Функция claim возвращает один из четырёх режимов:
-
new— записи не было, работаем; -
retake— была упавшая, протухший claim (старше 30 минут) или нет id задачи; -
attach— крон уже отправил запись, но не знал, куда постить; дописываем ему адрес треда и не качаем заново; -
skip— дубль.
Конкурентный claim покрыт тестом: из N параллельных претендентов побеждает один.
Статусы: claiming → submitted → posting → published | failed. Саммари сохраняется в строке до публикации, поэтому повторный пост не требует повторного распознавания.
Скачивание. На Success файл на SharePoint иногда ещё не дописан. Отсюда ретраи на 404/423: 4 попытки с удвоением паузы от 5 секунд. Качаем потоком в *.part. ffmpeg делает WAV 16 кГц mono и срезает тишину (silenceremove). Час записи — это MP4 примерно на 138 МБ.
Страховочный крон раз в час обходит команды из реестра и подбирает записи за 14 дней, которые listener пропустил, например во время деплоя. Ограничение: постить он не умеет. Адрес треда есть только в живом событии, поэтому крон распознаёт и ждёт, пока событие придёт (режим attach).
Авто-реестр команд. Список команд никто не ведёт руками: каждая активность несёт team.aadGroupId, и он сам попадает в таблицу teams. Имя команды резолвится фоном через Graph /teams/{id}. Через /groups не получилось: 403 при тех же правах.
MS Teams баг на проде — иногда бот тупо перестаёт получать любые апдейты. В такие моменты ко мне (вы не поверите как к саммари быстро привыкли люди!) прибегают и говорят, что бот не запостил саммари. Тут описано issue msteams-docs #11106: RSC настроен, @mention доходят, а остальные сообщения канала в одних тенантах не приходят вообще, в других приходят через раз.
Промпт под тип встречи
Под разные типы встреч разные промпты саммари, которые определяются по содержанию слов в названии встречи: DSM, planning, review, retro, grooming. Файл читается при каждой отправке, поэтому промпт можно править без рестарта. Для команд, которым свой формат не нужен, есть тумблер, и тогда саммари делается промптом по умолчанию.
При сравнении имён команд пришлось завести таблицу гомоглифов. «AB», набранное кириллицей, и «АВ» латиницей — разные строки, а люди пишут как попало.
Имена говорящих из транскрипта Teams
Главная проблема сервисов распознавания — диаризация. Разметка говорящих как SPEAKER_00, SPEAKER_01 и так далее. Читать саммари, где «SPEAKER_03 взял задачу» неприятно. А вот транскрипты Teams содержат настоящие имена. Я взял лучшее из обоих:
-
До отправки на распознавание забираем родной VTT (договорились что только эти люди создают командные встречи и прописали их в политику, чтобы можно было от их лица скачивать эти транскрипты). Число уникальных говорящих передаём сервису как подсказку
num_speakers, и диаризация заметно улучшается. -
После распознавания сопоставляем сегменты VTT с нашими кластерами. Ищем временной лаг между двумя дорожками (сначала грубо с шагом 0,5 с, потом с шагом 0,05 с), затем строим двудольное соответствие 1:1 «кластер → имя» и добираем остатки.
-
Подставляем имена в транскрипт и саммари.
Всего на трёх проверенных вручную встречах 94–99% сегментов получили верное имя, а 88–100% времени речи оказалось атрибутировано. А любой сбой — просто уходит на «Аноним», ведь ошибочно приписанная реплика другому человеку хуже анонимного.
Нюанс с meetingId: для канальной встречи это base64("1*{oid организатора}*{rootMessageId}**{threadId}"), где организатор — автор корневого сообщения серии. Транскрипт ищем строго по callId, а не «последний в серии». У регулярных встреч их десятки.
Права
У потока две identity в одном процессе:
-
Бот (Bot Framework) принимает события и постит в тред. Постить в канал app-only через Graph нельзя: application-разрешения
ChannelMessage.Sendне существует. Поэтому пишет только бот, через сохранённый ConversationReference. -
Graph-приложение с application-правами скачивает записи, кладёт материалы и читает транскрипты:
Sites.Read.All,Files.ReadWrite.All,OnlineMeetingTranscript.Read.All,ChannelMessage.Read.Allи несколько read-only разрешений на команды и пользователей.
Материалы (саммари, конспект, транскрипт, субтитры, JSON) кладём в папку <встреча> - <дата> - материалы рядом с записью. Права доступа к ним наследуются от папки команды. Самописный RBAC не нужен: кто видел запись, тот видит и саммари.
Грабли потока 1
RSC-доставка отваливается в отдельных командах. Симптом: в одной команде саммари перестают появляться, в остальных всё работает. Это недетерминированный баг Teams (см. issue #11106 выше). Лечится переустановкой бота в команде. Потерянные встречи досылаем командой служебного бота: он находит тред встречи через Graph по имени записи и подставляет адрес. Короче проверять и перепроверять за этим Майкрософтом, поэтому крон-сверка и SQL-отчёт «по каким командам давно не было публикаций» обязательны.
Приватные и общие каналы не работают вообще. RSC не доставляет туда события, Graph-подписки отвечают 403 … Shared/Private channel, бот не может туда постить, и у канала свой SharePoint-сайт. У Microsoft это значится в отложенных фичах, которые решили не чинить. Любопытно, что Graph app-only при этом читает и файлы, и сообщения таких каналов.
Messaging endpoint бота нельзя поменять. Формально можно, но после смены Teams перестаёт доставлять сообщения. Webchat в портале работает, healthz отвечает, а из Teams не приходит ни одного POST. Помогает только пересоздание регистрации с новым App ID. Так что будете делать ботов держите в голове, что если перестало что-то доставляться, просто пересоздай бота.
Тишина из-за блокировок День как день, сервис запущен, /healthz отвечает 200, лог молчит, бот не отвечает. Единственный след — 499 с нулём байт в логе nginx: входящий запрос ждал, пока бот проверит JWT, а бот не мог скачать ключи Bot Framework, потому что прямой выход к Microsoft закрыт. РКН это или ещё кто-то, спасибо… Лечится переменными прокси в systemd drop-in.
Docker Compose снёс соседний прод. Compose-файлы всех ботов лежали в каталогах deploy/, поэтому имя проекта у всех было deploy. Один docker compose up удалил контейнеры другого бота. Лечится явным name: на верхнем уровне compose-файла. Звучит банально, но стоило нам одного упавшего прода.
Сервис распознавания терял задачу. Отправили на распознавание — 200 ОК, ну ок и ок, а /result потом навсегда отвечал 404. Причина — OOM контейнера сервиса распознавания. Одиночные 404 сразу после отправки при этом нормальны (лаг регистрации задачи), и поначалу я просто хоронил живые записи. Ввёл 60-минутный грейс. Потом по мере роста нагрузки на сервис распознавания это окупилось, т.к. очередь на распознавание в пиковые часы копилась, и запись могла легко ждать и по 30 минут… Лечение банальное: ещё один инстанс сервиса.
Пустое саммари. Плавающий баг: транскрипт есть, а саммари пустое. Впервые поймали на очень длинном промпте (11,6 тыс. символов). Тот же файл с промптом по умолчанию дал 14 тыс. символов саммари за 226 секунд против 548. Позже повторилось на промпте в 6 тыс., то есть дело не только в длине. Старый poller молча ждал до суточного таймаута. Теперь пустой результат запускает пересабмит: до двух попыток, последняя — промптом по умолчанию с пометкой «общий формат». Я думаю, что модель иногда слишком много на thinking тратит, что вытесняет ответ. Но это не точно.
Поток 2. Ассистент встреч
Это основная и самая интересная часть.
Как позвать?
Сначала я сделал бот-приложение, которое организатор добавляет во встречу через «+». Технически это работало и было проверено вживую, но отвергнуто по UX. Никто не будет искать приложение в каждой встрече, после её создания. А вот добавить участника — раз плюнуть.
1. Пригласить на встречу. Можно позвать «Ассистента Встреч» на встречу через указание его как участника встречи в Обязательных. Поэтому админы завели мне обычного пользователя «Ассистент Встреч» с лицензией Тимс. Не shared mailbox, а полноценная учётка. Организатор добавляет его в приглашение, как любого коллегу. Приглашение работает одновременно как согласие на запись и как носитель ссылки на встречу. Больше ничего делать не надо.
2. POST /join со ссылкой. Для встреч вне календаря, для интеграций, или для случая “ой, забыли!”: пришлось поднять API. На вход ссылка, тема и кто попросил. Этим API пользуются агенты платформы цифровых сотрудников. Пишешь боту в Teams «запиши встречу по ссылке», и он зовёт Ассистента.
3. Телемост. Тот же /join, платформа определяется по хосту ссылки. Для Телемоста обязательно указывать, кто попросил: у Телемоста нет понятия организатора, а email участников он не отдаёт никому, даже владельцу встречи.
4. МТС Линк. То же самое как и у Телемоста.
Календарь: polling, EWS и on-prem сюрприз
Почему polling, а не webhook. Подписки Graph на календарь существуют и бесплатны. Но им нужен публичный эндпоинт, продление каждые несколько дней и обработка пропущенных уведомлений. Опрос раз в минуту проще и надёжнее, а задержка в минуту для встречи, запланированной заранее, ничего не значит. Заодно развеяли внутренний миф «вебхуки запретили». Запрет касался Incoming Webhooks и коннекторов в каналах Teams, а не уведомлений календаря Outlook.
Пивот на второй день. План был прямолинейный: Graph-приложение с Calendars.Read, через Application RBAC ограниченное одним ящиком. Первый же запрос вернул 404 MailboxNotEnabledForRESTAPI. Оказалось, что наш тенант гибридный: учётная запись синхронизирована в Entra ID, а сам ящик живёт в on-prem Exchange. Graph о нём знает, но календарь не отдаёт.
ИИ выдавало два варианта: мигрировать ящик в Exchange Online или читать календарь по-старому. Я выбрал EWS через exchangelib, NTLM и сервисную учётку с делегированием на один ящик. Graph-приложение для календаря удалили. Письма ad-hoc-встречам по той же причине тоже уходят через EWS: sendMail в Graph по on-prem ящику отвечает тем же 404.
Мелочи EWS, которые стоит знать:
-
ссылка на встречу Teams лежит в extended property
SkypeTeamsMeetingUrl, запасной путь — regex по телу приглашения; -
ItemIdв EWS нестабилен (меняется при правках), аImmutableIdесть только в Graph. Ключ встречи у нас —UID + время начала в UTC, так экземпляры серии различаются; -
отмену определяем по «пропала из выдачи два прохода подряд». Если бот уже во встрече, контейнер гасим;
-
окно опроса: от часа назад до суток вперёд.
А ещё, вы бы знали сколько я часов потратил на созвонах с админами, чтобы всё это завелось, какие права, где они, как работают. Ужас. Microsoft такое умеет — доказано.
Спавн и лобби
К началу встречи демон запускает контейнер media-bot (устройство ниже). Бот заходит анонимным гостем с понятным именем. Аноним, а не учётка Ассистента Встреч, потому что одна учётка может сидеть только в 1 встрече одновременно, в другой она уже будет на паузе. Кроме того, у анонима нет доступа ни к чему, кроме самой встречи.
Из лобби впускает организатор, вручную. Программно допустить бота нельзя: в Graph такого API нет, Microsoft это подтверждает. И ладно бы это, но они ещё начали политику по “обелению” ботов и вставление палок в колёса другим. Чего только стоит вот это окошко подтверждения:

Куда нажимать? Правильно, около 15% сотрудников жмут синюю кнопку.
Авто-допуск же требует политики «все заходят без лобби», а кто мне разрешит трогать политику тенанта?) Получается так: не впустили — не записали. В статистике это заметная доля (см. аналитику), но это фича, а не баг.
Когда заходить. Поначалу бот заходил за 3–5 минут до старта и торчал в пустом лобби. Я думал, что так правильно — у всех всплывает окошко, что встреча началась и подключаются. Но пошли жалобы (причём от топов): «он слишком рано начинает встречу!». Ладно, переделал на «через 1-2 мин после старта». Жалобы кончились, но допуск сместился на +2–3 минуты (связано с временем поднятия контейнера, и реального подключения в Chrome, которое как оказалось тоже не стабильное), и начало встречи терялось. Опять пошли жалобы: «он не приходит!» В итоге пришлось делать замеры: от запуска контейнера до клика «Join» ~30 секунд (SPA Teams поднимается ~20 с), в лобби люди впускают быстро, медиана 19 секунд. Сейчас спавн за 50 секунд до старта, так что клик «Join» попадает примерно в момент начала. Медиана допуска сократилась со +135 до +64 секунд от планового начала.
Остальные таймауты: ждём в лобби до 12 минут, уходим, если остались одни дольше 3 минут, жёсткий потолок записи 3 часа.
Небольшой патч безопасности
Т.к. бот заходит во встречу анонимным гостем, то любой другой гость мог назваться так же и попасть на встречу под его видом 🙂 Поэтому теперь у каждого захода есть короткий ключ подлинности, и он стоит в имени бота: «Digital teammate — ABCDE».
Если запись запускают по ссылке /join через ИИ агента, то он выдаёт сотруднику ключ. И сотрудник уже сравнивает ключ в имени бота. Без ключа запрос отклоняется. Для встреч из календаря ключ генерирует сам Ассистент Встреч, и ещё до захода бота он пишет в чат встречи с каким именем должен подключиться бот.
Распознавание и саммари
Аудио уходит во внутренний сервис распознавания: POST /transcribe (multipart), затем опрос статуса и забор результата. Результат: транскрипт с диаризацией, субтитры, саммари и «дистиллят» (сжатый конспект). Сервис принимает собственный системный промпт для саммари, это мы попросили добавить. Проверили, что структура саммари полностью подчиняется шаблону. Час аудио обрабатывается примерно за 3,5 минуты и стоит нам около 4,5 ₽ (расчёт ниже, в разделе «Сколько это стоит»).
Промпт Ассистента Встреч собран вокруг договорённостей: блок «задача — ответственный — срок», где отсутствие ответственного или срока пишется явно («не назначен», «срок не обсуждался»), а не додумывается. Для отдельных регулярных встреч есть свои промпты, они подбираются по подстроке темы.
Число говорящих бот считает по своему таймлайну и передаёт как подсказку. На тестовой встрече двух человек без подсказки получалось 5 кластеров, с подсказкой 2.
Доставка
Куда уходит результат, зависит от того, как позвали:
-
Если по расписанию из календаря: пост в чат встречи, материалы в OneDrive организатора, доступ к папке для участников.
-
Если через
/join: письмо с материалами плюс доступ к папке. В чат не сможем запостить, Ассистента Встреч там нет.
OneDrive организатора. Teams сам кладёт записи в OneDrive того, кто запустил запись, так что пользователи привыкли искать там. Папка Recordings/<встреча> - <дата> - материалы/: mp3 (chunked upload), саммари, конспект, транскрипт, субтитры, JSON. Пишет Graph-приложение с Files.ReadWrite.All. Это широкое право и сознательный компромисс против least privilege: более узкого способа писать в чужие OneDrive app-only нет.
Доступ участникам выдаётся через Graph driveItem/invite: роль read, тихо, без письма, только внутренним адресам. Внешнему гостю app-only-приглашение всё равно не выдать.
Письмо для ad-hoc-встреч: HTML с тремя ссылками в шапке (запись, транскрипт, материалы), копия ложится в «Отправленные» Ассистента Встреч. Если организатор неизвестен, не шлём совсем, чтобы фильтр по домену случайно не пропустил внешних.
Чат встречи. Саммари режется на части по структуре Markdown, а не по символам. Если часть начинается внутри таблицы, она повторяет шапку таблицы. Между частями пауза 15 секунд: без неё Teams молча съедал подряд идущие длинные сообщения. Teams нормально рендерит вставленный HTML с заголовками, вложенными списками и таблицами.
С постингом в чат связана моя самая длинная сага проекта…
Сага о посте в чат
Задача звучит просто: когда саммари готово, написать его в чат встречи. Но к этому моменту встреча уже закончилась, анонимный бот вышел. Значит писать должна учётка Ассистента Встреч, которая есть в чате как участник приглашения.
Что не сработало сразу:
-
Graph app-only не умеет писать в чаты: application-разрешения
ChatMessage.Sendне существует (есть только для миграции истории). -
ROPC (логин паролем) упирается в обязательную MFA:
AADSTS50076. -
Bot Framework требует установки приложения в каждую встречу, а от этого мы и уходили.
Решение номер один: через playwright в браузере
Залогиненный Teams web под Ассистентом Встреч в persistent-контексте Playwright. Технически это уже отдельная статья:
-
редактор игнорирует синтетический
insert_text, нужны настоящие события клавиатуры; -
HTML вставляется только реальным
Ctrl+Vпослеnavigator.clipboard.write(text/html). СинтетическийClipboardEvent('paste')в Chromium приходит сclipboardData === null; -
Enterи кнопка Send у этой учётки чистили черновик без отправки. Работал толькоCtrl+Enter. Двоеточие в тексте открывало пикер эмодзи.
Классический Teams web выключили
Через несколько дней после запуска Microsoft отключил классический веб-клиент. Deep-link на чат стал вести в мёртвую страницу, а новый клиент /v2/ не кодирует чат в URL и по умолчанию открывает последний активный. Отсюда главный принцип постера: позитивное подтверждение цели перед отправкой. Чат ищется в списке по threadId и открывается кликом по строке. Нет строки — выходим с кодом «не участник» и не пишем ничего. Лучше не доставить, чем доставить не туда.
Сессия тихо умирала
Затем начались протухания: раз в несколько дней постер переставал входить. Автоматический перелогин рапортовал успех, следующий пост падал. Я долго ковырялся с симптомами. Кука ESTSAUTHPERSISTENT есть, а ESTSAUTH нет. Активная сессия Entra держится на session-scoped ESTSAUTH, а Chromium не пишет session-куки на диск при закрытии persistent-контекста. Я запускал постер разовым docker run: каждый запуск логинился и уносил сессию с собой в могилу. Перелогин в отдельном контейнере «удавался» в своём контексте и умирал вместе с ним.
Пофиксил долгоживущим сервисом постера: один браузерный контекст живёт постоянно, POST /post на localhost, перелогин происходит в том же контексте.
Ложные позитивы и негативы
Браузерная проверка доставки врала в обе стороны:
-
Ложный «жив». Список чатов рисуется из кэша профиля (IndexedDB) без валидного токена. Признак «список загрузился» ничего не значит. После внешнего рестарта VM (unattended-upgrade обновил libc) сервис стартовал за 25 секунд без входа, keep-alive двое суток рапортовал «сессия тёплая», в базе стояло
chat_posted=true, а сообщений не было. -
Ложный успех. Заголовок «Саммари встречи „тема“» одинаков у всех постов в серии. Проверка находила прошлонедельный. Перешёл на прирост числа одноимённых сообщений.
-
Ложный провал. Realtime-рендер на нагруженной VM отставал больше 20 секунд. Пост уходил, а постер сообщал провал. Однажды это дало дубль при переотправке.
-
Ложное «сессия истекла». Из 13 тревог 11 оказались одной болезнью: чат не найден, живость сессии проверялась на той же сломанной странице, аватар присутствия не отрисовался, запускался бесполезный 90-секундный перелогин, и клиент отваливался по таймауту.
Общий урок: критерий успеха — серверное подтверждение доставки, а не состояние UI. Арбитром сделал Graph: GET /chats/{id}/messages видит сообщение через секунды после отправки.
Решение номер два: delegated Graph
Раз Graph всё равно стал арбитром, логично было постить через него же. Delegated-токен под учёткой Ассистента Встреч, POST /chats/{id}/messages, ответ 201 с id сообщения, и это и есть подтверждение. Токен получается через device code flow. Один нюанс стоил отдельного вечера: у конфиденциального приложения device code проходит все экраны входа, включая второй фактор, и падает на финальном обмене с AADSTS7000218 (нужен client secret). Работает только с public client. Обратная ловушка: опрос по ещё не подтверждённому коду отвечает authorization_pending для любого приложения, так что пре-флайт ничего не проверяет. Refresh-токен ротируется при каждом обмене, поэтому хранить его можно только в одном месте: копии на ноутбуке и на сервере гасят друг друга.
Поэтому браузер, профиль, keep-alive и вся коллекция ложных позитивов ушли вместе с ним. Плюс я накинул ещё одно право, чтобы Ассистент Встреч автодобавлялся в чат сам. Это подняло долю доставленных в чат с 91% в сентябре (смешанный месяц: половина на браузере) до 99% в октябре.
Регулярные встречи и доступ к чату
У MS всё строго с правами, поэтому когда сотрудники вместо редактирования ряда встречи, просто открывали экземпляр и добавляли Ассистента Встреч, то он получал доступ к собранию и чат только на время встречи. И как вы догадались, после встречи тупо не мог ничего запостить. Я написал инструкцию для сотрудников как правильно. Думаете сработало?))) В итоге теперь Ассистент Встреч сам добавляет себя в чат через Graph POST /chats/{id}/members (ChatMember.ReadWrite.All), когда видит приглашение, и перепроверяет членство перед каждым постом.
Хранилище состояния и наблюдаемость
Рабочее состояние демона — JSON-реестр под flock. Статусы: scheduled → spawned → pending_recognition → submitted → pending_chat_post → done, терминальные cancelled, expired, failed. Аналитика пишется отдельно, в свою базу Postgres (таблицы meetings и events). Ассистент Встреч от неё не зависит: если база недоступна, события копятся в локальный файл и досылаются позже. Все цифры ниже взяты оттуда.
Чтобы за этим зоопарком следить, я себе сделал отдельного служебного Teams-бота: «!статус» и др. команды. Связь с демоном только по данным: бот читает JSON-снапшот, а запрос на переотправку кладёт файлом, который демон подбирает на следующем проходе. Общего кода и сетевых вызовов между ними нет. События статусов также уходят в Graylog. Идентификаторы встреч там не пишутся целиком, только хвост хэша.
Грабли потока 2 одной таблицей
Самые показательные истории разобраны выше. Остальное здесь:
|
Симптом |
Корень |
Что сделали |
|---|---|---|
|
Бот не нажимает Join, в статистике «не вошёл» |
Teams блокирует кнопку для имени гостя со скобками и некоторыми другими символами |
Переименовали бота, ошибку валидации ловим явно |
|
На одну встречу пришли два бота |
Календарь даёт ссылку |
Дедуп по ядру ссылки и по |
|
Вторая встреча в «постоянной комнате» не записалась |
Дедуп считал предыдущую встречу по той же ссылке «ещё в работе» |
Открыто |
|
Не пишет одно вхождение серии |
Удалили Ассистента Встреч из серии, потом позвали в одно вхождение; EWS держит экземпляр отменённым |
Редкий случай, объясняем пользователям |
|
У гостевого организатора ничего не доставилось |
У организатора-гостя нет OneDrive, а чат встречи невидим |
Не чиним кодом: просим создать встречу от сотрудника |
|
Материалы не легли в OneDrive у встречи в чужом тенанте |
Организатор не резолвится |
Лестница: организатор из ссылки → из чата встречи → тот, кто позвал |
|
Два мёртвых адреса лишили доступа 40 живых участников |
Graph |
Поштучный фолбэк, счётчик розданных в базе, догнали ~124 гранта |
|
Загрузка в OneDrive падает на длинной теме |
Путь длиннее 400 символов |
Обрезаем имя встречи до 90 символов |
|
Ad-hoc-встречам месяцами не выдавался доступ |
У встреч из |
Берём участников из чата встречи |
|
«Потолок записей» при свободной машине |
Контейнер, не дождавшийся лобби, держал слот до конца окна |
Считаем живые контейнеры, а не статусы |
|
Сервис распознавания недоступен — встреча в |
503 сжигали все три попытки за 3 минуты |
Временные ошибки не тратят попытки, терпим до 24 часов |
|
Переотправка теряет имена говорящих |
Сопоставление жило только в публикации |
Готовое сообщение сохраняется, имена восстанавливаются из таймлайна |
|
Легли сразу запись по ссылке, канальный поток и служебный бот |
Истёк вручную выпущенный wildcard-сертификат |
Заменили, срок в календаре; ноль строк в access-log — признак обрыва TLS до nginx |
|
Служебный бот замолчал после чужого деплоя |
Локейшены nginx вписали в живой конфиг руками, деплой перелил конфиг из репозитория |
Конфиг только из репозитория |
Media-bot: Chrome, который ходит на встречи
Один контейнер — одна встреча. Внутри:
-
Google Chrome, а не Chromium. В сборке Chromium нет H.264, и Teams отвечает «Sorry, we couldn’t connect you». Телемост отдельно проверяет версию браузера: Electron со встроенным Chrome 148 получил «версия устарела», свежий Chrome прошёл. User-Agent не подменяем.
-
Headful под Xvfb. В headless Teams отдаёт другой DOM.
-
Playwright (
launch_persistent_context) плюсexpose_bindingдля событий из страницы. -
PulseAudio с null-sink. Chrome воспроизводит звук встречи в виртуальный
botsink, ffmpeg пишет его monitor-источник. Физических устройств нет, у каждого контейнера свой pulse, изоляция бесплатная. -
ffmpeg с двумя выходами: PCM в локальный TCP-сокет для потоковой обработки и непрерывный mp3 (16 кГц, mono, 48 кбит/с, ~21 МБ в час). Начинали с WAV 48 кГц, перешли на mp3, чтобы быстрее грузить в распознавание и OneDrive.
Флаги Chrome: --use-fake-ui-for-media-stream, --autoplay-policy=no-user-gesture-required, --disable-blink-features=AutomationControlled, без --enable-automation. Сознательно нет --ignore-certificate-errors и --disable-web-security: на них реагирует anti-abuse Teams.
Интерфейс страницы встречи (MeetingPage) общий для Teams и Телемоста, реализация выбирается по хосту ссылки. Общего базового класса нет, только функции-хелперы: наследование между двумя чужими DOM, которые меняются независимо, приносит больше боли, чем пользы.
Жизненный цикл
-
Ссылка канонизируется в
/v2/?meetingjoin=true#…&anon=true. -
На prejoin вводится имя, микрофон и камера выключаются переключателями. После допуска для страховки ещё раз хоткеями. Хоткей переключает состояние, а не ставит его, поэтому сначала проверяем, что сейчас.
-
Кнопка Join появляется раньше, чем становится активной. До начала встречи или пока Teams не поднял медиа, она disabled, поэтому ждём явно.
-
До трёх попыток входа с пересозданием браузера между ними: мёртвый renderer и мусор в DOM мешают. Главный кейс для ретраев — организатор опаздывает.
-
Допуск подтверждается только появлением кнопки «повесить трубку».
-
Во встрече синтетический
mousemoveраз в 30 секунд, иначе неактивной вкладке Teams перестаёт доставлять ростер. -
Выход: остались одни дольше 3 минут, встреча закончилась, страница зависла (
evaluateвисит больше 20 с), потолок 3 часа или командаPOST /leave. Если ростер не прочитался (None), отсчёт «одни» не сбрасывается и не запускается. Иначе нестабильный DOM продержал бы бота во встрече до потолка.
Скриншот причины любого сбоя входа пишется на хост: контейнер запускается с --rm, и без этого причина исчезает вместе с ним.
Кто говорит?
Знающие люди мне сразу сказали, что это нереально)) Чтобы саммари писало «Иван Иванов взял задачу», а не «SPEAKER_02», нужен таймлайн «кто когда говорил». Ростер Teams для этого бесполезен: в нём только «микрофон включён/выключен», а не речь. Источник — подсветка говорящего на плитке участника.
Индикатор. Узел [data-tid="voice-level-stream-outline"] с атомарным CSS-классом Fluent: один класс означает «говорит», другой — «тихо». Хеш-классы хрупкие, поэтому есть второй, семантический маркер. Имя берётся из data-tid предка, video-item-container-<Имя>. Я надеюсь не будет MS часто менять это)) пока что работает тьфу-тьфу-тьфу.
Recall 25% → ~100%. Первая версия слушала мутации класса через MutationObserver и ловила только начало речи: на непрерывной речи класс не мигает. 37 секунд монолога превращались в интервал на 1,5 секунды. Заметил только на живом прогоне с реальным разговором. Переписал на опрос состояния каждые 200 мс с дебаунсом выключения 400 мс, интервалы с паузой меньше 0,5 с склеиваются.
Слепота к участникам без камеры. У участника с выключенной камерой нет предка video-item-container-*, имя есть только в голом data-tid. Сверить его можно с ростером, но ростер есть в DOM, только пока открыта его панель. Анонс бота в чат открывал панель чата и на 12-й секунде вытеснял ростер. Участница без камеры, говорившая полчаса, получила одну секунду. Фикс: накопительный набор имён, повторное открытие ростера после анонса и автоматическая переинъекция observer’а. На многолюдной встрече, где раньше было «3 имени за первые 12 секунд», теперь ловится 12 имён.
Сопоставление с диаризацией. На входе две независимые разметки одной встречи. Сервис распознавания говорит: «вот кластеры голосов SPEAKER_00…NN и интервалы, когда каждый звучал». Бот говорит: «вот имена и интервалы, когда у каждого горела подсветка». Задача — сказать, какой кластер кто.
Сначала обе разметки приводим к одной оси времени. Ноль — момент старта записи ffmpeg. Звук до бота доходит с задержкой относительно подсветки, поэтому сдвиг между дорожками ищем перебором с шагом 50 мс: берём тот, при котором разметки совпадают лучше всего.
Первый метод (голосование) был в лоб: каждое слово транскрипта отдаём тому, у кого в этот момент горит подсветка, и кластер получает имя большинства своих слов. На проде он врал. Подсветка «залипает» на доминирующем спикере, и один человек забирал себе два-три кластера, а остальные оставались без имени.
Второй метод (назначение) — назначение в два прохода. Считаем таблицу: сколько секунд каждый кластер пересекается с подсветкой каждого человека.
-
Очевидные пары. Если больше половины звучания кластера приходится на одного человека, закрепляем пару сразу. Если на одного человека так претендуют два кластера, остаётся тот, у кого пересечение больше. Закреплённые кластеры и имена вычёркиваем из таблицы.
-
Остатки. Оставшиеся кластеры и оставшиеся имена распределяем строго один к одному так, чтобы суммарное пересечение было максимальным (классическая задача о назначениях, двудольное паросочетание). Одно имя не может достаться двум кластерам, и «залипание» больше не мешает: доминант уже забрал свой кластер в первом проходе.
На базе из 10 встреч и 38 назначений голосование дало 19 верных. Назначение дало все 38 верных при ручной сверке, хотя у 11 из них пересечение было меньше 35%. Я поставил порог отсечения «слабых» назначений в 30 секунд и в тот же день его убрал: слабые оказались правильными за счёт комбинаторики 1:1. Так что низкое покрытие не означает ошибку.
Потолок данного подхода — диаризация, а не сопоставление. Если сервис слил двух людей в один кластер (на встрече 1-на-1 так было 13 минут подряд), назвать их по отдельности нельзя. Так что тикет на улучшение диаризации в сервисе распознавания висит.
Отвергнутая идея: атрибуция прямо по подсветке, без диаризации. В какой-то момент подумал, может отбросить диаризацию вообще и по подсветке попробовать матчинг? Проверил на встрече из 8 человек длиной 63 минуты. Оказалось кольцо гаснет с задержкой… Одно кольцо горит лишь 49% времени, два — 35%, три — 12%. Суммарная подсветка в 1,6 раза длиннее встречи. Дальше не стал копать.
Телемост
Вторая платформа почти целиком переиспользовала первую: тот же контейнер, тот же звук, тот же конвейер. Отличия:
-
аноним заходит без лобби, даже в пустую встречу (если это не 360 Я.Телемост);
-
prejoin и тулбар размечены стабильными
data-testid, а плитки участников — CSS-модулями с плавающим хешем, отсюда селекторы[class*="…"]; -
говорящего выдаёт обводка плитки. Имя ей не потомок, а сосед, поэтому путь «обводка → контейнер плитки → имя в
title». Обводок бывает несколько сразу (это «громкость выше порога»), и для метода назначения это даже лучше; -
ростер надёжнее брать из WebSocket-сообщений клиента, чем из DOM. Серверный VAD в том же канале редкий (7 кадров за 70 секунд) и как детектор речи не годится;
-
чат анониму закрыт, email участников недоступен, поэтому материалы получает тот, кто позвал;
-
публичный API Телемоста умеет только CRUD конференций. Браузер — единственный путь.
Однажды утром все Телемост-встречи начали падать на входе. После обновления Телемоста ссылка /j/<id> стала открывать оболочку мессенджера вместо prejoin. Фикс в одну строку: переписываем ссылку в /private-join/<id>. Найти причину заняло больше времени, чем исправить.
Главное отличие, ради которого Телемост даже интереснее Teams, — звук. Teams отдаёт клиенту уже смикшированный сервером поток: все голоса в одной дорожке, и кто говорит, приходится угадывать по подсветке в интерфейсе. Телемост присылает каждого участника отдельной аудиодорожкой WebRTC. Это значит, что говорящего можно определять по самой дорожке, где есть звук, а не по DOM. Ни задержки гаснущей подсветки, ни «залипания», ни зависимости от вёрстки. А если писать каждую дорожку в свой файл, диаризация не нужна вовсе: голос уже подписан. Пока мы пишем общий микс, как в Teams, и раздельная запись — следующий шаг.
У МТС Линк примерно то же самое.
Ёмкость: упираемся в CPU, а не в память
Я следил за нагрузкой VM каждый день. Замер docker stats на одной записи (VM 4 vCPU / 7,7 ГБ на тот момент) показал:
-
CPU: медиана 55% одного ядра, пики 118%, на старте до 2,4 ядра;
-
RAM: ~880 МБ;
-
две трети нагрузки — renderer Chrome (декод видео). Весь Python-конвейер (ресэмплинг, VAD, потоковая отправка) — 1,1% CPU и 88 МБ.
Узкое место — процессор, особенно одновременный старт нескольких записей. Главный рычаг нашёлся в меню «Вид»: «Turn off incoming video». Бот не смотрит видео, но декодирует его. На контролируемом прогоне (4 участника, демонстрация экрана, все говорят) декод упал с 19,3 до 3,7 кадра в секунду, −81%. Атрибуция не пострадала: подсветка говорящего от видео не зависит. На проде медиана CPU одной записи упала с 55% до 37%, пик старта — с 241% до 94% (сравнение неконтролируемое, разные встречи). Отключить чужую демонстрацию экрана так же нельзя: такого пункта в Teams нет, зонд обошёл все меню трижды.
/dev/shm при --disable-dev-shm-usage не используется вообще, его размер на ёмкость не влияет. Позже VM расширили до 8 vCPU и 15 ГБ. Пиковая нагрузка, которую мы видели, — 10 одновременных записей (при ёмкости 15).
«Массовка»: как тестировать бота, который слушает
Атрибуцию не проверить без второго живого голоса. Поэтому у media-bot есть режим «массовки»: второй контейнер заходит во встречу и говорит заранее записанный разговор, который я точно провалидировал на правильность диаризации. Четыре независимые грабли, и каждая выглядела одинаково: «бот зашёл, но его не слышно»:
-
--use-fake-device-for-media-streamподменяет микрофон синтетическим тоном. В режиме говорящего флаг надо снимать. -
WebRTC скрывает monitor-источники PulseAudio из списка микрофонов. Нужен
module-remap-sourceповерх monitor-источника. Причём отдельного sink’а: если говорить в тот же, который слушаешь, получится петля, и её задавит эхоподавление. -
ffmpeg -f pulse <name>: в выходном направлении<name>— это имя потока, а не устройство. Звук молча шёл в default sink. Нужен явный-device. Ловится только черезvolumedetect: тишина −91 dB против −20…−35 dB у речи. Ни одной ошибки в логах. -
Микрофон глушился дважды: на prejoin и хоткеем после допуска. Анониму Teams вдобавок отдаёт микрофон уже выключенным, поэтому нужно явное «включить, если выключено».
Цифры
Все данные из продовых баз на 31.10.2026. Журнал Ассистента Встреч полный с 19.08.2026, июнь–июль — пилот на своих встречах. Месяцы по UTC.
Ассистент Встреч
|
Месяц |
Заходов бота |
Не вошёл |
Записей |
Часов |
Медиана / p90 длины, мин |
Активных организаторов |
|---|---|---|---|---|---|---|
|
Август (с 19-го) |
68 |
24 (35%) |
39 |
27 |
36 / 100 |
26 |
|
Сентябрь |
532 |
85 (16%) |
434 |
345 |
42 / 80 |
78 |
|
Октябрь |
668 |
118 (18%) |
542 |
430 |
39 / 90 |
74 |
|
Итого |
1268 |
227 (18%) |
1015 |
803 |
40 / 87 |
92 уник. |
С пилотом июня–июля получается ~1170 записей и ~920 часов.
-
Источники: календарь — 1268 заходов,
/joinдля Teams — 135, Телемост — 16. Ассистента Встреч добавляют во встречу прям при создании в 6 раз чаще, чем по прямой ссылке через бота в Тимс. -
Рост: публичный анонс был в конце августа. За неделю до анонса Ассистентом Встреч пользовались 6 организаторов, через неделю после 34. К концу сентября уникальных организаторов набралось 81. В октябре прирост новых замедлился, а использование держится: 74 активных организатора. Медиана 7 встреч на организатора, у 35 человек больше 10 встреч.
-
Встречи: в среднем 8 участников, медиана 6.
-
«Не вошёл» по неделям: 35%, 36%, 22%, 17%, 13%, 17%, 16%, 11%, 15%, 19%. Детальная причина хранится только в файле сессии, поэтому разбил по длительности попытки. 48% — это таймауты лобби, то есть анонимного гостя не впустили. 38% закончились быстрее двух минут: не удалось подключиться или кнопка Join не активировалась. Остальное — промежуточные случаи. Половина «провалов» — не баг, а работающий opt-out.
-
Как заканчиваются записи: 723 раза бот ушёл, оставшись один (штатно), 277 раз встреча закончилась, 15 раз сработал потолок 3 часа.
-
Скорость: от конца записи до готовых материалов медиана 4,4 мин, p90 11,2 мин (в пиковые дни сейчас по 8 одновременных встреч записывается и потом они массово идут на распознавание, поэтому и был поднят новый инстанс сервиса распознавания, чтобы ускориться). Обработка растёт с длиной: запись до 30 минут — 2,9 мин, 30–60 минут — 4,1 мин, 1–2 часа — 5,6 мин, дольше 2 часов — 8,6 мин. Медиана — 10% от длины записи. Если считать от планового конца встречи, получится медиана 16 минут, но это потому, что встречи регулярно идут дольше плана 🙂
-
Допуск из лобби: медиана +135 с от планового старта до переноса спавна и +64 с после. От запуска контейнера до допуска медиана 50 с.
-
Доставка: материалы получены у 96,1% записанных календарных встреч. 28 из 29 отказов — организатор без OneDrive, из них 11 гостевых учёток. Письма по ad-hoc-встречам: 125 из 127. Пост в чат: 95,0% за всё время, 91% в сентябре (браузерный постер), 99% в октябре (delegated Graph).
Ко мне регулярно прибегают и говорят: “Ассистент не пришёл! Я его звал, пытался вызвонить.”… Вот я вроде пишу статьи и посты внутри компании, но по ходу люди перестали нормально читать, вчитываться и анализировать. Приходится в каждом таком случае отвечать заготовкой как всё же это работает, почему не надо вызванивать из встречи Ассистента Встреч, откуда взять join ссылку чтобы его ещё раз позвать и т.д. Причём в 99% случаев — кто-то просто не пустил бота, нажал не ту кнопку.
Канальные встречи
|
Месяц |
Записей |
Опубликовано |
Ошибок |
Зависло |
Команд |
|---|---|---|---|---|---|
|
Июнь (пилот с 22-го) |
113 |
33 |
9 |
71 |
10 |
|
Июль |
233 |
221 |
10 |
2 |
9 |
|
Август |
229 |
218 |
7 |
4 |
11 |
|
Сентябрь |
308 |
297 |
10 |
1 |
16 |
|
Октябрь |
339 |
318 |
2 |
1 |
18 |
|
Всего |
1121 |
1004 |
39 |
79 |
18 всего |
-
71 «зависшая» запись июня — пилот до фикса доставки RSC: у них нет адреса треда. С 1 июля опубликовано 96,3%.
-
Из 39 ошибок 31 — «у записи нет файла», то есть запись не сохранилась на стороне Teams, 5 таймаут саммари, 3 недоступность сервиса распознавания.
-
Скорость: от появления записи до саммари в треде медиана 4 мин, p90 — 8 мин. Само распознавание — 3,5 и 5,9 мин.
-
Типы встреч: дейли 692, планирования 126, груминги 105, ретро 96, обзоры 22. Средняя длина саммари 6,3 тыс. символов, пустых нет. Имена из транскрипта Teams получили 419 записей.
-
Темп: 73-84 записей в неделю в 15-17 командах.
Вместе
Оба потока вместе дали ~2100 обработанных встреч за четыре месяца, из них ~800 часов записи только у Ассистента Встреч.
Сколько это стоит?
Мне тех лид клятвено заверяет: около 4,5 ₽ за час аудио, то есть примерно 7,5 копейки за минуту разговора.
Распознавание и модель для саммари живут на одном нашем сервере с двумя RTX 3090. Час работы сервера обходится примерно в 77 ₽:
|
Статья |
Как считали |
₽ в час |
|---|---|---|
|
Железо |
700 тыс. ₽ (сервер 500 тыс. + две 3090 по 100 тыс.) на 3 года, 26 280 часов |
26,6 |
|
Электричество |
вся стойка ~1,5 кВт × 12 ₽ за кВт·ч |
18 |
|
Люди |
25 часов поддержки в месяц по ставке 150 тыс. ₽ / 160 ч, размазанные на 730 часов работы сервера |
32 |
|
Итого |
|
~77 |
Один час аудио занимает 3,5 минуты сервера: 77 × 3,5 / 60 ≈ 4,5 ₽. Это цена при полной загрузке. Сервер у нас простаивает ночью и в выходные, так что фактическая стоимость часа выше. Зато каждая следующая встреча почти бесплатна, пока сервер не забит. Аренду помещения и налоги на зарплату не считали.
Связка с платформой цифровых сотрудников
Ради чего это затевалось. Платформа цифровых сотрудников (подробнее в статье коллеги) работает с хранилищем документов: материалы индексируются при попадании в него, и агенты, к папкам которых они привязаны, могут по ним искать.
Боты встреч сами в хранилище не пишут, и это правильное разделение ответственности. Бот кладёт материалы туда, где их и так ищут люди: в SharePoint команды и в OneDrive организатора. Дальше работают отдельные конвейеры-экстракторы. По расписанию они забирают транскрипты прошедших встреч и складывают их в папки хранилища вместе с карточкой встречи (тема, время, участники, статус записи) и живым расписанием на ближайшие дни.
Пара принципиальных решений:
-
Используем raw транскрипты, а не саммари. Саммари — это сжатие под конкретный вопрос «о чём договорились». Агенту нужен сырой материал: по полному транскрипту он ответит и на вопрос, который никто не задал в саммари. Аудио, видео и субтитры тоже не синкаем. Плюс у агентов режим глубоких размышлений гораздо выше и надёжнее, чем просто у локальной модели сделать по-быстрому саммари.
-
Покрытие точечное, а не «все встречи компании». Встречи попадают в хранилище у сотрудников, у которых настроен персональный ассистент, и по отдельным клиентским сериям, которые нужны агентам руководителей проектов. Каждое подключение — с согласия человека. Встречи с пометкой «частная» не выгружаются вообще. Но у меня уже стоит задача на будущее — выгружать вообще всё и вся.
-
Права наследуются папкой. Единица привязки к агенту — папка хранилища, поэтому раскладка по папкам и есть модель доступа.
Получился замкнутый круг. Человек зовёт Ассистента Встреч через бота в Тимсе, который по факту агент платформы цифровых сотрудников, который зовёт другого бота, который спавнит анонимного гостя, который записывает, распознаёт и кладёт материалы. Экстрактор доносит транскрипт до хранилища. Через несколько часов тот же агент отвечает на «что мы решили на той встрече» и «кто взял задачу».
Ограничения
-
Нужно впустить из лобби. Это осознанный opt-out, но для пользователей он выглядит как «бот не пришёл». Около половины неудачных заходов именно это.
-
Приватные каналы Teams не поддерживаются. Ограничение Teams.
-
Чат у анонима часто закрыт политикой встреч (и всегда в канальных встречах). Поэтому команды в чате во время встречи («/саммари», «/задачи») работают не везде.
-
Имена говорящих упираются в диаризацию. Если два человека попали в один кластер, разделить их нельзя.
-
Гостевые организаторы (учётки гостей в нашем тенанте) не получают материалы: у них нет OneDrive. Просим создавать встречу от имени сотрудника.
-
Зависимость от вёрстки чужих веб-клиентов. Teams и Телемост меняют DOM без предупреждения. Нас спасают стабильные
data-tid/data-testid, двойные маркеры и скриншоты каждого сбоя.
Что бы я сделал иначе
-
Не связывался с MS вообще 🙂 я всю жизнь проработал под ubuntu, а в этой компании мне сказали: “мы майкрософт пипл”. Вот теперь и огребаем…
-
Сразу выбрал бы серверный критерий доставки. Борьба с ложными позитивами браузерного постера закончилась в тот момент, когда арбитром стал Graph.
-
Начал бы с Postgres, а не с JSON-файлов. Транзакционный claim дешевле любой самодельной блокировки.
-
Мерил бы, а не прикидывал. «Упираемся в память» оказалось неправдой, «отключить распознавание в реальном времени ради экономии» — тоже (экономить было 1%). Один
docker statsпо 10 секунд на живой записи сэкономил бы много обсуждений. -
Сразу писал бы скриншот на каждый сбой. Половина багов браузерного бота диагностируется одним взглядом на картинку и не диагностируется никак по логам.
Итого
Записывать встречи технически несложно. Сложно сделать это так, чтобы сотруднику потом не пришлось ничего делать, а результат приходил туда, где его ждут. Лобби, чаты регулярных встреч, гостевые организаторы и session-куки — это и есть основная работа. Распознавание по сравнению с ними просто решённая задача.
ссылка на оригинал статьи https://habr.com/ru/articles/1104684/