Агент для прямої транскрипції та підсумку учасників, які приєдналися пізніше, з агентами LiveKit
Створіть Python-агента, який зберігає транскрипцію дзвінка, транслює прямі субтитри у браузер та приватно підсумовує зустріч для тих, хто приєднується пізно.
Джерела: Building an AI agent for transcribing and summarizing audio calls
Вже існує багато агентів та сервісів штучного інтелекту, тож ось щось просте та конкретне: агент, який допомагає людям під час аудіодзвінка. Він виконує три завдання. По-перше, він зберігає повний текст запису зустрічі у текстовому файлі. По-друге, він надсилає субтитри у режимі реального часу всім учасникам дзвінка. А коли хтось приєднується пізно, він надсилає цій особі приватний огляд того, що вона пропустила.
Для реалізації використовується локальний медіасервер, сумісний з LiveKit, та фреймворк LiveKit Agents для Python агента. Разом вони обробляють складні аспекти передачі аудіо в реальному часі, що залишає лише розробку власних функцій агента як єдине завдання для написання коду.
Компоненти та потік даних
Існує три основні частини:
- Аудіокімната, де зустрічаються люди та бот, яка розміщується на локальному сервері, тож реєстрація не потрібна.
Дані переміщуються між ними таким чином:
Запуск демо-версії
Вимоги: Python 3.10 або новіша версія, а для створення резюме — обліковий запис у OpenAI або AWS. Демо-версія працює без облікового запису, якщо параметр LLM_PROVIDER залишається порожнім: субтитри та збережена транскрипція все одно функціонують, а втрачаються лише резюме подій.
Почніть з клонування репозиторію:
git clone https://github.com/openvidu-labs/transcriber-summarizer-agent.git
cd transcriber-summarizer-agent
Потім створіть віртуальне середовище та встановіть залежності:
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
Далі відредагуйте файл .env, щоб вибрати постачальника транскрипції (STT_PROVIDER) та постачальника стиснення (LLM_PROVIDER):
# STT_PROVIDER: openai | aws | vosk (offline)
# LLM_PROVIDER: openai | aws | (empty for no summarization)
STT_PROVIDER=
LLM_PROVIDER=
# Required when "openai" is selected for STT_PROVIDER or LLM_PROVIDER
OPENAI_API_KEY=
# Required when "aws" is selected for STT_PROVIDER or LLM_PROVIDER
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
Запустіть три компоненти у окремих терміналах. Перший — це локальний медіа-сервер, встановлений згідно з інструкціями щодо самостійного хостингу. Другий — це агент для транскрипції:
python main.py dev
Третій — це веб-додаток:
python app/server.py
Відкрийте http://localhost:8080, введіть назву та приєднайтесь, потім промовте щось на мить — і почне формуватися транскрипт. Завантажте сторінку в іншій вкладці під іншою назвою, і незабаром з’явиться жовта панель із узагальненням того, що було сказано у першій вкладці. Повний журнал із часовими позначками зберігається у директорії transcripts/.
У AWS для використання Amazon Transcribe та Amazon Bedrock креденціалам потрібні відповідні дозволи, наприклад через цю політику:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TranscriberSummarizer",
"Effect": "Allow",
"Action": [
"transcribe:StartStreamTranscription",
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream"
],
"Resource": "*"
}
]
}
Точка входу агента
Агент LiveKit — це функція, яка виконується один раз для кожної кімнати. Вона реєструється в AgentServer, а CLI керує рештою процесу:
from livekit.agents import AgentServer, JobContext, cli
server = AgentServer()
@server.rtc_session()
async def entrypoint(ctx: JobContext):
await ctx.connect()
room = ctx.room
if __name__ == "__main__":
cli.run_app(server)
Зверніть увагу, що @server.rtc_session() не має параметра agent_name. Вказання імені агента змушує LiveKit чекати на прямий запит на передачу даних. Без імені агент автоматично приєднується до кожної нової кімнати, що підходить для транскрибувальника.
Перетворення мовлення на текст за лаштунками
Щоб дозволити зміну постачальників, вибір ховається за однією невеликою функцією. Кожен плагін LiveKit для перетворення мовлення на текст, чи то Vosk, OpenAI чи AWS, пропонує один і той самий інтерфейс .stream(), тому саме тут у агенті вказується ім’я постачальника:
def make_stt():
if STT_PROVIDER == "vosk":
from livekit.plugins import vosk
return vosk.STT(model_path=VOSK_MODEL_PATH, language="en-US", partial_results=False)
if STT_PROVIDER == "openai":
from livekit.plugins import openai
return openai.STT(model="gpt-4o-mini-transcribe")
if STT_PROVIDER == "aws":
from livekit.plugins import aws
return aws.STT()
raise ValueError(f"Unknown STT_PROVIDER {STT_PROVIDER!r}")
Імпорти знаходяться всередині кожної гілки, тому той, хто обирає AWS, встановлює лише livekit-plugins-aws, оскільки інші гілки ніколи не виконуються. Для Vosk параметр partial_results=False видаляє тимчасові припущення щодо середини слів та зберігає лише завершені речення.
Один розпізнавач використовується всіма спікерами, а обробник запускається після підписки на аудіодоріжку:
speech_to_text = make_stt() # one engine, shared by every speaker
@room.on("track_subscribed")
def _on_track_subscribed(track, publication, participant):
if track.kind == rtc.TrackKind.KIND_AUDIO:
asyncio.create_task(transcribe_track(participant, track))
Кожен спікер отримує власний аудіо- та STT-стрім, причому два завдання виконуються одночасно за допомогою asyncio.gather: надсилання кадрів та зчитування транскрипцій.
async def transcribe_track(participant, track):
audio = rtc.AudioStream(track, sample_rate=16000, num_channels=1)
async with speech_to_text.stream() as stt_stream:
async def feed_audio():
async for event in audio:
stt_stream.push_frame(event.frame)
stt_stream.end_input() # no more audio: let the recognizer finish
async def emit_transcripts():
async for event in stt_stream:
if event.type == stt_api.SpeechEventType.FINAL_TRANSCRIPT and event.alternatives:
text = event.alternatives[0].text.strip()
if text:
await record_line(participant, track, text)
await asyncio.gather(feed_audio(), emit_transcripts())
feed_audio надсилає кадри до розпізнавача, тоді як emit_transcripts витягує завершені речення, а блок async with займається очищенням. Код реагує лише на події FINAL_TRANSCRIPT, якими є цілі речення, а не кожна тимчасова припущення. Усе, що він отримує, потрапляє до record_line, який зберігає цей текст на диску, надсилає його до браузера та зберігає для подальших узагальнень.
Запис кожної рядка
record_line робить завершену фразу постійною. Він додає її до файлу на диску, зберігає копію в пам’яті для узагальнень та оприлюднює текст у браузері:
conversation = [] # in-memory history, used for the summary
async def record_line(participant, track, text):
speaker = participant.name or participant.identity
timestamp = datetime.datetime.now().strftime("%H:%M:%S")
conversation.append(f"{speaker}: {text}")
with open(transcript_path, "a", encoding="utf-8") as f:
f.write(f"[{timestamp}] {speaker}: {text}\n")
# Publish on LiveKit's built-in transcription channel, attributed to the speaker.
writer = await room.local_participant.stream_text(
topic=TOPIC_TRANSCRIPTION, # "lk.transcription"
sender_identity=participant.identity,
attributes={
ATTRIBUTE_TRANSCRIPTION_FINAL: "true",
ATTRIBUTE_TRANSCRIPTION_TRACK_ID: track.sid,
ATTRIBUTE_TRANSCRIPTION_SEGMENT_ID: utils.shortuuid("SG_"),
},
)
await writer.write(text)
await writer.aclose()
Отриманий файл виглядає як звичайний протокол зустрічі:
[14:02:11] Alice: should we ship the release today
[14:02:15] Bob: yes but let us wait for the tests to pass
[14:02:20] Alice: agreed lets do it after lunch
Для кроку публікації не потрібен власний канал. Вбудована функція транскрипції LiveKit — спеціальна тема lk.transcription — виконує цю роботу. Якщо sender_identity встановлений на спікера, будь-який клієнт LiveKit отримує текст у вигляді звичайного, правильно позначеного фрагмента транскрипції.
Підсумки для тих, хто запізнився
Це найцікавіша функція. Коли хтось приєднується до вже триваючого дзвінка, оператор підбирає короткий опис того, що вони пропустили, і надсилає його лише їм. LiveKit генерує сигнал participant_connected після кожного приєднання:
@room.on("participant_connected")
def _on_participant_connected(participant):
asyncio.create_task(summarize_for(participant))
async def summarize_for(participant):
await asyncio.sleep(2) # let the newcomer's browser get ready
if not conversation:
return # nothing said yet, nothing to summarize
summary = await summarize(conversation)
await room.local_participant.send_text(
summary,
topic="summary",
destination_identities=[participant.identity],
)
LLM також знаходиться за сервером. Кожен LiveKit LLM надає один і той самий метод .chat(), тому функція summarize() ніколи не робить розгалужень:
def make_llm():
model = os.getenv("SUMMARY_MODEL") # optional override; default per provider
if LLM_PROVIDER == "openai":
from livekit.plugins import openai
return openai.LLM(model=model or "gpt-4.1")
if LLM_PROVIDER == "aws":
from livekit.plugins import aws
return aws.LLM(model=model or "us.amazon.nova-2-lite-v1:0")
raise ValueError(f"Unknown LLM_PROVIDER {LLM_PROVIDER!r}")
async def summarize(conversation):
ctx = llm.ChatContext.empty()
ctx.add_message(role="system", content=SUMMARY_PROMPT)
ctx.add_message(role="user", content="Transcript so far:\n" + "\n".join(conversation))
chunks = [c async for c in make_llm().chat(chat_ctx=ctx).to_str_iterable()]
return "".join(chunks).strip()
SUMMARY_PROMPT — це один простий та дружній рядок, у якому проситься короткий огляд ситуації у трьох–чотирьох реченнях. Функція .to_str_iterable() перетворює потокову відповідь на фрагменти тексту, які потім об’єднуються; цей процес однаковий для всіх постачальників та не вимагає використання спеціального SDK кожного з них.
Два аспекти допомагають забезпечити чітку передачу інформації. По-перше, очікування двох секунд запобігає конфліктам: функція participant_connected може бути викликана трохи раніше, ніж браузер нової особи налаштує свої слухачі. По-друге, використання destination_identities для адресування повідомлення гарантує, що його отримає лише одна людина, тож усі інші, хто вже спілкується, залишаються недоторканими.
Оскільки обидва плагіни походять від LiveKit, один постачальник надає функції розпізнавання мовлення та LLM у одному пакеті з одним набором облікових даних. За допомогою openai один OPENAI_API_KEY обробляє як транскрипцію, так і стиснення тексту. За допомогою aws одна пара ключів IAM підтримує як функцію Transcribe, так і Bedrock.
Також можлива безплатна налаштування в офлайн-режимі: використовуйте vosk для розпізнавання мовлення та залиште поле LLM_PROVIDER порожнім. У цьому випадку транскрипція генерується локально, а функція стиснення тексту вимикається та замінюється повною транскрипцією.
Бокова частина браузера
Фронтенд навмисно простий: один файл HTML, браузерний SDK LiveKit з CDN та деякий JavaScript, без жодних фреймворків чи інструментів для об’єднання коду.
Браузери не можуть створювати власні токени доступу, оскільки це призведе до викриття секрету API перед кожним відвідувачем. Крихітний сервер токенів, створений на основі стандартної бібліотеки Python та livekit-api, заповнює цю прогалину:
from livekit.api import AccessToken, VideoGrants
token = (
AccessToken(API_KEY, API_SECRET)
.with_identity(identity)
.with_name(name)
.with_grants(VideoGrants(room_join=True, room=room))
.to_jwt()
)
На цій сторінці для підключення потрібно здійснити три дзвінки: отримати токен, під’єднатися та увімкнути мікрофон:
const { token, url } = await (await fetch(`/token?room=${room}&identity=${id}&name=${name}`)).json();
const room = new LivekitClient.Room();
await room.connect(url, token);
await room.localParticipant.setMicrophoneEnabled(true);
Відображення транскрипції означає підписку на зарезервований LiveKit текстовий потік lk.transcription. Обробник отримує об’єкт для читання та інформацію про відправника, яка містить ім’я спікера, а атрибут lk.transcription_final дозволяє йому ігнорувати текст, який ще не завершений:
room.registerTextStreamHandler("lk.transcription", async (reader, participantInfo) => {
if (reader.info.attributes?.["lk.transcription_final"] !== "true") return;
const text = await reader.readAll();
addLine(nameFor(participantInfo?.identity), text, new Date().toLocaleTimeString());
});
Оскільки це стандартний канал, той самий обробник працює в будь-якому клієнті LiveKit. Підсумок подій надсилається через спеціальну тему summary, і коли він надходить, сторінка розміщує цей підсумок у виділеній рамці. Це і є весь клієнт.
Що у результаті отримуємо
Готовий агент може обробляти все від двостороннього чату до повноцінних зустрічей. Він записує чистий текст розмови на диск, відображає субтитри в реальному часі та надає тим, хто запізнився, підсумок подій — усе це у приблизно двохстах рядках. Його можна запустити за допомогою Vosk, OpenAI чи AWS, а одне значення файлу .env дозволяє перемикатися між ними, оскільки обидві частини реалізовані за допомогою функцій make_stt() та make_llm(). Очевидними наступними кроками є зберігання тексту розмов, переклад у реальному часі чи перетворення підсумків на конкретні дії.
Він залишається простим, тому що складні компоненти вже реалізовані: транспортування аудіо в реальному часі, підписки на треки та шари комунікації з постачальниками ШІ. LiveKit Agents та його плагіни забезпечують необхідні елементи, тож код зосереджується на логіці агента, а не на технічних деталях.
Для продакшну самостійно розгорнута платформа, побудована на тому ж ядрі, сумісному з LiveKit, дозволяє агенту працювати без змін, тоді як платформа займається автомасштабуванням, високою доступністю, передаванням даних через TURN у обмежених мережах, керуванням записами та забезпеченням можливостей моніторингу — усе це в межах вашої власної інфраструктури з конфіденційністю даних та витратами, які не залежать від тарифікації SaaS за хвилину.