Files
vidconf/backend/core/summarization/chunking.py

157 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Чанкинг транскрипта для map-стадии суммаризации (ТЗ §1.3).
Транскрипт — строка с одной фразой на строку в формате `[Имя MM:SS] текст`
(или `[Имя ЧЧ:MM:SS] текст` от часа, см. `workers/summarizer/transcript.py`).
Каждая строка уже атомарна и соответствует одной реконструированной фразе
(`build_phrases`) — граница фразы там уже равна смене спикера,
поэтому `chunk_transcript` закрывает чанк исключительно на границах строк и
никогда не режет фразу пополам (кроме аварийного случая монолога, см. ниже).
Правила закрытия чанка (проверяются перед добавлением очередной фразы):
- добавление фразы сделало бы охваченный чанком промежуток времени
(от начала чанка до начала этой фразы) >= `target_chunk_minutes`;
- ЛИБО добавление фразы превысило бы `max_chunk_tokens` для чанка.
В любом из этих случаев уже накопленный чанк закрывается, а фраза уходит в
новый чанк.
Аварийный случай — монолог: единственная фраза сама по себе превышает
`max_chunk_tokens` (например, 40-минутный монолог одного участника). Такую
фразу нельзя оставить целой и нельзя просто выбросить — она делится по
границам предложений на несколько частей меньше лимита, каждая из которых
получает повторённую исходную метку `[Имя MM:SS]`, и добавляется в список
чанков как самостоятельный чанк.
"""
import re
from collections.abc import Callable
_LABEL_RE = re.compile(r"^\[(?P<speaker>.+) (?P<time>\d{1,3}:\d{2}(?::\d{2})?)\] (?P<text>.*)$")
"""Метка фразы: `[Имя MM:SS]` или `[Имя ЧЧ:MM:SS]`; имя может содержать пробелы."""
_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+")
"""Граница предложения — пробел после `.`, `!` или `?`."""
def _parse_offset_seconds(time_str: str) -> float:
"""Разобрать метку времени `MM:SS` или `ЧЧ:MM:SS` в секунды от начала сеанса."""
parts = [int(part) for part in time_str.split(":")]
if len(parts) == 2:
minutes, seconds = parts
return float(minutes * 60 + seconds)
hours, minutes, seconds = parts
return float(hours * 3600 + minutes * 60 + seconds)
def _line_offset(line: str) -> float:
"""Извлечь смещение начала фразы (в секундах) из метки строки.
Строка без распознаваемой метки считается стоящей в начале сеанса
(0.0) — чанкер не должен падать на нестандартном входе, только терять
точность деления по времени.
"""
match = _LABEL_RE.match(line)
if match is None:
return 0.0
return _parse_offset_seconds(match.group("time"))
def _split_sentences(text: str) -> list[str]:
"""Разбить текст фразы на предложения; пустой текст даёт список из одного элемента."""
sentences = [s.strip() for s in _SENTENCE_SPLIT_RE.split(text) if s.strip()]
return sentences or [text]
def _split_monologue(
line: str,
count_tokens: Callable[[str], int],
max_chunk_tokens: int,
) -> list[str]:
"""Аварийно разделить фразу-монолог, превышающую лимит, по предложениям.
Метка спикера повторяется в каждой части. Если строка не распознана как
фраза с меткой (не должно случаться при штатном входе из
`build_transcript`), делить не на чем — возвращаем строку одним чанком,
даже с превышением лимита.
"""
match = _LABEL_RE.match(line)
if match is None:
return [line]
label = f"[{match.group('speaker')} {match.group('time')}]"
sentences = _split_sentences(match.group("text"))
pieces: list[str] = []
current: list[str] = []
for sentence in sentences:
candidate = f"{label} {' '.join([*current, sentence])}"
if current and count_tokens(candidate) > max_chunk_tokens:
pieces.append(f"{label} {' '.join(current)}")
current = [sentence]
else:
current.append(sentence)
if current:
pieces.append(f"{label} {' '.join(current)}")
return pieces
def chunk_transcript(
transcript: str,
count_tokens: Callable[[str], int],
*,
max_chunk_tokens: int = 8000,
target_chunk_minutes: int = 20,
) -> list[str]:
"""Разбить транскрипт на чанки для map-стадии суммаризации.
Аргументы:
transcript: строки-фразы `[Имя MM:SS] текст`, по одной на строку.
count_tokens: подсчёт токенов для строки/чанка (в проде —
`QwenTokenCounter`, в тестах — фейковый callable).
max_chunk_tokens: верхняя граница токенов на чанк (ТЗ: 38k).
target_chunk_minutes: целевая длительность чанка в минутах записи.
Возвращает список строк-чанков (без изменения содержимого фраз внутри),
пустой список для пустого транскрипта.
"""
lines = [line for line in transcript.splitlines() if line.strip()]
if not lines:
return []
target_seconds = target_chunk_minutes * 60
chunks: list[str] = []
current_lines: list[str] = []
current_tokens = 0
chunk_start_offset = 0.0
def flush() -> None:
nonlocal current_lines, current_tokens
if current_lines:
chunks.append("\n".join(current_lines))
current_lines = []
current_tokens = 0
for line in lines:
line_tokens = count_tokens(line)
if line_tokens > max_chunk_tokens:
# монолог одной фразой больше лимита — аварийное деление по предложениям
flush()
chunks.extend(_split_monologue(line, count_tokens, max_chunk_tokens))
continue
if current_lines:
offset = _line_offset(line)
prospective_span = offset - chunk_start_offset
prospective_tokens = current_tokens + line_tokens
if prospective_tokens > max_chunk_tokens or prospective_span >= target_seconds:
flush()
if not current_lines:
chunk_start_offset = _line_offset(line)
current_lines.append(line)
current_tokens += line_tokens
flush()
return chunks