📱 Подписаться
IT и цифровая трансформация

Как заставить LLM выбирать осмысленные фрагменты из часовой расшифровки: почему «найди интересные моменты» не работает

📰 Habr 👁️ 0 просмотров

ShortyAiBotTg4 часа назад

Как заставить LLM выбирать осмысленные фрагменты из часовой расшифровки: почему «найди интересные моменты» не работает

Уровень сложностиПростойВремя на прочтение6 минОхват и читатели4.9KИскусственный интеллектРабота с видео*КейсПредставьте: у вас есть транскрипт выступления на 40-60 минут – полотно из нескольких тысяч слов с таймкодами. И для продвижения материала через Reels, Shorts или, упаси господь, ВК Клипы, нужно достать из него +-6 самодостаточных фрагментов: законченная мысль, не оборванная на полуслове, которую можно показать вне контекста. Изначальная мысль закинуть в LLM промпт и забыть развалилась. Расскажу, какие грабли я собрал и какая конструкция в итоге заработала стабильно.

Привет, Хабр! Меня зовут Андрей, и я потихоньку развиваю своего телеграм-бота для нарезки вертикальных видео по имени Шорти.

Постановка задачи

Формально на входе:

{
"duration": 2412.3,
"words": [{"word": "сегодня", "start": 0.12, "end": 0.43}, ...],
"segments": [{"start": 0.0, "end": 5.2, "text": "..."}, ...]
}На выходе нужно N диапазонов (start, end) в секундах, каждый из которых:

• начинается с начала предложения и заканчивается на конце предложения (никаких «…и поэтому» в начале);
• содержит одну законченную мысль — историю, тезис, вывод, шутку;
• укладывается в 15-75 секунд (формат вертикального ролика).Ключевая трудность: модель «видит» текст, но режет по символам/смыслу так, как удобно ей, а нам нужны границы, выровненные по реальной речи и таймингам.

Наивный подход и четыре способа, которыми он ломается

Первое, что приходит в голову:

«Вот транскрипт с таймкодами. Найди 6 самых интересных моментов и верни их время начала и конца.»Это не работает. А именно происходит:

• Старт с середины фразы. Модель возвращает start, попадающий внутрь предложения: «…а вот это уже меняет всё». Зритель не понимает, о чём речь.
• Старт со связки. Грамматически это «начало предложения», но смыслово — мусор: «Но если посмотреть глубже…», «Поэтому я и говорю…». Формально корректно, на деле — оборванный контекст.
• Таймкоды «из головы». Если просить модель назвать секунды, она их галлюцинирует. Возвращает start: 734.0, а реального слова на 734-й секунде нет — там середина паузы или чужая фраза. Модель не считает время, она его придумывает.
• Нестабильный формат. На длинном входе модель то возвращает 6 фрагментов, то 1; то валидный JSON, то JSON с комментарием сверху, то с оборванной скобкой. Один и тот же промпт на одном и том же входе ведёт себя по-разному от запроса к запросу.Каждую из четырёх проблем пришлось закрывать отдельно.

Идея, которая всё развернула: единица — предложение, а не слово и не секунда

Главная ошибка наивного подхода — давать модели свободу резать где угодно. Решение: сузить пространство выбора до предложений. Модель не называет секунды и не режет по словам — она выбирает диапазон номеров предложений.

Сначала склеиваем слова/сегменты Whisper обратно в предложения и нумеруем их:

def build_sentences(words: list[dict]) -> list[dict]:
"""Склеивает слова в предложения, сохраняя тайминги границ."""
sentences, cur = [], []
for w in words:
cur.append(w)
if w["word"].endswith((".", "!", "?", "…")):
sentences.append({
"id": len(sentences),
"text": " ".join(x["word"] for x in cur),
"start": cur[0]["start"],
"end": cur[-1]["end"],
})
cur = []
if cur: # хвост без финальной пунктуации
sentences.append({"id": len(sentences),
"text": " ".join(x["word"] for x in cur),
"start": cur[0]["start"], "end": cur[-1]["end"]})
return sentencesТеперь модель видит пронумерованный список:

[0] Сегодня я хочу поговорить про найм.
[1] Когда мы выросли с пяти до пятидесяти человек, всё сломалось.
[2] Оказалось, что процесс, который работал на маленькой команде, не масштабируется.
...И возвращает не секунды, а индексы:

{"highlights": [{"from": 1, "to": 4, "score": 0.9}, {"from": 12, "to": 15, "score": 0.8}]}Что это сразу чинит:

• галлюцинации таймкодов исчезают как класс — время мы берём не у модели, а из своих же предложений: start = sentences[from].start, end = sentences[to].end;
• границы всегда по предложениям — невозможно начать с середины фразы, потому что выбор — это целые предложения.def ranges_to_items(sentences, ranges, min_len=15, max_len=125):
items = []
for r in ranges:
s, e = sentences[r["from"]], sentences[r["to"]]
dur = e["end"] - s["start"]
if min_len <= dur <= max_len:
items.append({"start": s["start"], "end": e["end"],
"score": r.get("score", 0)})
return itemsПромпт, который заработал

Перевод единицы в «предложения» убрал проблемы 1 и 3. Проблему 2 (старт со связок) и качество выбора закрыл промпт — жёсткий, с явными критериями и явными запретами:

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

Верни 6 фрагментов как диапазоны предложений [from, to]. Каждый фрагмент:
— ЗАКОНЧЕННАЯ мысль: история, тезис с объяснением, вывод, яркий пример или шутка;
— НАЧИНАЕТСЯ с предложения, которое можно понять без предыдущего контекста;
— НЕ начинается со связок: «но», «поэтому», «и», «а», «то есть», «таким образом»;
— длиной примерно 15–120 секунд связной речи.

Для каждого фрагмента дай score 0..1 — насколько он сильный вне контекста.
Ответ — строго JSON: {"highlights": [{"from": int, "to": int, "score": float}]}Два неочевидных момента, которые сильно подняли качество:

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

Подстраховка: доснэппинг границ после ответа

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

WEAK_STARTS = ("но", "а", "и", "поэтому", "то есть", "таким образом", "значит")

def snap_start(sentences, idx):
"""Если предложение стартует со связки — двигаем к следующему сильному началу."""
while idx < len(sentences):
first = sentences[idx]["text"].lstrip().split(" ", 1)[0].lower().strip(",")
if first not in WEAK_STARTS:
return idx
idx += 1
return idxМодель предлагает, детерминированный код подчищает. Этот «ремень безопасности» поверх вероятностного выбора окупился больше всего: качество перестало плясать от запроса к запросу.

Самое раздражающее: надёжность

Проблема 4 (нестабильность) оказалась самой живучей. Что помогло:

Over-request + топ по score. Просишь N, а просишь N+2. Модель на длинном входе любит вернуть меньше, чем просили; запас + отбор топа по score гарантирует, что выдашь ровно N приличных, а не «что осталось».

Ретраи на кривой JSON и 5xx. Модель периодически возвращает JSON с префиксом-болтовнёй или обрывает скобку, плюс прилетают 503. Простой ретрай с парсингом «вытащи первый валидный JSON-объект» добивает в 2–3 попытки:

import json, re

def parse_highlights(raw: str) -> list[dict] | None:
m = re.search(r"\{.*\}", raw, re.S) # вырезаем JSON из возможной болтовни
if not m:
return None
try:
return json.loads(m.group(0))["highlights"]
except (json.JSONDecodeError, KeyError):
return None

def get_highlights(call_llm, prompt, attempts=3):
for _ in range(attempts):
raw = call_llm(prompt) # бросает на 5xx — ловим выше
parsed = parse_highlights(raw)
if parsed:
return parsed
return None # уходим в эвристический фолбэкЛюбопытное наблюдение: первый запрос нередко возвращает 1 фрагмент, а ретрай с тем же промптом — нормальные 6. Дешевле сделать второй запрос, чем вылизывать промпт до идеала.

Фолбэк без LLM

Модель может быть недоступна (нет ключа, лимиты, ночной 503). Чтобы пайплайн не падал, есть эвристика без LLM: берём предложения, скорим по простым признакам (длина, наличие цифр/имён/вопросов, плотность речи без длинных пауз), снэппим границы тем же кодом и отдаём топ. Качество ниже, но продукт всегда что-то выдаёт — это важнее, чем «иногда идеально, иногда никак».

Что в итоге

Рабочая конструкция собралась из пяти слоёв, и ни один по отдельности задачу не решает:

• Предложение как единица выбора — убивает галлюцинации таймкодов и обрывы фраз.
• Промпт с явными критериями и запретами + score — поднимает качество выбора.
• Детерминированный доснэппинг границ — снимает дрожание от запроса к запросу.
• Over-request + ретраи + вырезание JSON — закрывает нестабильность.
• Эвристический фолбэк — гарантирует, что выход есть всегда.Главный вывод, который я унёс: не давайте LLM резать где угодно — сужайте пространство выбора и подчищайте результат детерминированным кодом. Модель хороша как «оценщик смысла», но границы, тайминги и формат надёжнее держать на своей стороне.

Всё это крутится у меня внутри Telegram-бота, который нарезает записи выступлений на вертикальные ролики со слайдами — если интересно посмотреть на результат этой механики вживую, он тут (первая нарезка бесплатная, на ней и видно, как отрабатывает выбор фрагментов).

Буду рад, если поделитесь в комментариях, как сами решаете похожую задачу извлечения span'ов из длинных текстов — особенно как боретесь с нестабильностью формата.Теги:• ai
• ml
• ии
• ии-агенты
• ии чат-бот
• монтаж видео
• нарезка видео
• рилсы
• шортсы
• тиктокХабы:• Искусственный интеллект
• Работа с видео

Получайте больше инсайтов о систематизации бизнеса

Подписывайтесь на Telegram-канал Business Operations — ежедневные материалы о бизнес-процессах, операционном управлении и повышении эффективности

💬 Подписаться на канал