Первоначальная версия VidConf
This commit is contained in:
5
backend/core/summarization/__init__.py
Normal file
5
backend/core/summarization/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Пакет чистых функций суммаризации: чанкинг транскрипта и подсчёт токенов.
|
||||
|
||||
Плагин `QwenLocal` использует эти функции как строительные
|
||||
блоки map-reduce; сам пакет не знает про LLM и HTTP.
|
||||
"""
|
||||
156
backend/core/summarization/chunking.py
Normal file
156
backend/core/summarization/chunking.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Чанкинг транскрипта для 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: верхняя граница токенов на чанк (ТЗ: 3–8k).
|
||||
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
|
||||
155
backend/core/summarization/llm_client.py
Normal file
155
backend/core/summarization/llm_client.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""HTTP-клиент к OpenAI-совместимому LLM-серверу (llama.cpp server, ТЗ §3.3).
|
||||
|
||||
`llama.cpp server` (образ `ghcr.io/ggml-org/llama.cpp:server`) отдаёт
|
||||
`/v1/chat/completions` в формате OpenAI Chat Completions API: тело запроса
|
||||
`{"model": ..., "messages": [{"role": "user", "content": ...}]}`, ответ —
|
||||
`choices[0].message.content` (см. `tools/server/README.md` проекта llama.cpp).
|
||||
|
||||
Надёжность: retry с экспоненциальным backoff на
|
||||
сетевых ошибках и retryable HTTP-статусах (5xx, включая 503 — модель ещё
|
||||
грузится), плюс circuit breaker поверх retry — после `breaker_threshold`
|
||||
подряд неудачных вызовов `complete()` окно `breaker_cooldown_s` секунд все
|
||||
вызовы падают немедленно с `LlmUnavailableError`, не делая HTTP-запросов.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_INITIAL_BACKOFF_S = 0.5
|
||||
"""Базовая пауза перед повтором; растёт экспоненциально: `_INITIAL_BACKOFF_S * 2**attempt`."""
|
||||
|
||||
_RETRYABLE_STATUS_CODES = frozenset({503})
|
||||
"""Дополнительные ретраибл-статусы помимо диапазона 5xx (503 — модель llama.cpp ещё грузится)."""
|
||||
|
||||
|
||||
class LlmUnavailableError(Exception):
|
||||
"""LLM-сервер недоступен: исчерпаны попытки retry либо открыт circuit breaker."""
|
||||
|
||||
|
||||
class OpenAICompatClient:
|
||||
"""Клиент чат-комплишенов OpenAI-совместимого сервера (llama.cpp, Ollama и т.п.).
|
||||
|
||||
`transport` — точка внедрения `httpx.MockTransport` в тестах; в проде не
|
||||
передаётся (используется реальный сетевой транспорт `httpx`).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
model: str,
|
||||
*,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 1024,
|
||||
timeout_s: float = 600.0,
|
||||
max_attempts: int = 3,
|
||||
breaker_threshold: int = 5,
|
||||
breaker_cooldown_s: float = 60.0,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._model = model
|
||||
self._temperature = temperature
|
||||
self._max_tokens = max_tokens
|
||||
self._max_attempts = max_attempts
|
||||
self._breaker_threshold = breaker_threshold
|
||||
self._breaker_cooldown_s = breaker_cooldown_s
|
||||
self._client = httpx.Client(timeout=timeout_s, transport=transport)
|
||||
|
||||
self._consecutive_failures = 0
|
||||
self._breaker_open_until = 0.0
|
||||
|
||||
def complete(self, prompt: str, *, max_tokens: int | None = None) -> str:
|
||||
"""Выполнить чат-комплишн по одному пользовательскому сообщению `prompt`.
|
||||
|
||||
`max_tokens` — переопределение лимита на конкретный вызов (ADR-004:
|
||||
раздельные лимиты map/reduce у `QwenLocal`); по умолчанию берётся
|
||||
`max_tokens`, заданный в конструкторе клиента.
|
||||
|
||||
Бросает `LlmUnavailableError`, если circuit breaker открыт (окно
|
||||
отказа ещё не истекло) либо все попытки retry исчерпаны.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if now < self._breaker_open_until:
|
||||
raise LlmUnavailableError(
|
||||
"LLM-сервер недоступен: circuit breaker открыт после серии сбоев"
|
||||
)
|
||||
|
||||
url = f"{self._base_url}/chat/completions"
|
||||
payload: dict[str, Any] = {
|
||||
"model": self._model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": self._temperature,
|
||||
"max_tokens": max_tokens if max_tokens is not None else self._max_tokens,
|
||||
}
|
||||
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(self._max_attempts):
|
||||
try:
|
||||
response = self._client.post(url, json=payload)
|
||||
except httpx.TransportError as exc:
|
||||
last_error = exc
|
||||
logger.warning(
|
||||
"Сетевая ошибка при обращении к LLM (попытка %d): %s", attempt + 1, exc
|
||||
)
|
||||
else:
|
||||
if response.status_code == 200:
|
||||
self._consecutive_failures = 0
|
||||
try:
|
||||
data = response.json()
|
||||
content: str = data["choices"][0]["message"]["content"]
|
||||
except (ValueError, KeyError, IndexError, TypeError) as exc:
|
||||
last_error = exc
|
||||
logger.warning("Некорректный ответ LLM-сервера: %s", exc)
|
||||
else:
|
||||
return content
|
||||
elif response.status_code >= 500 or response.status_code in _RETRYABLE_STATUS_CODES:
|
||||
last_error = RuntimeError(
|
||||
f"LLM-сервер вернул retryable статус {response.status_code}"
|
||||
)
|
||||
logger.warning(
|
||||
"Retryable статус %d от LLM (попытка %d)", response.status_code, attempt + 1
|
||||
)
|
||||
else:
|
||||
# Не retryable статус (например, 4xx) — не тратим оставшиеся
|
||||
# попытки, но фиксируем сбой для circuit breaker.
|
||||
self._register_failure()
|
||||
raise LlmUnavailableError(
|
||||
f"LLM-сервер вернул статус {response.status_code}: {response.text}"
|
||||
) from None
|
||||
|
||||
if attempt < self._max_attempts - 1:
|
||||
time.sleep(_INITIAL_BACKOFF_S * (2**attempt))
|
||||
|
||||
self._register_failure()
|
||||
raise LlmUnavailableError(
|
||||
f"LLM-сервер недоступен после {self._max_attempts} попыток"
|
||||
) from last_error
|
||||
|
||||
def _register_failure(self) -> None:
|
||||
"""Учесть неудачный вызов `complete()`; открыть breaker при достижении порога."""
|
||||
self._consecutive_failures += 1
|
||||
if self._consecutive_failures >= self._breaker_threshold:
|
||||
self._breaker_open_until = time.monotonic() + self._breaker_cooldown_s
|
||||
logger.warning(
|
||||
"Circuit breaker открыт на %.0fс после %d подряд неудач",
|
||||
self._breaker_cooldown_s,
|
||||
self._consecutive_failures,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Закрыть базовый HTTP-клиент (освободить соединения и пул `httpx`).
|
||||
|
||||
Безопасно вызывать более одного раза — `httpx.Client.close()` идемпотентен.
|
||||
"""
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self) -> "OpenAICompatClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info: object) -> None:
|
||||
self.close()
|
||||
51
backend/core/summarization/tokens.py
Normal file
51
backend/core/summarization/tokens.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Подсчёт токенов для чанкера и плагина `QwenLocal`."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QwenTokenCounter:
|
||||
"""Подсчёт токенов токенизатором модели Qwen2.5-3B-Instruct.
|
||||
|
||||
Загрузка `tokenizers.Tokenizer` — ленивая (при первом вызове), чтобы
|
||||
инстанцирование плагина в API-процессе не тянуло за собой файл
|
||||
токенизатора. Если файл отсутствует или повреждён — используется
|
||||
эвристический фолбэк `len(text) // 3`, чтобы пайплайн не падал из-за
|
||||
отсутствия токенизатора (например, в dev-окружении без volume модели).
|
||||
"""
|
||||
|
||||
def __init__(self, tokenizer_path: str | None = None) -> None:
|
||||
self._tokenizer_path = tokenizer_path
|
||||
self._tokenizer: Any | None = None
|
||||
self._load_attempted = False
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Загрузить токенизатор один раз (синглтон на инстанс counter'а)."""
|
||||
if self._load_attempted:
|
||||
return
|
||||
self._load_attempted = True
|
||||
|
||||
if not self._tokenizer_path:
|
||||
logger.warning("Путь к токенизатору не задан, использую эвристику len(text) // 3")
|
||||
return
|
||||
|
||||
try:
|
||||
from tokenizers import Tokenizer
|
||||
|
||||
self._tokenizer = Tokenizer.from_file(self._tokenizer_path)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Не удалось загрузить токенизатор из %s, использую эвристику len(text) // 3",
|
||||
self._tokenizer_path,
|
||||
)
|
||||
self._tokenizer = None
|
||||
|
||||
def __call__(self, text: str) -> int:
|
||||
"""Вернуть число токенов в тексте (или эвристическую оценку)."""
|
||||
self._ensure_loaded()
|
||||
if self._tokenizer is not None:
|
||||
encoded: int = len(self._tokenizer.encode(text).ids)
|
||||
return encoded
|
||||
return len(text) // 3
|
||||
Reference in New Issue
Block a user