- BrainTools - https://www.braintools.ru -
Представьте: у вас есть транскрипт выступления на 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 с комментарием сверху, то с оборванной скобкой. Один и тот же промпт на одном и том же входе ведёт себя по-разному от запроса к запросу.
Каждую из четырёх проблем пришлось закрывать отдельно.
Главная ошибка [1] наивного подхода — давать модели свободу резать где угодно. Решение: сузить пространство выбора до предложений. Модель не называет секунды и не режет по словам — она выбирает диапазон номеров предложений.
Сначала склеиваем слова/сегменты 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. Дешевле сделать второй запрос, чем вылизывать промпт до идеала.
Модель может быть недоступна (нет ключа, лимиты, ночной 503). Чтобы пайплайн не падал, есть эвристика без LLM: берём предложения, скорим по простым признакам (длина, наличие цифр/имён/вопросов, плотность речи без длинных пауз), снэппим границы тем же кодом и отдаём топ. Качество ниже, но продукт всегда что-то выдаёт — это важнее, чем «иногда идеально, иногда никак».
Рабочая конструкция собралась из пяти слоёв, и ни один по отдельности задачу не решает:
Предложение как единица выбора — убивает галлюцинации таймкодов и обрывы фраз.
Промпт с явными критериями и запретами + score — поднимает качество выбора.
Детерминированный доснэппинг границ — снимает дрожание от запроса к запросу.
Over-request + ретраи + вырезание JSON — закрывает нестабильность.
Эвристический фолбэк — гарантирует, что выход есть всегда.
Главный вывод, который я унёс: не давайте LLM резать где угодно — сужайте пространство выбора и подчищайте результат детерминированным кодом. Модель хороша как «оценщик смысла», но границы, тайминги и формат надёжнее держать на своей стороне.
Всё это крутится у меня внутри Telegram-бота, который нарезает записи выступлений на вертикальные ролики со слайдами — если интересно посмотреть на результат этой механики вживую, он тут [2] (первая нарезка бесплатная, на ней и видно, как отрабатывает выбор фрагментов).
Буду рад, если поделитесь в комментариях, как сами решаете похожую задачу извлечения span’ов из длинных текстов — особенно как боретесь с нестабильностью формата.
Автор: ShortyAiBotTg
Источник [3]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/32327
URLs in this post:
[1] ошибка: http://www.braintools.ru/article/4192
[2] он тут: https://t.me/reels_akimov_bot
[3] Источник: https://habr.com/ru/articles/1052696/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1052696
Нажмите здесь для печати.