Как я сделал бота, который сам приходит на встречи в Teams / Яндекс Телемост / МТС Линк, записывает разговор и через несколько минут отдаёт расшифровку и саммари с договорённостями. Почему не Copilot и не штатная расшифровка, почему два разных потока вместо одного, какие грабли собрал и что показали четыре месяца в проде.
Так как в компании мы используем MS Teams пришлось строить два потока. Канальные встречи в Teams командах (дейли, планирования, ретро - т.е. в основном регулярные) обрабатывает бот, установленный в команду. Он ловит системное событие «запись готова», забирает видео из SharePoint и публикует саммари в тред встречи. Все остальные встречи (частные, клиентские, чужой тенант, Телемост, МТС) обслуживает «Ассистент Встреч»: его зовут в приглашение как обычного участника, и к началу встречи туда заходит анонимный Chrome в Docker-контейнере.
Распознавание, диаризацию и саммари делает внутренний сервис на своих серверах и локальной модели. Наружу аудио не уходит.
Цифры на 06.11.2026. Ассистент сделал ~1170 записей, это ~920 часов разговоров у 93 организаторов. Канальный поток опубликовал 1136 саммари в 19 командах. Медиана от конца записи до готовых материалов — 4,6 минуты.
Самые дорогие грабли. Календарный ящик у нас в on-prem Exchange, и Graph API его не видит. Сессия браузерного постера тихо умирала из-за session-куки. Детектор говорящего ловил четверть речи, пока мы не перешли от событий к опросу состояния.
Думаю как и в любой большой компании, у нас много встреч: внутренние дейли, клиентские синки, планирования, разборы. Договорённости на них проговариваются вслух и дальше живут в “записях на листочке” участников. Кто не был, тот не знает. Кто был - тот через неделю помнит 10%. Пробовали как-то вводить правило, чтобы хоть кто-то протокол вёл, ну вы поняли. Никто.
Первая боль - это договорённости встреч не доживают до следующей встречи, потому что их никто не записывает.
Вторая появилась позже и оказалась не менее важной. В компании зажила платформа цифровых сотрудников: агенты помогают инженерам поддержки, руководителям проектов, продажам. Коллега недавно подробно рассказала на Хабре, как мы внедряли цифровых сотрудников в поддержку. Главный вывод той статьи: самое сложное в агентах - данные. Агент знает только то, что записано и проиндексировано. А заметная часть знаний компании вообще нигде не записана. Её проговаривают на встречах. Короче, двигаем в сторону AI SDLC.
Т.е. вторая боль - агенты слепы ко всему, что решили голосом, и отвечают без учёта контекста.
Ассистент Встреч закрыл эту дыру с двух сторон:
Человек получает расшифровку, саммари и список «задача - ответственный - срок» автоматически примерно через 4.6 мин.
Транскрипты попадают в хранилище, которое индексирует платформа. Агенты (по клиенту, по продукту, личные) начинают отвечать и по прошедшим встречам: «что решили с клиентом X на прошлой неделе», «когда у меня следующий синк». Подробнее о связке в конце статьи. И, что немаловажно, возможно строить на этом проактивность агентов.
Первым делом я, разумеется, посмотрел на то, как это делает 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 | из подсветки говорящего в интерфейсе |
Ограничения | только каналы, где стоит бот; приватные каналы не работают | нужно впустить из лобби |
Потоки разделены физически: свои бот-регистрации, свои приложения, свои права. Принцип наименьших привилегий: если утечёт ключ одного потока, второй не пострадает.
Когда канальная встреча записывается, Teams постит в её тред служебную активность CallRecording и проводит её по статусам Initial → ChunkFinished → Success. В событии Success есть всё нужное: driveId и driveItemId файла записи на SharePoint, тема, callId, ссылка на транскрипт. Из самой активности берём conversation.id вида 19:…@thread.tacv2;messageid=<root>. По нему потом отвечаем в тот же тред.
Обычный бот видит в канале только сообщения со своим @mention. Служебная активность mention не содержит. Нужно RSC-разрешение ChannelMessage.Read.Group, тогда бот получает все сообщения канала, а RSC выдаёт владелец команды при его установке в команду/канал. Админского гранта на весь тенант нет, и это осознанное решение.
Зачем качать видео и распознавать заново? Я прошёл все пути к транскриптам Тимс, и почти все оказались тупиками:
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», набранное кириллицей, и «АВ» латиницей — разные строки, а люди пишут как попало.
Главная проблема сервисов распознавания - диаризация. Разметка говорящих как 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 не нужен: кто видел запись, тот видит и саммари.
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 тратит, что вытесняет ответ. Но это не точно.
Это основная и самая интересная часть.
Сначала я сделал бот-приложение, которое организатор добавляет во встречу через «+». Технически это работало и было проверено вживую, но отвергнуто по UX. Никто не будет искать приложение в каждой встрече, после её создания. А вот добавить участника - раз плюнуть.
1. Пригласить на встречу. Можно позвать «Ассистента Встреч» на встречу через указание его как участника встречи в Обязательных. Поэтому админы завели мне обычного пользователя «Ассистент Встреч» с лицензией Тимс. Не shared mailbox, а полноценная учётка. Организатор добавляет его в приглашение, как любого коллегу. Приглашение работает одновременно как согласие на запись и как носитель ссылки на встречу. Больше ничего делать не надо.
2. POST /join со ссылкой. Для встреч вне календаря, для интеграций, или для случая “ой, забыли!”: пришлось поднять API. На вход ссылка, тема и кто попросил. Этим API пользуются агенты платформы цифровых сотрудников. Пишешь боту в Teams «запиши встречу по ссылке», и он зовёт Ассистента.
3. Телемост. Тот же /join, платформа определяется по хосту ссылки. Для Телемоста обязательно указывать, кто попросил: у Телемоста нет понятия организатора, а email участников он не отдаёт никому, даже владельцу встречи.
4. МТС Линк. То же самое как и у Телемоста.
Почему 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. Идентификаторы встреч там не пишутся целиком, только хвост хэша.
Самые показательные истории разобраны выше. Остальное здесь:
Симптом | Корень | Что сделали |
|---|---|---|
Бот не нажимает Join, в статистике «не вошёл» | Teams блокирует кнопку для имени гостя со скобками и некоторыми другими символами | Переименовали бота, ошибку валидации ловим явно |
На одну встречу пришли два бота | Календарь даёт ссылку | Дедуп по ядру ссылки и по |
Вторая встреча в «постоянной комнате» не записалась | Дедуп считал предыдущую встречу по той же ссылке «ещё в работе» | Открыто |
Не пишет одно вхождение серии | Удалили Ассистента Встреч из серии, потом позвали в одно вхождение; EWS держит экземпляр отменённым | Редкий случай, объясняем пользователям |
У гостевого организатора ничего не доставилось | У организатора-гостя нет OneDrive, а чат встречи невидим | Не чиним кодом: просим создать встречу от сотрудника |
Материалы не легли в OneDrive у встречи в чужом тенанте | Организатор не резолвится | Лестница: организатор из ссылки → из чата встречи → тот, кто позвал |
Два мёртвых адреса лишили доступа 40 живых участников | Graph | Поштучный фолбэк, счётчик розданных в базе, догнали ~124 гранта |
Загрузка в OneDrive падает на длинной теме | Путь длиннее 400 символов | Обрезаем имя встречи до 90 символов |
Ad-hoc-встречам месяцами не выдавался доступ | У встреч из | Берём участников из чата встречи |
«Потолок записей» при свободной машине | Контейнер, не дождавшийся лобби, держал слот до конца окна | Считаем живые контейнеры, а не статусы |
Сервис распознавания недоступен — встреча в | 503 сжигали все три попытки за 3 минуты | Временные ошибки не тратят попытки, терпим до 24 часов |
Переотправка теряет имена говорящих | Сопоставление жило только в публикации | Готовое сообщение сохраняется, имена восстанавливаются из таймлайна |
Легли сразу запись по ссылке, канальный поток и служебный бот | Истёк вручную выпущенный wildcard-сертификат | Заменили, срок в календаре; ноль строк в access-log — признак обрыва TLS до nginx |
Служебный бот замолчал после чужого деплоя | Локейшены nginx вписали в живой конфиг руками, деплой перелил конфиг из репозитория | Конфиг только из репозитория |
Один контейнер - одна встреча. Внутри:
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, и раздельная запись - следующий шаг.
У МТС Линк примерно то же самое.
Я следил за нагрузкой 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-куки - это и есть основная работа. Распознавание по сравнению с ними просто решённая задача.