Compare commits
@@ -153,6 +153,46 @@ for turn in r["turns"]:
|
||||
Заодно постобработка приводит типографику к принятому виду: все виды тире
|
||||
заменяются на дефис, кавычки отбиваются пробелом.
|
||||
|
||||
## Обновления
|
||||
|
||||
Сервис проверяет новую версию при каждом запуске и обновляет только папку `app` -
|
||||
это десятки килобайт. Python, библиотеки, ffmpeg и модели остаются на месте,
|
||||
перекидывать весь пакет заново не нужно.
|
||||
|
||||
Настройка в `config.toml`:
|
||||
|
||||
```toml
|
||||
[update]
|
||||
enabled = true
|
||||
server = "https://git.netranking.ru"
|
||||
repo = "bryzgalov/talkscore-asr"
|
||||
token = "" # токен Gitea с правом чтения; для публичного репозитория не нужен
|
||||
```
|
||||
|
||||
Репозиторий приватный, поэтому токен обязателен. Создать его: Gitea → Settings →
|
||||
Applications → Generate Token, достаточно права `read:repository`.
|
||||
|
||||
Как это работает:
|
||||
|
||||
1. `start.bat` перед запуском сервиса спрашивает у Gitea последний релиз
|
||||
2. если версия там новее, скачивает `app-<версия>.zip` и сверяет контрольную сумму
|
||||
3. откладывает текущий код, ставит новый и проверяет, что он импортируется
|
||||
4. если проверка не прошла, возвращает предыдущую версию
|
||||
|
||||
Нет сети или Gitea недоступен - сервис просто запускается на текущей версии.
|
||||
`config.toml` и `replacements.txt` обновление не трогает: они ваши.
|
||||
Чтобы выключить проверку совсем, поставьте `enabled = false`.
|
||||
|
||||
### Выпуск новой версии
|
||||
|
||||
```bash
|
||||
uv run --with requests python build/release.py 0.2.0 -m "что изменилось"
|
||||
```
|
||||
|
||||
Скрипт проставит версию в `app/version.py`, соберёт архив только из кода
|
||||
и опубликует релиз с контрольной суммой. Целевая машина подхватит его
|
||||
при следующем запуске.
|
||||
|
||||
## Автозапуск
|
||||
|
||||
Чтобы сервис поднимался при старте Windows, создайте задачу в планировщике:
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Акустические признаки реплики.
|
||||
|
||||
На записи с одним микрофоном на столе участники сидят на разном расстоянии,
|
||||
и это слышно: дальний голос глуше и тише. Тембровые модели такую разницу
|
||||
почти не улавливают, поэтому признаки считаются отдельно и отдаются наружу -
|
||||
по ним LLM может уточнить, кто где, когда разделение по голосу ненадёжно.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
__all__ = ["segment_acoustics", "separation_quality"]
|
||||
|
||||
SAMPLE_RATE = 16000
|
||||
|
||||
|
||||
def segment_acoustics(samples: np.ndarray) -> dict:
|
||||
"""Считает признаки, связанные с расстоянием до микрофона."""
|
||||
if len(samples) < SAMPLE_RATE // 4:
|
||||
return {}
|
||||
|
||||
rms = float(np.sqrt((samples ** 2).mean()) + 1e-9)
|
||||
spectrum = np.abs(np.fft.rfft(samples * np.hanning(len(samples))))
|
||||
freqs = np.fft.rfftfreq(len(samples), 1 / SAMPLE_RATE)
|
||||
total = float(spectrum.sum()) + 1e-9
|
||||
|
||||
return {
|
||||
# Громкость: ближний к микрофону обычно громче
|
||||
"loudness_db": round(20 * float(np.log10(rms)), 1),
|
||||
# Доля высоких частот: у дальнего голоса верх съедается расстоянием
|
||||
"hf_ratio": round(float(spectrum[(freqs > 3000) & (freqs < 7000)].sum()) / total, 3),
|
||||
# Центр тяжести спектра, тот же смысл другим числом
|
||||
"centroid_hz": round(float((spectrum * freqs).sum()) / total),
|
||||
}
|
||||
|
||||
|
||||
def separation_quality(embeddings: np.ndarray, labels: np.ndarray) -> float:
|
||||
"""Насколько уверенно голоса разошлись по кластерам.
|
||||
|
||||
Единица - участники различаются чётко, около нуля - модель фактически
|
||||
не видит разницы и разметка по говорящим случайна. На записях с одним
|
||||
микрофоном в комнате значение обычно низкое.
|
||||
"""
|
||||
if len(embeddings) < 4 or len(set(labels.tolist())) < 2:
|
||||
return 0.0
|
||||
|
||||
norm = embeddings / (np.linalg.norm(embeddings, axis=1, keepdims=True) + 1e-9)
|
||||
distances = 1 - norm @ norm.T
|
||||
scores = []
|
||||
for i in range(len(norm)):
|
||||
own = distances[i][(labels == labels[i])]
|
||||
other = distances[i][(labels != labels[i])]
|
||||
own = own[own > 0]
|
||||
if len(own) and len(other):
|
||||
a, b = float(own.mean()), float(other.mean())
|
||||
scores.append((b - a) / max(a, b))
|
||||
return round(float(np.mean(scores)), 3) if scores else 0.0
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Подбор оптимального числа потоков на конкретной машине.
|
||||
|
||||
Масштабирование ONNX сильно зависит от процессора: на Apple M4 оптимум
|
||||
оказался в районе 4 потоков, на Ryzen картина другая. Гадать бесполезно,
|
||||
поэтому сервис умеет померить сам.
|
||||
"""
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from app.pipeline import SAMPLE_RATE, Pipeline
|
||||
|
||||
__all__ = ["run_benchmark"]
|
||||
|
||||
# Больше 16 потоков смысла мерить нет: везде, где замеряли, деградация
|
||||
# начиналась гораздо раньше.
|
||||
THREAD_OPTIONS = (1, 2, 4, 8, 16)
|
||||
|
||||
|
||||
def run_benchmark(samples: np.ndarray, models_dir: Path, base_dir: Path,
|
||||
replacements_path: Path, seconds: float = 60.0,
|
||||
speakers: int = 2) -> dict:
|
||||
"""Гоняет один и тот же фрагмент на разном числе потоков."""
|
||||
clip = samples[: int(seconds * SAMPLE_RATE)]
|
||||
duration = len(clip) / SAMPLE_RATE
|
||||
rows = []
|
||||
|
||||
for threads in THREAD_OPTIONS:
|
||||
pipe = Pipeline(models_dir=models_dir, threads=threads,
|
||||
replacements_path=replacements_path, base_dir=base_dir)
|
||||
pipe.warmup()
|
||||
|
||||
t0 = time.time()
|
||||
pipe._diarizer(speakers).process(clip)
|
||||
diar = time.time() - t0
|
||||
|
||||
t0 = time.time()
|
||||
pipe._asr.recognize(clip[: int(30 * SAMPLE_RATE)], sample_rate=SAMPLE_RATE)
|
||||
asr = time.time() - t0
|
||||
|
||||
rows.append({
|
||||
"threads": threads,
|
||||
"diarization_sec": round(diar, 2),
|
||||
"diarization_x": round(duration / diar, 1) if diar else 0,
|
||||
"asr_sec": round(asr, 2),
|
||||
"asr_x": round(min(30.0, duration) / asr, 1) if asr else 0,
|
||||
})
|
||||
|
||||
best_diar = max(rows, key=lambda r: r["diarization_x"])
|
||||
best_asr = max(rows, key=lambda r: r["asr_x"])
|
||||
return {
|
||||
"clip_sec": round(duration, 1),
|
||||
"results": rows,
|
||||
"best_for_diarization": best_diar["threads"],
|
||||
"best_for_asr": best_asr["threads"],
|
||||
"recommended_threads": best_diar["threads"],
|
||||
"hint": (
|
||||
f"Поставьте threads = {best_diar['threads']} в config.toml. "
|
||||
"Разделение говорящих занимает большую часть времени, поэтому "
|
||||
"ориентируемся на него."
|
||||
),
|
||||
}
|
||||
+87
-9
@@ -1,11 +1,20 @@
|
||||
"""Настройки сервиса: читаются из config.toml рядом с исполняемым файлом."""
|
||||
"""Чтение настроек сервиса.
|
||||
|
||||
ЭТО КОД, А НЕ НАСТРОЙКИ. Править нужно файл config.toml рядом со start.bat -
|
||||
он создаётся при первом запуске и обновлением не затрагивается. Правки в этом
|
||||
файле не подействуют (config.toml уже существует) и пропадут при обновлении.
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
import tomllib
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["Settings", "load_settings", "BASE_DIR"]
|
||||
__all__ = ["Settings", "load_settings", "BASE_DIR", "ConfigError"]
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
"""Настройки прочитать не удалось. Текст рассчитан на чтение человеком."""
|
||||
|
||||
# Корень пакета: при запуске из ZIP это папка, куда его распаковали.
|
||||
BASE_DIR = Path(os.environ.get("TALKSCORE_ASR_HOME") or Path(__file__).resolve().parent.parent)
|
||||
@@ -24,21 +33,51 @@ token = "{token}"
|
||||
# Кто может обращаться. Пусто = разрешены все адреса.
|
||||
# Примеры: "192.168.1.0/24, 10.8.0.5, 127.0.0.1"
|
||||
allow_ips = "127.0.0.1, ::1"
|
||||
# Открывать ли /docs и /openapi.json. Они не требуют токена и показывают
|
||||
# устройство API всем, кто знает адрес, поэтому по умолчанию выключены.
|
||||
# Адреса обратных прокси, которым можно верить в определении клиента.
|
||||
# Пусто = сервис работает напрямую и заголовкам не доверяет.
|
||||
# При работе через start-https.bat (Caddy на этой же машине) ставьте 127.0.0.1
|
||||
trust_proxy = ""
|
||||
# Открывать ли /docs и /openapi.json. Они показывают устройство API, поэтому
|
||||
# по умолчанию выключены и отвечают 404. Включённые - требуют адрес из списка
|
||||
# и токен: в браузере его передают ссылкой вида /docs?token=ЗНАЧЕНИЕ.
|
||||
docs = false
|
||||
|
||||
[processing]
|
||||
# Сколько потоков отдать распознаванию. 0 = половина ядер.
|
||||
# Потоков на одну задачу. 0 = половина ядер, но не больше 8.
|
||||
# Оптимум зависит от процессора: подберите его через POST /v1/benchmark
|
||||
# и впишите сюда полученное число.
|
||||
threads = 0
|
||||
# Сколько записей обрабатывать одновременно. Значение больше 1 включает
|
||||
# отдельные процессы: библиотеки держат GIL, и в потоках задачи выстраиваются
|
||||
# в очередь вместо параллельной работы. Каждый процесс держит свою копию
|
||||
# моделей, это около 1 ГБ памяти на процесс.
|
||||
workers = 1
|
||||
# Ожидаемое число говорящих в записи. 0 = определять автоматически
|
||||
# (на реальных звонках работает плохо, для диалога ставьте 2).
|
||||
speakers = 2
|
||||
# Выравнивать громкость перед обработкой. Для записей с микрофоном на столе
|
||||
# это решающая настройка: без неё тихий участник сливается с громким и
|
||||
# разделение по говорящим разваливается. На распознавание не влияет.
|
||||
normalize = true
|
||||
# Максимальный размер загружаемого файла, МБ
|
||||
max_upload_mb = 500
|
||||
# Сколько часов хранить результаты завершённых задач
|
||||
keep_results_hours = 72
|
||||
|
||||
[webhook]
|
||||
# Куда сообщать о готовых задачах. Пусто = не сообщать, забирайте опросом.
|
||||
# Можно переопределить для отдельной задачи параметром webhook в запросе.
|
||||
url = ""
|
||||
# Секрет для подписи: сервис положит её в заголовок X-Talkscore-Signature,
|
||||
# чтобы принимающая сторона убедилась, что запрос от вас.
|
||||
secret = ""
|
||||
|
||||
[https]
|
||||
# Домены для start-https.bat через запятую. Caddy получит на них сертификаты
|
||||
# Let's Encrypt сам. Требуются открытые снаружи порты 80 и 443 и A-записи,
|
||||
# указывающие на эту машину. Caddyfile создаётся из этих настроек.
|
||||
domains = ""
|
||||
|
||||
[update]
|
||||
# Проверять обновления кода при каждом запуске. Обновляется только папка app,
|
||||
# это десятки килобайт: Python, библиотеки и модели остаются на месте.
|
||||
@@ -57,14 +96,20 @@ class Settings:
|
||||
token: str = ""
|
||||
allow_ips: str = ""
|
||||
docs: bool = False
|
||||
trust_proxy: str = ""
|
||||
threads: int = 0
|
||||
workers: int = 0
|
||||
speakers: int = 2
|
||||
normalize: bool = True
|
||||
max_upload_mb: int = 500
|
||||
keep_results_hours: float = 72.0
|
||||
webhook_url: str = ""
|
||||
webhook_secret: str = ""
|
||||
update_enabled: bool = False
|
||||
update_server: str = "https://git.netranking.ru"
|
||||
update_repo: str = "bryzgalov/talkscore-asr"
|
||||
update_token: str = ""
|
||||
https_domains: str = ""
|
||||
base_dir: Path = field(default_factory=lambda: BASE_DIR)
|
||||
|
||||
@property
|
||||
@@ -83,12 +128,25 @@ class Settings:
|
||||
def max_upload_bytes(self) -> int:
|
||||
return self.max_upload_mb * 1024 * 1024
|
||||
|
||||
# Масштабирование сильно зависит от процессора, и универсального числа нет.
|
||||
# На Ryzen 9 9950X всё растёт до 16 потоков (распознавание x14.7 -> x61.3),
|
||||
# на Apple M4 после 4 потоков начинается спад: там половина ядер медленные
|
||||
# и работа, попавшая на них, тормозит остальные.
|
||||
# Поэтому дефолт умеренный, а точное значение подбирается через /v1/benchmark.
|
||||
MAX_DEFAULT_THREADS = 8
|
||||
MAX_WORKERS = 4
|
||||
|
||||
def effective_threads(self) -> int:
|
||||
if self.threads > 0:
|
||||
return self.threads
|
||||
# Половина логических ядер: оставляем запас, чтобы машина не вставала колом
|
||||
# во время обработки. На Ryzen 9 9950X это 16 потоков.
|
||||
return max(1, (os.cpu_count() or 4) // 2)
|
||||
cores = os.cpu_count() or 4
|
||||
return max(1, min(self.MAX_DEFAULT_THREADS, cores // 2))
|
||||
|
||||
def effective_workers(self) -> int:
|
||||
if self.workers > 0:
|
||||
return self.workers
|
||||
cores = os.cpu_count() or 4
|
||||
return max(1, min(self.MAX_WORKERS, cores // self.effective_threads()))
|
||||
|
||||
|
||||
def load_settings(config_path: Path | None = None) -> Settings:
|
||||
@@ -98,11 +156,25 @@ def load_settings(config_path: Path | None = None) -> Settings:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(_DEFAULT_CONFIG.format(token=secrets.token_urlsafe(32)), encoding="utf-8")
|
||||
|
||||
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
try:
|
||||
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
# Самая частая причина - раздел объявлен дважды: настройки дописывали
|
||||
# блоками, и рядом со старым [security] появился второй.
|
||||
raise ConfigError(
|
||||
f"не удалось прочитать {path}\n"
|
||||
f" {exc}\n\n"
|
||||
" Скорее всего, один и тот же раздел объявлен дважды.\n"
|
||||
" Каждый раздел ([server], [security], [processing], [webhook],\n"
|
||||
" [update], [https]) должен встречаться в файле ровно один раз,\n"
|
||||
" а все его настройки - идти внутри него одним блоком."
|
||||
) from exc
|
||||
server = data.get("server", {})
|
||||
security = data.get("security", {})
|
||||
proc = data.get("processing", {})
|
||||
upd = data.get("update", {})
|
||||
hook = data.get("webhook", {})
|
||||
https = data.get("https", {})
|
||||
|
||||
return Settings(
|
||||
host=server.get("host", "0.0.0.0"),
|
||||
@@ -110,10 +182,16 @@ def load_settings(config_path: Path | None = None) -> Settings:
|
||||
token=str(security.get("token", "")),
|
||||
allow_ips=str(security.get("allow_ips", "")),
|
||||
docs=bool(security.get("docs", False)),
|
||||
trust_proxy=str(security.get("trust_proxy", "")),
|
||||
threads=int(proc.get("threads", 0)),
|
||||
workers=int(proc.get("workers", 1)),
|
||||
speakers=int(proc.get("speakers", 2)),
|
||||
normalize=bool(proc.get("normalize", True)),
|
||||
max_upload_mb=int(proc.get("max_upload_mb", 500)),
|
||||
keep_results_hours=float(proc.get("keep_results_hours", 72)),
|
||||
webhook_url=str(hook.get("url", "")),
|
||||
webhook_secret=str(hook.get("secret", "")),
|
||||
https_domains=str(https.get("domains", "")),
|
||||
update_enabled=bool(upd.get("enabled", False)),
|
||||
update_server=str(upd.get("server", "https://git.netranking.ru")),
|
||||
update_repo=str(upd.get("repo", "bryzgalov/talkscore-asr")),
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
"""Читаемый вывод в консоли: цвета и перенос длинных строк.
|
||||
|
||||
Windows-консоль по умолчанию не разбирает ANSI-последовательности и печатает
|
||||
их как мусор вида `[34mINFO[0m`. Начиная с Windows 10 разбор можно включить
|
||||
через WinAPI - это и делается ниже. Если включить не удалось (старая система,
|
||||
вывод перенаправлен в файл), цвета молча отключаются: лучше блёклый лог,
|
||||
чем лог с мусором.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import textwrap
|
||||
from collections.abc import Callable
|
||||
|
||||
__all__ = ["enable_ansi", "ConsoleFormatter", "setup_logging"]
|
||||
|
||||
# Цвет только там, где он несёт смысл: обычные сообщения остаются обычными,
|
||||
# чтобы предупреждения и ошибки было видно с одного взгляда.
|
||||
COLORS = {
|
||||
"DEBUG": "\033[90m",
|
||||
"INFO": "",
|
||||
"WARNING": "\033[33m",
|
||||
"ERROR": "\033[31m",
|
||||
"CRITICAL": "\033[1;31m",
|
||||
}
|
||||
DIM = "\033[90m"
|
||||
RESET = "\033[0m"
|
||||
MIN_WIDTH = 60
|
||||
FALLBACK_WIDTH = 100
|
||||
|
||||
|
||||
def enable_ansi() -> bool:
|
||||
"""Включает разбор ANSI в консоли Windows. Возвращает, можно ли красить."""
|
||||
if not sys.stdout.isatty():
|
||||
return False
|
||||
if os.name != "nt":
|
||||
return True
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
|
||||
mode = ctypes.c_uint32()
|
||||
if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
||||
return False
|
||||
# ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
||||
return bool(kernel32.SetConsoleMode(handle, mode.value | 0x0004))
|
||||
except (ImportError, AttributeError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def console_width() -> int:
|
||||
"""Ширина окна консоли. При перенаправлении в файл берём разумную по умолчанию."""
|
||||
width = shutil.get_terminal_size((FALLBACK_WIDTH, 24)).columns
|
||||
return max(MIN_WIDTH, width)
|
||||
|
||||
|
||||
class ConsoleFormatter(logging.Formatter):
|
||||
"""Формат вида `09:29:46 WARNING текст`, длинные строки переносятся по словам.
|
||||
|
||||
Продолжение переносится с отступом под текст сообщения, а не под начало
|
||||
строки: так видно, где кончается одно сообщение и начинается следующее.
|
||||
"""
|
||||
|
||||
def __init__(self, colored: bool,
|
||||
width: Callable[[], int] = console_width) -> None:
|
||||
super().__init__(datefmt="%H:%M:%S")
|
||||
self.colored = colored
|
||||
# Ширину берём через переданную функцию, а не из глобальной: окно консоли
|
||||
# меняется на ходу, а в тестах ширину надо задавать явно.
|
||||
self.width = width
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
stamp = self.formatTime(record, self.datefmt)
|
||||
level = record.levelname
|
||||
head = f"{stamp} {level:<8}"
|
||||
indent = " " * len(head)
|
||||
body = record.getMessage()
|
||||
if record.exc_info:
|
||||
body = f"{body}\n{self.formatException(record.exc_info)}"
|
||||
|
||||
lines: list[str] = []
|
||||
room = self.width() - len(head)
|
||||
for chunk in body.splitlines() or [""]:
|
||||
lines.extend(textwrap.wrap(chunk, width=room) or [""])
|
||||
|
||||
if not self.colored:
|
||||
return "\n".join([head + lines[0]]
|
||||
+ [indent + line for line in lines[1:]])
|
||||
|
||||
tint = COLORS.get(level, "")
|
||||
painted = f"{DIM}{stamp}{RESET} {tint}{level:<8}{RESET}" if tint \
|
||||
else f"{DIM}{stamp}{RESET} {level:<8}"
|
||||
return "\n".join([painted + lines[0]]
|
||||
+ [indent + line for line in lines[1:]])
|
||||
|
||||
|
||||
def setup_logging(level: int = logging.INFO) -> bool:
|
||||
"""Настраивает единый обработчик вывода. Возвращает, включены ли цвета."""
|
||||
colored = enable_ansi()
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(ConsoleFormatter(colored))
|
||||
root = logging.getLogger()
|
||||
root.handlers[:] = [handler]
|
||||
root.setLevel(level)
|
||||
return colored
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
"""Запуск сервиса: обновление, Caddyfile, HTTPS.
|
||||
|
||||
Раньше вся эта последовательность жила в .bat-файлах, а Caddyfile правился
|
||||
руками. Оба файла лежат вне папки app, поэтому обновление их не доставляло:
|
||||
чтобы получить новый порядок запуска, приходилось переустанавливать сервис.
|
||||
|
||||
Теперь .bat - это трёхстрочная заглушка, которая просто зовёт этот модуль,
|
||||
а Caddyfile создаётся здесь из настроек. Меняется только код внутри app,
|
||||
который обновление возит штатно.
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["render_caddyfile", "ensure_caddyfile", "main"]
|
||||
|
||||
# По этой строке отличаем свой файл от отредактированного вручную.
|
||||
MARK = "# Создан автоматически talkscore-asr. Правки будут перезаписаны."
|
||||
CADDY_WAIT_SEC = 3
|
||||
|
||||
|
||||
def render_caddyfile(domains: str, port: int, max_upload_mb: int) -> str:
|
||||
"""Собирает конфигурацию Caddy из настроек сервиса."""
|
||||
listed = ", ".join(d.strip() for d in domains.split(",") if d.strip())
|
||||
# Пустой строки перед блоком быть не должно: caddy fmt считает это
|
||||
# неформатированным файлом и ругается при каждом запуске.
|
||||
return f"""{MARK}
|
||||
# Домены и предел размера берутся из config.toml, менять нужно там.
|
||||
{{
|
||||
\t# Caddy очень подробно рассказывает про сертификаты. В консоли из-за этого
|
||||
\t# не видно сообщений сервиса, поэтому его вывод уходит в файл.
|
||||
\tlog {{
|
||||
\t\toutput file logs/caddy.log {{
|
||||
\t\t\troll_size 10MiB
|
||||
\t\t\troll_keep 5
|
||||
\t\t}}
|
||||
\t\tformat console
|
||||
\t\tlevel INFO
|
||||
\t}}
|
||||
}}
|
||||
|
||||
{listed} {{
|
||||
\t# X-Forwarded-For здесь намеренно не задаётся: Caddy сам игнорирует этот
|
||||
\t# заголовок из входящего запроса и подставляет реальный адрес клиента.
|
||||
\t# Не добавляйте сюда trusted_proxies - тогда Caddy начнёт верить заголовку
|
||||
\t# от клиента, и любой сможет назваться разрешённым адресом.
|
||||
\treverse_proxy 127.0.0.1:{port}
|
||||
|
||||
\trequest_body {{
|
||||
\t\tmax_size {max_upload_mb}MB
|
||||
\t}}
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def ensure_caddyfile(base_dir: Path, content: str) -> Path | None:
|
||||
"""Записывает Caddyfile, не затирая ручные правки.
|
||||
|
||||
Если файл создан не нами, откладываем новую версию рядом: молча стереть
|
||||
чужую настройку хуже, чем оставить её устаревшей.
|
||||
"""
|
||||
target = base_dir / "Caddyfile"
|
||||
if target.exists():
|
||||
existing = target.read_text(encoding="utf-8", errors="replace")
|
||||
if MARK not in existing:
|
||||
spare = base_dir / "Caddyfile.new"
|
||||
if existing.strip() != content.strip():
|
||||
spare.write_text(content, encoding="utf-8")
|
||||
return spare
|
||||
return None
|
||||
if existing == content:
|
||||
return None
|
||||
target.write_text(content, encoding="utf-8")
|
||||
return target
|
||||
|
||||
|
||||
def start_caddy(base_dir: Path) -> subprocess.Popen | None:
|
||||
"""Поднимает Caddy отдельным процессом. Его отсутствие не должно ронять запуск."""
|
||||
binary = base_dir / "bin" / "caddy.exe"
|
||||
if not binary.is_file():
|
||||
binary = base_dir / "bin" / "caddy"
|
||||
if not binary.is_file():
|
||||
print("\n Caddy не найден в папке bin - запускаюсь без HTTPS.\n")
|
||||
return None
|
||||
(base_dir / "logs").mkdir(exist_ok=True)
|
||||
return subprocess.Popen(
|
||||
[str(binary), "run", "--config", str(base_dir / "Caddyfile")],
|
||||
cwd=str(base_dir))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
argv = sys.argv[1:] if argv is None else argv
|
||||
https = "https" in argv
|
||||
|
||||
from app import updater
|
||||
from app.config import ConfigError, load_settings
|
||||
|
||||
try:
|
||||
settings = load_settings()
|
||||
except ConfigError as exc:
|
||||
print(f"\n {exc}\n")
|
||||
return 1
|
||||
|
||||
updater.main()
|
||||
|
||||
caddy = None
|
||||
if https:
|
||||
if not settings.https_domains.strip():
|
||||
print("\n В config.toml не заданы домены: раздел [https], ключ domains."
|
||||
"\n Без них сертификат выпустить не на что.\n")
|
||||
return 1
|
||||
written = ensure_caddyfile(
|
||||
settings.base_dir,
|
||||
render_caddyfile(settings.https_domains, settings.port,
|
||||
settings.max_upload_mb))
|
||||
if written is not None and written.name.endswith(".new"):
|
||||
print(f"\n Caddyfile правили вручную, поэтому не трогаю его."
|
||||
f"\n Новая версия отложена рядом: {written}\n")
|
||||
caddy = start_caddy(settings.base_dir)
|
||||
|
||||
try:
|
||||
from app.main import run
|
||||
|
||||
run()
|
||||
finally:
|
||||
if caddy is not None:
|
||||
caddy.terminate()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Кольцевой буфер последних записей журнала.
|
||||
|
||||
Нужен, чтобы смотреть логи через API, а не копировать их из окна консоли.
|
||||
Хранится в памяти: файл на диске пришлось бы чистить, а история глубже
|
||||
последних сотен строк для разбора сбоя не нужна.
|
||||
"""
|
||||
import logging
|
||||
import threading
|
||||
from collections import deque
|
||||
|
||||
__all__ = ["LogBuffer", "install"]
|
||||
|
||||
|
||||
class LogBuffer(logging.Handler):
|
||||
def __init__(self, capacity: int = 500):
|
||||
super().__init__()
|
||||
self._records: deque = deque(maxlen=capacity)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
message = record.getMessage()
|
||||
if record.exc_info:
|
||||
message += "\n" + self.format(record).split("\n", 1)[-1]
|
||||
except Exception: # noqa: BLE001 - журнал не должен падать сам
|
||||
message = "не удалось разобрать запись журнала"
|
||||
with self._lock:
|
||||
self._records.append({
|
||||
"time": record.created,
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": message,
|
||||
})
|
||||
|
||||
def tail(self, limit: int = 100, level: str | None = None) -> list[dict]:
|
||||
with self._lock:
|
||||
rows = list(self._records)
|
||||
if level:
|
||||
wanted = level.upper()
|
||||
rows = [r for r in rows if r["level"] == wanted]
|
||||
return rows[-limit:]
|
||||
|
||||
|
||||
_buffer: LogBuffer | None = None
|
||||
|
||||
|
||||
def install(capacity: int = 500) -> LogBuffer:
|
||||
"""Подключает буфер к корневому журналу. Повторный вызов вернёт тот же буфер."""
|
||||
global _buffer
|
||||
if _buffer is None:
|
||||
_buffer = LogBuffer(capacity)
|
||||
_buffer.setFormatter(logging.Formatter("%(message)s"))
|
||||
logging.getLogger().addHandler(_buffer)
|
||||
return _buffer
|
||||
|
||||
|
||||
def get() -> LogBuffer:
|
||||
return install()
|
||||
+303
-45
@@ -1,70 +1,120 @@
|
||||
"""HTTP-сервис распознавания: приём файла, очередь, выдача результата."""
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.openapi.docs import get_swagger_ui_html
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer
|
||||
|
||||
from app.config import Settings, load_settings
|
||||
from app.config import ConfigError, Settings, load_settings
|
||||
from app.console import setup_logging
|
||||
from app.logbuffer import get as log_buffer
|
||||
from app.logbuffer import install as install_log_buffer
|
||||
from app.pipeline import ModelsMissing, Pipeline, to_wav16k
|
||||
from app.security import check_token, ip_allowed, parse_allowlist
|
||||
from app.security import (check_token, client_address, ip_allowed,
|
||||
parse_allowlist, token_matches)
|
||||
from app.store import JobStatus, JobStore
|
||||
from app.webhook import deliver_async
|
||||
from app.version import __version__
|
||||
|
||||
log = logging.getLogger("talkscore-asr")
|
||||
|
||||
settings: Settings = load_settings()
|
||||
try:
|
||||
settings: Settings = load_settings()
|
||||
except ConfigError as exc:
|
||||
# Печатаем понятную причину и выходим: со сломанным конфигом
|
||||
# сервис всё равно ничего осмысленного не сделает.
|
||||
print(f"\n {exc}\n")
|
||||
raise SystemExit(1) from None
|
||||
allowlist = parse_allowlist(settings.allow_ips)
|
||||
store = JobStore(settings.data_dir / "jobs.db")
|
||||
pipeline = Pipeline(
|
||||
models_dir=settings.models_dir,
|
||||
threads=settings.effective_threads(),
|
||||
replacements_path=settings.replacements_path,
|
||||
base_dir=settings.base_dir,
|
||||
)
|
||||
trusted_proxies = parse_allowlist(settings.trust_proxy)
|
||||
|
||||
# Хранилище создаётся не здесь, а при старте сервиса. На Windows процессы
|
||||
# поднимаются через spawn и заново импортируют этот модуль: если бы очередь
|
||||
# открывалась на уровне модуля, каждый новый процесс при старте помечал бы
|
||||
# чужие выполняющиеся задачи как сорванные.
|
||||
store: JobStore = None # type: ignore[assignment]
|
||||
def make_pipeline() -> Pipeline:
|
||||
"""Своя копия моделей на каждого воркера: они не рассчитаны на общий доступ."""
|
||||
return Pipeline(
|
||||
models_dir=settings.models_dir,
|
||||
threads=settings.effective_threads(),
|
||||
replacements_path=settings.replacements_path,
|
||||
base_dir=settings.base_dir,
|
||||
)
|
||||
|
||||
|
||||
pipeline = make_pipeline()
|
||||
|
||||
_worker_stop = threading.Event()
|
||||
_state: dict = {"ready": False, "error": None}
|
||||
|
||||
|
||||
def _process(job_id: str) -> None:
|
||||
def _process(job_id: str, worker: Pipeline, pool) -> None:
|
||||
"""Задача уже помечена в работе тем, кто её забрал."""
|
||||
job = store.get(job_id)
|
||||
if job is None:
|
||||
return
|
||||
store.mark_running(job_id)
|
||||
upload = settings.data_dir / "uploads" / job_id
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
wav = Path(tmp) / "audio.wav"
|
||||
to_wav16k(upload, wav, pipeline.ffmpeg)
|
||||
speakers = int(job["options"].get("speakers", settings.speakers))
|
||||
result = pipeline.transcribe(wav, num_speakers=speakers)
|
||||
speakers = int(job["options"].get("speakers", settings.speakers))
|
||||
if pool is not None:
|
||||
# Считаем в отдельном процессе: библиотеки держат GIL, и в потоках
|
||||
# задачи выстраиваются в очередь вместо параллельной работы.
|
||||
from app.worker import run_job
|
||||
|
||||
result = pool.submit(run_job, str(upload), speakers, worker.ffmpeg,
|
||||
settings.normalize).result()
|
||||
else:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
wav = Path(tmp) / "audio.wav"
|
||||
to_wav16k(upload, wav, worker.ffmpeg, settings.normalize)
|
||||
result = worker.transcribe(wav, num_speakers=speakers)
|
||||
result["filename"] = job["filename"]
|
||||
store.mark_done(job_id, result)
|
||||
log.info("задача %s готова: %.1f с аудио, x%s", job_id,
|
||||
result["duration_sec"], result["timing"]["realtime_factor"])
|
||||
_notify(job, {"job_id": job_id, "status": JobStatus.DONE, **result})
|
||||
except Exception as exc: # noqa: BLE001 - в статус задачи должна попасть любая причина
|
||||
log.exception("задача %s провалилась", job_id)
|
||||
store.mark_failed(job_id, error=f"{type(exc).__name__}: {exc}")
|
||||
error = f"{type(exc).__name__}: {exc}"
|
||||
store.mark_failed(job_id, error=error)
|
||||
_notify(job, {"job_id": job_id, "status": JobStatus.FAILED,
|
||||
"filename": job["filename"], "error": error})
|
||||
finally:
|
||||
upload.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _worker_loop() -> None:
|
||||
"""Один воркер: модели тяжёлые, параллельные задачи только мешали бы друг другу."""
|
||||
def _notify(job: dict, payload: dict) -> None:
|
||||
"""Сообщает о результате, если для задачи задан адрес."""
|
||||
url = job["options"].get("webhook") or settings.webhook_url
|
||||
if url:
|
||||
deliver_async(url, payload, settings.webhook_secret)
|
||||
|
||||
|
||||
def _worker_loop(index: int, worker: Pipeline, pool=None) -> None:
|
||||
"""Разбирает очередь. Задача захватывается атомарно, поэтому воркеров может быть много."""
|
||||
last_cleanup = 0.0
|
||||
while not _worker_stop.is_set():
|
||||
if _state["ready"]:
|
||||
job_id = store.take_next()
|
||||
job_id = store.claim_next()
|
||||
if job_id:
|
||||
_process(job_id)
|
||||
log.info("воркер %d взял задачу %s", index, job_id)
|
||||
_process(job_id, worker, pool)
|
||||
continue
|
||||
if time.time() - last_cleanup > 3600:
|
||||
# Уборкой занимается только первый воркер, чтобы не делать её хором.
|
||||
if index == 0 and time.time() - last_cleanup > 3600:
|
||||
removed = store.cleanup(settings.keep_results_hours)
|
||||
if removed:
|
||||
log.info("удалено старых задач: %d", removed)
|
||||
@@ -72,39 +122,101 @@ def _worker_loop() -> None:
|
||||
_worker_stop.wait(1.0)
|
||||
|
||||
|
||||
def _make_pool(count: int):
|
||||
"""Пул процессов для расчётов. При неудаче работаем в одном процессе.
|
||||
|
||||
На Windows процессы поднимаются через spawn, и сбои тут возможны, поэтому
|
||||
отказ не должен ронять сервис - он просто станет однопроцессным.
|
||||
"""
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
|
||||
from app.worker import init_worker
|
||||
|
||||
try:
|
||||
pool = ProcessPoolExecutor(
|
||||
max_workers=count,
|
||||
initializer=init_worker,
|
||||
initargs=(str(settings.models_dir), settings.effective_threads(),
|
||||
str(settings.replacements_path), str(settings.base_dir)),
|
||||
)
|
||||
# Пустая задача проверяет, что процессы действительно поднялись
|
||||
pool.submit(str, "ok").result(timeout=300)
|
||||
return pool
|
||||
except Exception as exc: # noqa: BLE001 - причин может быть много, важен откат
|
||||
log.warning("не удалось запустить процессы (%s), работаю в одном", exc)
|
||||
return None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global store
|
||||
|
||||
(settings.data_dir / "uploads").mkdir(parents=True, exist_ok=True)
|
||||
store = JobStore(settings.data_dir / "jobs.db")
|
||||
try:
|
||||
pipeline.warmup()
|
||||
_state["ready"] = True
|
||||
except ModelsMissing as exc:
|
||||
_state["error"] = str(exc)
|
||||
log.error("сервис запущен без моделей: %s", exc)
|
||||
worker = threading.Thread(target=_worker_loop, name="asr-worker", daemon=True)
|
||||
worker.start()
|
||||
log.info("сервис слушает %s:%s, потоков %d", settings.host, settings.port,
|
||||
settings.effective_threads())
|
||||
count = settings.effective_workers()
|
||||
pool = None
|
||||
if count > 1 and _state["ready"]:
|
||||
pool = _make_pool(count)
|
||||
|
||||
workers = []
|
||||
for i in range(count):
|
||||
worker = pipeline if i == 0 else make_pipeline()
|
||||
thread = threading.Thread(target=_worker_loop, args=(i, worker, pool),
|
||||
name=f"asr-worker-{i}", daemon=True)
|
||||
thread.start()
|
||||
workers.append(thread)
|
||||
|
||||
log.info("сервис слушает %s:%s, воркеров %d по %d потоков, режим %s",
|
||||
settings.host, settings.port, count, settings.effective_threads(),
|
||||
"процессы" if pool else "один процесс")
|
||||
yield
|
||||
_worker_stop.set()
|
||||
worker.join(timeout=5)
|
||||
for thread in workers:
|
||||
thread.join(timeout=5)
|
||||
if pool is not None:
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
|
||||
# Схема API не требует токена, поэтому по умолчанию она не публикуется:
|
||||
# знать устройство сервиса посторонним незачем.
|
||||
# Штатные /docs и /openapi.json отключены: они никого не проверяют. Вместо них
|
||||
# ниже свои маршруты - выключенные по умолчанию и закрытые адресом и токеном.
|
||||
app = FastAPI(
|
||||
title="talkscore-asr",
|
||||
version="0.1.0",
|
||||
version=__version__,
|
||||
description=(
|
||||
"Локальное распознавание русской речи с разделением по говорящим.\n\n"
|
||||
"Все методы, кроме `/health`, требуют заголовок `Authorization: Bearer <токен>`. "
|
||||
"Нажмите **Authorize** и вставьте токен из `config.toml`."
|
||||
),
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs" if settings.docs else None,
|
||||
redoc_url="/redoc" if settings.docs else None,
|
||||
openapi_url="/openapi.json" if settings.docs else None,
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_url=None,
|
||||
)
|
||||
bearer_scheme = HTTPBearer(description="Токен из config.toml, раздел [security]")
|
||||
|
||||
|
||||
def peer_address(request: Request) -> str | None:
|
||||
"""Адрес клиента с поправкой на обратный прокси."""
|
||||
peer = request.client.host if request.client else None
|
||||
return client_address(peer, request.headers.get("x-forwarded-for"), trusted_proxies)
|
||||
|
||||
|
||||
def ip_guard(request: Request) -> None:
|
||||
"""Только проверка адреса: нужна для страницы с описанием методов."""
|
||||
client_ip = peer_address(request)
|
||||
if not ip_allowed(client_ip, allowlist):
|
||||
raise HTTPException(status_code=403, detail="адрес не в списке разрешённых")
|
||||
|
||||
|
||||
def guard(request: Request) -> None:
|
||||
"""Проверяет адрес и токен. Порядок важен: сначала сеть, потом секрет."""
|
||||
client_ip = request.client.host if request.client else None
|
||||
client_ip = peer_address(request)
|
||||
if not ip_allowed(client_ip, allowlist):
|
||||
log.warning("отказано по адресу: %s", client_ip)
|
||||
raise HTTPException(status_code=403, detail="адрес не в списке разрешённых")
|
||||
@@ -112,29 +224,109 @@ def guard(request: Request) -> None:
|
||||
raise HTTPException(status_code=401, detail="неверный или отсутствующий токен")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> JSONResponse:
|
||||
"""Проверка живости - без токена, чтобы годилась для мониторинга."""
|
||||
def docs_guard(request: Request) -> str:
|
||||
"""Пускает к документации и возвращает токен для ссылки на схему.
|
||||
|
||||
Токен принимается и заголовком, и параметром ?token=. Браузер, открывая
|
||||
страницу по ссылке, заголовок не подставит, а документация без браузера
|
||||
теряет смысл. Раньше здесь проверялся только адрес - с выключенным
|
||||
списком адресов это означало открытый доступ.
|
||||
"""
|
||||
if not settings.docs:
|
||||
# 404, а не 403: подтверждать существование страницы незачем.
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
ip_guard(request)
|
||||
supplied = request.query_params.get("token")
|
||||
if check_token(request.headers.get("authorization"), settings.token):
|
||||
return supplied or ""
|
||||
if token_matches(supplied, settings.token):
|
||||
return supplied or ""
|
||||
raise HTTPException(status_code=401, detail="неверный или отсутствующий токен")
|
||||
|
||||
|
||||
@app.get("/docs", include_in_schema=False)
|
||||
def docs_page(request: Request):
|
||||
"""Описание методов для браузера. Требует адрес из списка и токен."""
|
||||
token = docs_guard(request)
|
||||
# Схему Swagger запрашивает сам, уже без заголовка - протаскиваем токен
|
||||
# в адрес, иначе страница откроется и тут же покажет ошибку доступа.
|
||||
suffix = f"?token={quote(token)}" if token else ""
|
||||
return get_swagger_ui_html(openapi_url=f"openapi.json{suffix}",
|
||||
title="talkscore-asr")
|
||||
|
||||
|
||||
@app.get("/openapi.json", include_in_schema=False)
|
||||
def openapi_schema(request: Request) -> JSONResponse:
|
||||
docs_guard(request)
|
||||
schema = get_openapi(title=app.title, version=app.version,
|
||||
description=app.description, routes=app.routes)
|
||||
schema["components"] = schema.get("components", {})
|
||||
schema["components"]["securitySchemes"] = {
|
||||
"bearer": {"type": "http", "scheme": "bearer"}}
|
||||
schema["security"] = [{"bearer": []}]
|
||||
return JSONResponse(schema)
|
||||
|
||||
|
||||
@app.get("/v1/logs", dependencies=[Depends(guard)], summary="Журнал сервиса",
|
||||
description="Последние записи журнала: удобно посмотреть причину сбоя, "
|
||||
"не заходя на машину. Уровень можно отфильтровать параметром level.")
|
||||
def logs(limit: int = Query(100, ge=1, le=500),
|
||||
level: str | None = Query(None, description="INFO, WARNING или ERROR")) -> dict:
|
||||
rows = log_buffer().tail(limit=limit, level=level)
|
||||
return {
|
||||
"count": len(rows),
|
||||
"records": [
|
||||
{"time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(r["time"])),
|
||||
"level": r["level"], "logger": r["logger"], "message": r["message"]}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health", summary="Состояние сервиса",
|
||||
description="Единственный метод без токена - годится для мониторинга. "
|
||||
"Показывает версию, очередь, число потоков и то, каким сервис "
|
||||
"видит ваш адрес.")
|
||||
def health(request: Request) -> JSONResponse:
|
||||
"""Проверка живости - без токена, чтобы годилась для мониторинга.
|
||||
|
||||
Показывает адрес обратившегося и вердикт по списку доступа: без этого
|
||||
отладка отказов превращается в гадание, а сам адрес клиенту и так известен.
|
||||
Список разрешённых адресов при этом не раскрывается.
|
||||
"""
|
||||
client_ip = peer_address(request)
|
||||
return JSONResponse({
|
||||
"status": "ok" if _state["ready"] else "no_models",
|
||||
"version": __version__,
|
||||
"error": _state["error"],
|
||||
"queue": store.stats(),
|
||||
"threads": settings.effective_threads(),
|
||||
"workers": settings.effective_workers(),
|
||||
"your_ip": client_ip,
|
||||
"your_ip_allowed": ip_allowed(client_ip, allowlist),
|
||||
"ip_filter_active": bool(allowlist),
|
||||
})
|
||||
|
||||
|
||||
@app.post("/v1/jobs", dependencies=[Depends(guard)])
|
||||
@app.post("/v1/jobs", dependencies=[Depends(guard)], summary="Отправить запись",
|
||||
description="Принимает аудиофайл любого формата, который читает ffmpeg: "
|
||||
"mp3, wav, m4a, ogg, opus, wma. Возвращает идентификатор задачи - "
|
||||
"результат забирается отдельным запросом, потому что обработка "
|
||||
"занимает минуты.")
|
||||
async def create_job(
|
||||
file: UploadFile = File(...),
|
||||
speakers: int | None = Query(None, ge=0, le=10,
|
||||
description="число говорящих, 0 = определить автоматически"),
|
||||
webhook: str | None = Query(None,
|
||||
description="куда сообщить о готовности; заменяет адрес из настроек"),
|
||||
) -> dict:
|
||||
if not _state["ready"]:
|
||||
raise HTTPException(status_code=503, detail=_state["error"] or "сервис ещё не готов")
|
||||
|
||||
job_id = store.create(filename=file.filename or "audio",
|
||||
duration_sec=0.0,
|
||||
options={"speakers": settings.speakers if speakers is None else speakers})
|
||||
options={"speakers": settings.speakers if speakers is None else speakers,
|
||||
"webhook": webhook or ""})
|
||||
target = settings.data_dir / "uploads" / job_id
|
||||
size = 0
|
||||
try:
|
||||
@@ -158,7 +350,35 @@ async def create_job(
|
||||
"queue_position": store.queue_position(job_id)}
|
||||
|
||||
|
||||
@app.get("/v1/jobs/{job_id}", dependencies=[Depends(guard)])
|
||||
@app.post("/v1/benchmark", dependencies=[Depends(guard)], summary="Подобрать число потоков",
|
||||
description="Прогоняет минуту из присланной записи на разном числе потоков "
|
||||
"и показывает, где быстрее. Занимает пару минут и на это время "
|
||||
"нагружает процессор.")
|
||||
async def benchmark(file: UploadFile = File(...)) -> dict:
|
||||
from app.bench import run_benchmark
|
||||
from app.pipeline import read_wav
|
||||
|
||||
if not _state["ready"]:
|
||||
raise HTTPException(status_code=503, detail=_state["error"] or "сервис ещё не готов")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src = Path(tmp) / (file.filename or "audio")
|
||||
with src.open("wb") as out:
|
||||
while chunk := await file.read(1 << 20):
|
||||
out.write(chunk)
|
||||
wav = Path(tmp) / "audio.wav"
|
||||
to_wav16k(src, wav, pipeline.ffmpeg, settings.normalize)
|
||||
samples = read_wav(wav)
|
||||
|
||||
return run_benchmark(samples, models_dir=settings.models_dir,
|
||||
base_dir=settings.base_dir,
|
||||
replacements_path=settings.replacements_path)
|
||||
|
||||
|
||||
@app.get("/v1/jobs/{job_id}", dependencies=[Depends(guard)], summary="Забрать результат",
|
||||
description="Пока задача не готова, возвращает статус и место в очереди. "
|
||||
"Готовый результат содержит реплики с говорящими и тайм-кодами, "
|
||||
"статистику разговора и время обработки.")
|
||||
def get_job(job_id: str) -> dict:
|
||||
job = store.get(job_id)
|
||||
if job is None:
|
||||
@@ -174,7 +394,9 @@ def get_job(job_id: str) -> dict:
|
||||
return body
|
||||
|
||||
|
||||
@app.delete("/v1/jobs/{job_id}", dependencies=[Depends(guard)])
|
||||
@app.delete("/v1/jobs/{job_id}", dependencies=[Depends(guard)], summary="Удалить задачу",
|
||||
description="Убирает задачу и её результат. Завершённые задачи и так "
|
||||
"удаляются сами через срок из keep_results_hours.")
|
||||
def delete_job(job_id: str) -> dict:
|
||||
if store.get(job_id) is None:
|
||||
raise HTTPException(status_code=404, detail="задача не найдена")
|
||||
@@ -191,12 +413,36 @@ def _setup_console() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _port_busy(host: str, port: int) -> bool:
|
||||
"""Проверяет, слушает ли кто-то уже этот порт.
|
||||
|
||||
Проверяем подключением, а не пробной привязкой: привязка оставляет сокет
|
||||
в TIME_WAIT и может сама помешать запуску через секунду.
|
||||
"""
|
||||
target = "127.0.0.1" if host in ("0.0.0.0", "::", "") else host
|
||||
with socket.socket() as probe:
|
||||
probe.settimeout(0.5)
|
||||
try:
|
||||
return probe.connect_ex((target, port)) == 0
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def run() -> None:
|
||||
import uvicorn
|
||||
|
||||
_setup_console()
|
||||
logging.basicConfig(level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
install_log_buffer()
|
||||
setup_logging(logging.INFO)
|
||||
|
||||
if _port_busy(settings.host, settings.port):
|
||||
# Uvicorn напишет про это «[Errno 10048] ... bind on address», из чего
|
||||
# неочевидно, что виноват уже работающий экземпляр сервиса.
|
||||
print(f"\n Порт {settings.port} уже занят - похоже, сервис уже запущен."
|
||||
"\n Проверьте свёрнутые окна talkscore-asr либо найдите процесс:"
|
||||
f"\n netstat -ano | findstr :{settings.port}"
|
||||
"\n taskkill /PID <номер из последней колонки> /F\n")
|
||||
raise SystemExit(1)
|
||||
|
||||
if not settings.token:
|
||||
print("\n В config.toml пустой токен - сервис никого не пустит."
|
||||
@@ -208,11 +454,23 @@ def run() -> None:
|
||||
print("\n Модели не найдены. Сначала запустите download_models.bat"
|
||||
"\n Сервис поднимется, но принимать записи не сможет.\n")
|
||||
|
||||
nets = len(allowlist)
|
||||
access = f"только с {nets} адресов" if nets else "со ВСЕХ адресов"
|
||||
print(f"\n Настройки: {settings.base_dir / 'config.toml'}"
|
||||
f"\n Токен: {'задан' if settings.token else 'НЕ ЗАДАН, сервис никого не пустит'}"
|
||||
f"\n Доступ: {access}"
|
||||
f"\n Обработка: {settings.effective_workers()} задач одновременно"
|
||||
f" по {settings.effective_threads()} потока")
|
||||
print(f"\n talkscore-asr слушает http://{settings.host}:{settings.port}"
|
||||
f"\n Потоков: {settings.effective_threads()}"
|
||||
f"\n Проверка: curl http://localhost:{settings.port}/health"
|
||||
"\n Остановить: Ctrl+C\n")
|
||||
uvicorn.run(app, host=settings.host, port=settings.port, log_level="info")
|
||||
# log_config=None отключает собственную настройку логов uvicorn. Иначе он
|
||||
# заводит свои обработчики с propagate=False, его строки идут мимо нашего
|
||||
# формата и рвутся посреди слова, а наши выглядят иначе - в одном окне два
|
||||
# разных формата.
|
||||
uvicorn.run(app, host=settings.host, port=settings.port, log_level="info",
|
||||
log_config=None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+116
-12
@@ -12,14 +12,27 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from app.text import apply_replacements, load_replacements, normalize_typography
|
||||
from app.acoustics import segment_acoustics, separation_quality
|
||||
from app.text import (apply_replacements, load_replacements,
|
||||
normalize_typography, split_sentences)
|
||||
from app.turns import Segment, chunk_ranges, merge_turns, speech_stats
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SAMPLE_RATE = 16000
|
||||
# GigaAM падает на кусках длиннее ~200 с, держим запас.
|
||||
MAX_CHUNK_SEC = 150.0
|
||||
# Предел зависит от сборки onnxruntime: на macOS реплика в 190 с проходит,
|
||||
# на Windows та же падает в свёртке энкодера. Берём с большим запасом, а на
|
||||
# случай сюрпризов есть деление куска пополам при ошибке.
|
||||
MAX_CHUNK_SEC = 60.0
|
||||
# Ниже этого делить бессмысленно: явно не длина виновата.
|
||||
MIN_SPLIT_SEC = 2.0
|
||||
# Модель отпечатков рассчитана на короткий фрагмент и на длинном падает.
|
||||
# Для оценки голоса секунд более чем достаточно.
|
||||
EMBED_SEC = 8.0
|
||||
# Ниже этого значения голоса практически неразличимы и разметка по говорящим
|
||||
# случайна. На записях с одним микрофоном в комнате так бывает часто.
|
||||
RELIABLE_SEPARATION = 0.35
|
||||
ASR_MODEL_NAME = "gigaam-v3-e2e-rnnt"
|
||||
ASR_MODEL_REL = Path("gigaam")
|
||||
SEG_MODEL_REL = Path("sherpa-onnx-pyannote-segmentation-3-0") / "model.onnx"
|
||||
@@ -42,13 +55,22 @@ def find_ffmpeg(base_dir: Path) -> str:
|
||||
return found
|
||||
|
||||
|
||||
def to_wav16k(src: Path, dst: Path, ffmpeg: str) -> float:
|
||||
# Выравнивание громкости - решающий шаг для записей с одним микрофоном на столе.
|
||||
# На проверочных разговорах доля второго участника выросла с 1.8 до 24.3 процента,
|
||||
# а переключений между репликами - с 21 до 71 процента. Распознанный текст при
|
||||
# этом не меняется: сравнение фрагментов дало полностью совпадающие расшифровки.
|
||||
# Параметры подобраны замером: f=400:g=3 даёт лишь 8.6 процента вместо 43.2,
|
||||
# так что менять их наугад не стоит.
|
||||
NORMALIZE_FILTER = "dynaudnorm=f=200:g=5"
|
||||
|
||||
|
||||
def to_wav16k(src: Path, dst: Path, ffmpeg: str, normalize: bool = True) -> float:
|
||||
"""Приводит любой аудиофайл к 16 кГц моно WAV. Возвращает длительность."""
|
||||
subprocess.run(
|
||||
[ffmpeg, "-nostdin", "-v", "error", "-y", "-i", str(src),
|
||||
"-vn", "-ar", str(SAMPLE_RATE), "-ac", "1", "-c:a", "pcm_s16le", str(dst)],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
command = [ffmpeg, "-nostdin", "-v", "error", "-y", "-i", str(src), "-vn"]
|
||||
if normalize:
|
||||
command += ["-af", NORMALIZE_FILTER]
|
||||
command += ["-ar", str(SAMPLE_RATE), "-ac", "1", "-c:a", "pcm_s16le", str(dst)]
|
||||
subprocess.run(command, check=True, capture_output=True)
|
||||
with wave.open(str(dst)) as w:
|
||||
return w.getnframes() / w.getframerate()
|
||||
|
||||
@@ -70,11 +92,25 @@ class Pipeline:
|
||||
self.models_dir = Path(models_dir)
|
||||
self.threads = threads
|
||||
self.replacements_path = Path(replacements_path)
|
||||
self.ffmpeg = find_ffmpeg(Path(base_dir))
|
||||
self.base_dir = Path(base_dir)
|
||||
self._ffmpeg: str | None = None
|
||||
self._asr = None
|
||||
self._diar_cache: dict[int, object] = {}
|
||||
self._embedder = None
|
||||
self._replacements: dict[str, str] = {}
|
||||
|
||||
@property
|
||||
def ffmpeg(self) -> str:
|
||||
"""Ищется при первом обращении, а не в конструкторе.
|
||||
|
||||
Иначе отсутствие ffmpeg роняло бы сам импорт модуля - и вместо внятного
|
||||
сообщения пользователь получал бы стек при старте, а обновление кода
|
||||
не могло бы проверить работоспособность новой версии.
|
||||
"""
|
||||
if self._ffmpeg is None:
|
||||
self._ffmpeg = find_ffmpeg(self.base_dir)
|
||||
return self._ffmpeg
|
||||
|
||||
def warmup(self) -> None:
|
||||
"""Грузит модели заранее, чтобы первый запрос не ждал."""
|
||||
import onnx_asr
|
||||
@@ -135,10 +171,62 @@ class Pipeline:
|
||||
self._diar_cache[num_speakers] = diar
|
||||
return diar
|
||||
|
||||
def _separation_quality(self, samples: np.ndarray, segments: list[Segment]) -> float:
|
||||
"""Оценивает, действительно ли голоса разошлись, или разметка случайна."""
|
||||
import sherpa_onnx
|
||||
|
||||
usable = [s for s in segments if s.end - s.start >= 1.0][:120]
|
||||
if len(usable) < 4:
|
||||
return 0.0
|
||||
if self._embedder is None:
|
||||
self._embedder = sherpa_onnx.SpeakerEmbeddingExtractor(
|
||||
sherpa_onnx.SpeakerEmbeddingExtractorConfig(
|
||||
model=str(self.models_dir / EMB_MODEL_REL), num_threads=self.threads))
|
||||
vectors, labels = [], []
|
||||
for seg in usable:
|
||||
# Берём кусок из середины реплики: там речь устойчивее, чем на краях,
|
||||
# а длинный фрагмент модель отпечатков просто не переваривает.
|
||||
middle = (seg.start + seg.end) / 2
|
||||
half = min(EMBED_SEC, seg.end - seg.start) / 2
|
||||
piece = samples[int((middle - half) * SAMPLE_RATE):int((middle + half) * SAMPLE_RATE)]
|
||||
if len(piece) < SAMPLE_RATE // 2:
|
||||
continue
|
||||
stream = self._embedder.create_stream()
|
||||
stream.accept_waveform(SAMPLE_RATE, piece)
|
||||
stream.input_finished()
|
||||
vectors.append(np.array(self._embedder.compute(stream)))
|
||||
labels.append(seg.speaker)
|
||||
if len(vectors) < 4:
|
||||
return 0.0
|
||||
return separation_quality(np.array(vectors), np.array(labels))
|
||||
|
||||
def _recognize_safely(self, audio: np.ndarray, depth: int = 0) -> str:
|
||||
"""Распознаёт кусок, при ошибке деля его пополам.
|
||||
|
||||
Пределы длины у разных сборок onnxruntime разные и заранее неизвестны,
|
||||
поэтому вместо подбора константы просто уменьшаем кусок и пробуем снова.
|
||||
"""
|
||||
try:
|
||||
return self._asr.recognize(audio, sample_rate=SAMPLE_RATE)
|
||||
except Exception:
|
||||
seconds = len(audio) / SAMPLE_RATE
|
||||
if seconds <= MIN_SPLIT_SEC or depth >= 5:
|
||||
log.warning("не удалось распознать кусок в %.1f с, пропускаю", seconds)
|
||||
return ""
|
||||
log.info("кусок в %.1f с не прошёл, делю пополам", seconds)
|
||||
middle = len(audio) // 2
|
||||
left = self._recognize_safely(audio[:middle], depth + 1)
|
||||
right = self._recognize_safely(audio[middle:], depth + 1)
|
||||
return " ".join(part for part in (left, right) if part)
|
||||
|
||||
def transcribe(self, wav_path: Path, num_speakers: int = 2) -> dict:
|
||||
"""Полный проход: диаризация, распознавание реплик, постобработка."""
|
||||
"""Полный проход: диаризация, распознавание реплик, постобработка.
|
||||
|
||||
Модели грузятся при первой задаче, а не при создании: воркеров несколько,
|
||||
и держать копии моделей у тех, кому работа ещё не досталась, незачем.
|
||||
"""
|
||||
if self._asr is None:
|
||||
raise RuntimeError("модели не загружены, вызовите warmup()")
|
||||
self.warmup()
|
||||
self._reload_replacements()
|
||||
|
||||
samples = read_wav(wav_path)
|
||||
@@ -147,6 +235,12 @@ class Pipeline:
|
||||
t0 = time.time()
|
||||
raw = self._diarizer(num_speakers).process(samples).sort_by_start_time()
|
||||
segments = [Segment(start=s.start, end=s.end, speaker=s.speaker) for s in raw]
|
||||
try:
|
||||
quality = self._separation_quality(samples, segments)
|
||||
except Exception as exc: # noqa: BLE001 - оценка вспомогательная
|
||||
# Метрика не должна ронять задачу: без неё расшифровка всё равно нужна.
|
||||
log.warning("не удалось оценить разделение говорящих: %s", exc)
|
||||
quality = 0.0
|
||||
t_diar = time.time() - t0
|
||||
|
||||
t0 = time.time()
|
||||
@@ -157,24 +251,34 @@ class Pipeline:
|
||||
audio = samples[int(start * SAMPLE_RATE):int(stop * SAMPLE_RATE)]
|
||||
if len(audio) < SAMPLE_RATE * 0.2:
|
||||
continue
|
||||
pieces.append(self._asr.recognize(audio, sample_rate=SAMPLE_RATE))
|
||||
pieces.append(self._recognize_safely(audio))
|
||||
text = " ".join(p.strip() for p in pieces if p and p.strip())
|
||||
if not text:
|
||||
continue
|
||||
text = normalize_typography(apply_replacements(text, self._replacements))
|
||||
audio = samples[int(turn.start * SAMPLE_RATE):int(turn.end * SAMPLE_RATE)]
|
||||
turns_out.append({
|
||||
"speaker": turn.speaker + 1,
|
||||
"start": round(turn.start, 2),
|
||||
"end": round(turn.end, 2),
|
||||
"text": text,
|
||||
# Подсказка для LLM: у говорящего ближе к микрофону громкость
|
||||
# и доля высоких выше. Когда разделение по голосу ненадёжно,
|
||||
# это единственный объективный признак, кто есть кто.
|
||||
"acoustics": segment_acoustics(audio),
|
||||
})
|
||||
t_asr = time.time() - t0
|
||||
|
||||
return {
|
||||
"duration_sec": round(duration, 1),
|
||||
"turns": turns_out,
|
||||
# Готовый материал для разметки ролей на стороне Talkscore: делить
|
||||
# реплики самостоятельно ей незачем, пунктуацию ставит ASR.
|
||||
"sentences": split_sentences(turns_out),
|
||||
"stats": {
|
||||
**speech_stats(segments),
|
||||
"separation_quality": quality,
|
||||
"speakers_reliable": quality >= RELIABLE_SEPARATION,
|
||||
"silence_sec": round(max(0.0, duration - sum(s.end - s.start for s in segments)), 1),
|
||||
"turns_count": len(turns_out),
|
||||
},
|
||||
|
||||
+36
-3
@@ -3,7 +3,8 @@ import ipaddress
|
||||
import secrets
|
||||
from ipaddress import IPv4Network, IPv6Network
|
||||
|
||||
__all__ = ["check_token", "parse_allowlist", "ip_allowed"]
|
||||
__all__ = ["check_token", "token_matches", "parse_allowlist", "ip_allowed",
|
||||
"client_address"]
|
||||
|
||||
Network = IPv4Network | IPv6Network
|
||||
|
||||
@@ -15,12 +16,26 @@ def check_token(header_value: str | None, expected: str) -> bool:
|
||||
иначе забытая настройка молча выставила бы сервис наружу.
|
||||
Сравнение идёт в постоянное время, чтобы токен нельзя было подобрать по таймингам.
|
||||
"""
|
||||
if not expected or not header_value:
|
||||
if not header_value:
|
||||
return False
|
||||
scheme, _, value = header_value.partition(" ")
|
||||
if scheme.lower() != "bearer":
|
||||
return False
|
||||
return secrets.compare_digest(value.strip(), expected)
|
||||
return token_matches(value, expected)
|
||||
|
||||
|
||||
def token_matches(value: str | None, expected: str) -> bool:
|
||||
"""Сверяет голое значение токена, без схемы Bearer.
|
||||
|
||||
Нужно для ссылок вида ?token=: браузер, открывая страницу по ссылке,
|
||||
заголовок Authorization не подставит.
|
||||
"""
|
||||
if not expected or not value:
|
||||
return False
|
||||
# Сравниваем байты, а не строки: compare_digest на строках с не-ASCII
|
||||
# бросает TypeError, и токен с кириллицей давал бы 500 вместо 401.
|
||||
return secrets.compare_digest(value.strip().encode("utf-8"),
|
||||
expected.encode("utf-8"))
|
||||
|
||||
|
||||
def parse_allowlist(raw: str) -> list[Network]:
|
||||
@@ -51,3 +66,21 @@ def ip_allowed(client_ip: str | None, allowlist: list[Network]) -> bool:
|
||||
except ValueError:
|
||||
return False
|
||||
return any(addr in net for net in allowlist)
|
||||
|
||||
|
||||
def client_address(peer_ip: str | None, forwarded: str | None,
|
||||
trusted_proxies: list[Network]) -> str | None:
|
||||
"""Возвращает адрес клиента с учётом обратного прокси.
|
||||
|
||||
Заголовку X-Forwarded-For верим только тогда, когда сам запрос пришёл
|
||||
от доверенного прокси. Иначе любой желающий подставил бы себе разрешённый
|
||||
адрес и обошёл ограничение.
|
||||
"""
|
||||
if not forwarded or not trusted_proxies or not ip_allowed(peer_ip, trusted_proxies):
|
||||
return peer_ip
|
||||
first = forwarded.split(",")[0].strip()
|
||||
try:
|
||||
ipaddress.ip_address(first)
|
||||
except ValueError:
|
||||
return peer_ip
|
||||
return first
|
||||
|
||||
+11
-4
@@ -82,13 +82,20 @@ class JobStore:
|
||||
job["options"] = json.loads(job["options"]) if job["options"] else {}
|
||||
return job
|
||||
|
||||
def take_next(self) -> str | None:
|
||||
"""Возвращает id самой старой задачи в очереди, не меняя её статус."""
|
||||
def claim_next(self) -> str | None:
|
||||
"""Забирает самую старую задачу из очереди и сразу помечает её в работе.
|
||||
|
||||
Выборка и пометка выполняются одним оператором под общим замком: иначе
|
||||
два воркера успевают увидеть одну и ту же задачу и берут её оба.
|
||||
"""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT id FROM jobs WHERE status=? ORDER BY created_at LIMIT 1",
|
||||
(JobStatus.QUEUED,),
|
||||
"UPDATE jobs SET status=?, started_at=? WHERE id = ("
|
||||
" SELECT id FROM jobs WHERE status=? ORDER BY created_at LIMIT 1"
|
||||
") RETURNING id",
|
||||
(JobStatus.RUNNING, time.time(), JobStatus.QUEUED),
|
||||
).fetchone()
|
||||
self._conn.commit()
|
||||
return row["id"] if row else None
|
||||
|
||||
def queue_position(self, job_id: str) -> int:
|
||||
|
||||
+37
-1
@@ -7,7 +7,8 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["apply_replacements", "load_replacements", "normalize_typography"]
|
||||
__all__ = ["apply_replacements", "load_replacements", "normalize_typography",
|
||||
"split_sentences"]
|
||||
|
||||
# Все виды тире, которые встречаются в выводе моделей, включая минус.
|
||||
_DASHES = "‐‑‒–—―−"
|
||||
@@ -57,3 +58,38 @@ def normalize_typography(text: str) -> str:
|
||||
text = _GLUED_QUOTE_RE.sub(r" \1", text)
|
||||
text = _MULTISPACE_RE.sub(" ", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
# Конец предложения: точка, восклицательный, вопросительный или многоточие,
|
||||
# после которых идёт пробел. Пунктуацию расставляет сама модель GigaAM.
|
||||
_SENTENCE_END_RE = re.compile(r"(?<=[.!?…])\s+")
|
||||
|
||||
|
||||
def split_sentences(turns: list[dict]) -> list[dict]:
|
||||
"""Режет реплики на предложения для последующей разметки ролей.
|
||||
|
||||
Разделение по голосам на записях с одним микрофоном обычно не работает,
|
||||
и роли расставляет LLM по смыслу текста. Ей нужны именно предложения:
|
||||
в одну реплику диаризация складывает и вопрос одного человека, и ответ
|
||||
другого, поэтому разметить реплику целиком нельзя.
|
||||
|
||||
Точного времени начала у предложения нет - внутри реплики оно делится
|
||||
поровну. Для сборки диалога обратно этого достаточно, для точной привязки
|
||||
к звуку - нет.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
for index, turn in enumerate(turns):
|
||||
parts = [p.strip() for p in _SENTENCE_END_RE.split(turn["text"]) if p.strip()]
|
||||
if not parts:
|
||||
continue
|
||||
span = (turn["end"] - turn["start"]) / len(parts)
|
||||
loudness = (turn.get("acoustics") or {}).get("loudness_db")
|
||||
for position, text in enumerate(parts):
|
||||
out.append({
|
||||
"n": len(out) + 1,
|
||||
"text": text,
|
||||
"start": round(turn["start"] + position * span, 2),
|
||||
"loudness_db": loudness,
|
||||
"turn": index,
|
||||
})
|
||||
return out
|
||||
|
||||
+89
-33
@@ -9,6 +9,7 @@ Python, библиотеки, ffmpeg и модели остаются на ме
|
||||
"""
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -70,13 +71,19 @@ def _safe_members(archive: zipfile.ZipFile) -> list[str]:
|
||||
return names
|
||||
|
||||
|
||||
def _default_verify(base: Path) -> bool:
|
||||
"""Проверяет, что обновлённый код хотя бы импортируется."""
|
||||
def _default_verify(staging: Path) -> bool:
|
||||
"""Проверяет, что новый код импортируется, ещё до его установки.
|
||||
|
||||
Код берётся из временной папки, рабочая при этом не тронута: если новая
|
||||
версия окажется нерабочей, ставить её не начнём вовсе.
|
||||
"""
|
||||
env = dict(os.environ)
|
||||
env.update({"PYTHONPATH": str(staging)})
|
||||
env.setdefault("TALKSCORE_ASR_HOME", str(staging.parent))
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", "import app.main"],
|
||||
cwd=str(base), capture_output=True, timeout=UPDATE_TIMEOUT_SEC,
|
||||
env={"PYTHONPATH": str(base), "PATH": "", "TALKSCORE_ASR_HOME": str(base)},
|
||||
cwd=str(staging), capture_output=True, timeout=UPDATE_TIMEOUT_SEC, env=env,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
log.error("новая версия не импортируется: %s",
|
||||
@@ -89,48 +96,94 @@ def _default_verify(base: Path) -> bool:
|
||||
|
||||
def install_update(archive_bytes: bytes, base_dir: Path,
|
||||
verify: Callable[[Path], bool] | None = None) -> None:
|
||||
"""Ставит новую версию папки app с возможностью отката."""
|
||||
"""Обновляет файлы внутри app, не трогая саму папку.
|
||||
|
||||
Windows не даёт переместить или удалить папку, из которой сейчас выполняется
|
||||
код, а обновление запускается именно оттуда. Поэтому папка остаётся на месте,
|
||||
а файлы в ней перезаписываются по одному, с копией для отката.
|
||||
"""
|
||||
base = Path(base_dir)
|
||||
verify = verify or _default_verify
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as z:
|
||||
members = _safe_members(z)
|
||||
staging = base / ".update-staging"
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
staging.mkdir(parents=True)
|
||||
for name in members:
|
||||
if Path(name).name in USER_FILES:
|
||||
continue
|
||||
z.extract(name, staging)
|
||||
|
||||
new_app = staging / "app"
|
||||
if not new_app.is_dir():
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise UpdateError("в архиве нет папки app")
|
||||
|
||||
staging = base / ".update-staging"
|
||||
backup = base / ".update-backup"
|
||||
shutil.rmtree(backup, ignore_errors=True)
|
||||
current = base / "app"
|
||||
if current.exists():
|
||||
shutil.move(str(current), str(backup))
|
||||
live = base / "app"
|
||||
|
||||
try:
|
||||
shutil.move(str(new_app), str(current))
|
||||
if not verify(base):
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
staging.mkdir(parents=True)
|
||||
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as z:
|
||||
for name in _safe_members(z):
|
||||
if Path(name).name in USER_FILES:
|
||||
continue
|
||||
z.extract(name, staging)
|
||||
|
||||
if not (staging / "app").is_dir():
|
||||
raise UpdateError("в архиве нет папки app")
|
||||
|
||||
# Проверяем до установки: рабочая версия ещё цела и продолжит работать,
|
||||
# если новая окажется сломанной.
|
||||
if not verify(staging):
|
||||
raise UpdateError("новая версия не прошла проверку запуска")
|
||||
except Exception:
|
||||
shutil.rmtree(current, ignore_errors=True)
|
||||
if backup.exists():
|
||||
shutil.move(str(backup), str(current))
|
||||
raise
|
||||
|
||||
live.mkdir(parents=True, exist_ok=True)
|
||||
shutil.rmtree(backup, ignore_errors=True)
|
||||
shutil.copytree(live, backup)
|
||||
|
||||
try:
|
||||
_sync_files(staging / "app", live)
|
||||
except Exception:
|
||||
_sync_files(backup, live)
|
||||
raise
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
shutil.rmtree(backup, ignore_errors=True)
|
||||
|
||||
|
||||
def _sync_files(src: Path, dst: Path) -> None:
|
||||
"""Приводит dst к состоянию src: копирует файлы и убирает лишние.
|
||||
|
||||
Устаревший модуль, удалённый в новой версии, обязан исчезнуть и здесь,
|
||||
иначе он останется импортируемым и будет вести себя как призрак.
|
||||
"""
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
wanted = set()
|
||||
for item in sorted(src.rglob("*")):
|
||||
rel = item.relative_to(src)
|
||||
target = dst / rel
|
||||
wanted.add(rel)
|
||||
if item.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(item, target)
|
||||
|
||||
for item in sorted(dst.rglob("*"), reverse=True):
|
||||
rel = item.relative_to(dst)
|
||||
if rel in wanted or rel.parts[0] == "__pycache__":
|
||||
continue
|
||||
if item.is_file():
|
||||
item.unlink(missing_ok=True)
|
||||
elif item.is_dir() and not any(item.iterdir()):
|
||||
item.rmdir()
|
||||
|
||||
|
||||
def _api(session, url: str, token: str):
|
||||
headers = {"Authorization": f"token {token}"} if token else {}
|
||||
response = session.get(url, headers=headers, timeout=30)
|
||||
|
||||
# На приватном репозитории Gitea отвечает 404, а не 403: она скрывает сам
|
||||
# факт его существования. Поэтому 404 почти всегда означает не «нет релиза»,
|
||||
# а «этот токен сюда не пускают».
|
||||
if response.status_code in (401, 403, 404):
|
||||
if not token:
|
||||
raise UpdateError(
|
||||
"репозиторий не отдаётся без токена. Впишите его в config.toml, "
|
||||
"раздел [update], поле token (Gitea: Settings - Applications - "
|
||||
"Generate Token, право read:repository)")
|
||||
raise UpdateError(
|
||||
f"Gitea ответила {response.status_code}. Обычно это значит, что токен "
|
||||
"в config.toml неверный или у него нет права read:repository")
|
||||
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
@@ -194,7 +247,10 @@ def main() -> int:
|
||||
else:
|
||||
print(" Установлена последняя версия")
|
||||
except Exception as exc: # noqa: BLE001 - падение обновления не должно мешать работе
|
||||
print(f" Обновиться не удалось ({type(exc).__name__}: {exc}), запускаюсь на текущей версии")
|
||||
# Полный стек requests занимает пол-экрана и пугает, оставляем суть.
|
||||
reason = str(exc).split("(Caused by")[0].strip()[:160] or type(exc).__name__
|
||||
print(f" Обновиться не удалось: {reason}")
|
||||
print(" Запускаюсь на текущей версии")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.14.1"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Отправка готового результата на сторонний адрес.
|
||||
|
||||
Опрос статуса работает, но заставляет принимающую сторону дёргать сервис
|
||||
каждые несколько секунд. Вебхук снимает это: сервис сам постучится, когда
|
||||
задача готова.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
__all__ = ["sign_payload", "deliver", "deliver_async"]
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Задержки между попытками: сразу, через полминуты, через пять минут.
|
||||
# Дольше ждать нет смысла - результат лежит в очереди и его можно забрать.
|
||||
RETRY_DELAYS = (0, 30, 300)
|
||||
TIMEOUT_SEC = 30
|
||||
|
||||
|
||||
def sign_payload(body: bytes, secret: str) -> str:
|
||||
"""Подпись тела запроса, чтобы принимающая сторона знала, что это мы."""
|
||||
return hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def deliver(url: str, payload: dict, secret: str = "",
|
||||
delays: tuple = RETRY_DELAYS) -> bool:
|
||||
"""Отправляет результат, повторяя при неудаче. Возвращает признак успеха."""
|
||||
import requests
|
||||
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
headers = {"Content-Type": "application/json; charset=utf-8"}
|
||||
if secret:
|
||||
headers["X-Talkscore-Signature"] = sign_payload(body, secret)
|
||||
|
||||
for attempt, delay in enumerate(delays, start=1):
|
||||
if delay:
|
||||
time.sleep(delay)
|
||||
try:
|
||||
response = requests.post(url, data=body, headers=headers, timeout=TIMEOUT_SEC)
|
||||
if response.status_code < 300:
|
||||
log.info("вебхук доставлен по задаче %s", payload.get("job_id"))
|
||||
return True
|
||||
log.warning("вебхук: попытка %d, ответ %s", attempt, response.status_code)
|
||||
except Exception as exc: # noqa: BLE001 - причина неважна, важна повторная попытка
|
||||
log.warning("вебхук: попытка %d не удалась (%s)", attempt, exc)
|
||||
log.error("вебхук не доставлен по задаче %s, результат остаётся в очереди",
|
||||
payload.get("job_id"))
|
||||
return False
|
||||
|
||||
|
||||
def deliver_async(url: str, payload: dict, secret: str = "") -> None:
|
||||
"""Отправляет в фоне: воркер не должен ждать чужой сервер."""
|
||||
thread = threading.Thread(target=deliver, args=(url, payload, secret),
|
||||
name="webhook", daemon=True)
|
||||
thread.start()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Обработка задачи в отдельном процессе.
|
||||
|
||||
Замеры показали, что sherpa-onnx и onnxruntime держат GIL: две задачи в двух
|
||||
потоках идут ровно столько же, сколько подряд (выигрыш 1.04x). Поэтому
|
||||
процессор простаивает, а воркеры-потоки бесполезны. В процессах те же две
|
||||
задачи дают 1.59x даже с загрузкой моделей в каждом.
|
||||
|
||||
Модуль намеренно не импортирует app.main: на Windows дочерний процесс
|
||||
поднимается через spawn и заново импортирует модуль с функцией. Если бы
|
||||
это был main, в каждом процессе поднимался бы ещё один веб-сервер.
|
||||
"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from app.pipeline import Pipeline
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Живёт внутри процесса-воркера: модели грузятся один раз на процесс.
|
||||
_pipeline: Pipeline | None = None
|
||||
|
||||
|
||||
def init_worker(models_dir: str, threads: int, replacements_path: str, base_dir: str) -> None:
|
||||
"""Готовит процесс к работе: грузит модели один раз."""
|
||||
global _pipeline
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
_pipeline = Pipeline(models_dir=Path(models_dir), threads=threads,
|
||||
replacements_path=Path(replacements_path),
|
||||
base_dir=Path(base_dir))
|
||||
_pipeline.warmup()
|
||||
|
||||
|
||||
def run_job(audio_path: str, num_speakers: int, ffmpeg: str,
|
||||
normalize: bool = True) -> dict:
|
||||
"""Переводит файл в WAV и распознаёт. Выполняется в процессе-воркере."""
|
||||
import tempfile
|
||||
|
||||
from app.pipeline import to_wav16k
|
||||
|
||||
if _pipeline is None:
|
||||
raise RuntimeError("процесс-воркер не инициализирован")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
wav = Path(tmp) / "audio.wav"
|
||||
to_wav16k(Path(audio_path), wav, ffmpeg, normalize)
|
||||
return _pipeline.transcribe(wav, num_speakers=num_speakers)
|
||||
@@ -23,6 +23,10 @@ PY_URL = f"https://www.python.org/ftp/python/{PY_VERSION}/python-{PY_VERSION}-em
|
||||
# LGPL-сборка: нам нужен только декодер аудио, GPL-компоненты не требуются.
|
||||
FFMPEG_URL = ("https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/"
|
||||
"ffmpeg-master-latest-win64-lgpl.zip")
|
||||
# Caddy сам получает и продлевает сертификаты Let's Encrypt - для машины без
|
||||
# постоянного администратора это надёжнее ручной возни с сертификатами.
|
||||
CADDY_URL = ("https://github.com/caddyserver/caddy/releases/download/v2.10.2/"
|
||||
"caddy_2.10.2_windows_amd64.zip")
|
||||
|
||||
PACKAGES = [
|
||||
"fastapi", "uvicorn", "python-multipart",
|
||||
@@ -82,6 +86,16 @@ def add_ffmpeg(stage: Path) -> None:
|
||||
(bin_dir / "ffmpeg.exe").write_bytes(z.read(name))
|
||||
|
||||
|
||||
def add_caddy(stage: Path) -> None:
|
||||
print("Caddy для HTTPS")
|
||||
data = fetch(CADDY_URL)
|
||||
bin_dir = stage / "bin"
|
||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as z:
|
||||
name = next(n for n in z.namelist() if n.endswith("caddy.exe"))
|
||||
(bin_dir / "caddy.exe").write_bytes(z.read(name))
|
||||
|
||||
|
||||
def add_app(stage: Path) -> None:
|
||||
print("Код сервиса")
|
||||
shutil.copytree(ROOT / "app", stage / "app",
|
||||
@@ -92,6 +106,7 @@ def add_app(stage: Path) -> None:
|
||||
shutil.copy(src, stage / name)
|
||||
write_bat(stage / "start.bat", START_BAT)
|
||||
write_bat(stage / "download_models.bat", DOWNLOAD_BAT)
|
||||
write_bat(stage / "start-https.bat", HTTPS_BAT)
|
||||
(stage / "download_models.py").write_text(
|
||||
(ROOT / "build" / "download_models.py").read_text(encoding="utf-8"), encoding="utf-8")
|
||||
|
||||
@@ -109,13 +124,25 @@ def write_bat(path: Path, text: str) -> None:
|
||||
|
||||
# chcp 65001 переводит консоль в UTF-8, и Python дальше пишет в той же кодировке.
|
||||
# Без этой пары русский текст в выводе превращается в мусор.
|
||||
# .bat - трёхстрочные заглушки: вся логика запуска живёт в app/launcher.py,
|
||||
# который обновление доставляет штатно. Раньше логика была здесь, и чтобы
|
||||
# изменить порядок запуска, требовалась переустановка.
|
||||
# chcp 65001 переводит консоль в UTF-8, иначе русский текст станет мусором.
|
||||
START_BAT = """@echo off
|
||||
chcp 65001 >nul
|
||||
cd /d "%~dp0"
|
||||
set "TALKSCORE_ASR_HOME=%~dp0"
|
||||
set "PYTHONPATH=%~dp0"
|
||||
"%~dp0python\\python.exe" -m app.updater
|
||||
"%~dp0python\\python.exe" -m app.main
|
||||
"%~dp0python\\python.exe" -m app.launcher
|
||||
pause
|
||||
"""
|
||||
|
||||
HTTPS_BAT = """@echo off
|
||||
chcp 65001 >nul
|
||||
cd /d "%~dp0"
|
||||
set "TALKSCORE_ASR_HOME=%~dp0"
|
||||
set "PYTHONPATH=%~dp0"
|
||||
"%~dp0python\\python.exe" -m app.launcher https
|
||||
pause
|
||||
"""
|
||||
|
||||
@@ -144,6 +171,7 @@ def main() -> int:
|
||||
add_wheels(stage)
|
||||
if not args.skip_ffmpeg:
|
||||
add_ffmpeg(stage)
|
||||
add_caddy(stage)
|
||||
add_app(stage)
|
||||
|
||||
archive = OUT / "talkscore-asr-windows.zip"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# HTTPS для сервиса
|
||||
|
||||
Без него токен и расшифровки разговоров идут по сети открытым текстом.
|
||||
Схема простая: Caddy держит сертификаты и HTTPS, сервис сидит на localhost
|
||||
и наружу напрямую не смотрит.
|
||||
|
||||
## Что нужно от вас
|
||||
|
||||
1. **Открыть порты 80 и 443** на роутере и в брандмауэре Windows.
|
||||
Порт 80 нужен Let's Encrypt для проверки владения доменом, 443 - для самой
|
||||
работы. Сейчас оба закрыты.
|
||||
2. **Проверить домены.** `asr.netranking.ru` уже указывает на 90.157.58.23.
|
||||
`asr.talkscore.ru` пока не резолвится - добавьте A-запись на тот же адрес.
|
||||
3. **Порт 8756 можно закрыть снаружи** - после перехода на HTTPS он больше
|
||||
не нужен из интернета.
|
||||
|
||||
## Настройка
|
||||
|
||||
В `config.toml` поменяйте две строки:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
# Сервис больше не смотрит наружу: снаружи стоит Caddy
|
||||
host = "127.0.0.1"
|
||||
|
||||
[security]
|
||||
# Доверяем заголовку с адресом клиента только от своего же прокси
|
||||
trust_proxy = "127.0.0.1"
|
||||
```
|
||||
|
||||
**Зачем `trust_proxy`.** За прокси все запросы приходят с localhost, и список
|
||||
разрешённых адресов увидел бы вместо клиента саму машину - то есть пустил бы
|
||||
кого угодно. Caddy передаёт настоящий адрес в заголовке, и сервис его читает,
|
||||
но **только когда запрос пришёл от доверенного прокси**. Иначе заголовок
|
||||
подделал бы любой желающий.
|
||||
|
||||
Домены прописаны в файле `Caddyfile` рядом со `start.bat`. Если понадобится
|
||||
изменить список - правьте его.
|
||||
|
||||
## Запуск
|
||||
|
||||
Вместо `start.bat` запускайте **`start-https.bat`**: он поднимает сервис на
|
||||
localhost и Caddy перед ним.
|
||||
|
||||
При первом запуске Caddy получит сертификаты - это занимает несколько секунд
|
||||
и требует, чтобы порт 80 был доступен снаружи. Дальше он продлевает их сам,
|
||||
напоминаний не нужно.
|
||||
|
||||
Проверка:
|
||||
|
||||
```
|
||||
curl https://asr.netranking.ru/health
|
||||
```
|
||||
|
||||
## Если сертификат не выдаётся
|
||||
|
||||
Caddy пишет причину в своё окно. Частые случаи:
|
||||
|
||||
- **порт 80 закрыт** - Let's Encrypt не может проверить домен;
|
||||
- **домен не указывает на эту машину** - проверьте A-запись;
|
||||
- **порт 80 занят** другим приложением (IIS, Skype) - освободите его.
|
||||
|
||||
Пока сертификата нет, сервис доступен только по `http://localhost:8756`
|
||||
с самой машины.
|
||||
|
||||
## Автозапуск
|
||||
|
||||
```
|
||||
schtasks /create /tn "talkscore-asr" /tr "C:\talkscore-asr\start-https.bat" ^
|
||||
/sc onstart /ru SYSTEM /rl HIGHEST
|
||||
```
|
||||
@@ -0,0 +1,370 @@
|
||||
# Интеграция Talkscore с локальным ASR-сервисом
|
||||
|
||||
Документ для агента, который будет дорабатывать Talkscore. Описывает, что
|
||||
изменить на стороне Talkscore, чтобы использовать локальный сервис распознавания
|
||||
`talkscore-asr` вместо облачного ASR.
|
||||
|
||||
## Что это за сервис
|
||||
|
||||
Локальный сервис на Windows-машине: принимает аудиофайл, возвращает расшифровку
|
||||
с разделением по говорящим. Работает офлайн, аудио наружу не уходит.
|
||||
|
||||
- Распознавание: GigaAM v3 (SaluteDevices), русский язык, с пунктуацией
|
||||
- Разделение говорящих: sherpa-onnx с моделями pyannote и NeMo TitaNet
|
||||
- Скорость: около 60 минут записи за 2,5 минуты
|
||||
|
||||
Адрес и токен спросите у владельца: сервис закрыт списком разрешённых адресов,
|
||||
поэтому сервер Talkscore нужно в этот список внести.
|
||||
|
||||
## Главное, что нужно изменить в Talkscore
|
||||
|
||||
### 1. Отключить предобработку аудио
|
||||
|
||||
Сейчас в Talkscore включена нормализация громкости (RMS до -20 dBFS) перед
|
||||
отправкой в ASR. **Её нужно выключить** - сервис делает нормализацию сам,
|
||||
причём более подходящую (`dynaudnorm`), и лишний проход только тратит ресурсы
|
||||
сервера.
|
||||
|
||||
Замер на восьми разговорах: нормализация `dynaudnorm` даёт долю второго
|
||||
участника 24,3 % против 3,5 % у нормализации RMS. Двойная обработка не ломает
|
||||
результат, но и не улучшает его.
|
||||
|
||||
Что оставить включённым: ничего из блока предобработки не требуется. VAD и
|
||||
шумоподавление сервису не нужны - у него свой VAD внутри диаризации.
|
||||
|
||||
### 2. Заменить вызов ASR
|
||||
|
||||
Было: отправка в облачный ASR и ожидание ответа.
|
||||
Стало: постановка задачи и получение результата вебхуком.
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
ASR_URL = "http://АДРЕС:8756"
|
||||
ASR_TOKEN = "токен из config.toml сервиса"
|
||||
|
||||
def send_to_asr(file_path: str, call_id: str) -> str:
|
||||
"""Ставит запись в очередь распознавания. Возвращает id задачи."""
|
||||
with open(file_path, "rb") as f:
|
||||
response = requests.post(
|
||||
f"{ASR_URL}/v1/jobs",
|
||||
headers={"Authorization": f"Bearer {ASR_TOKEN}"},
|
||||
params={
|
||||
"speakers": 2,
|
||||
"webhook": f"https://talkscore.ru/api/asr-callback?call_id={call_id}",
|
||||
},
|
||||
files={"file": f},
|
||||
timeout=300,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["job_id"]
|
||||
```
|
||||
|
||||
### 3. Принять результат вебхуком
|
||||
|
||||
Сервис сам постучится, когда задача готова. Подпись тела лежит в заголовке
|
||||
`X-Talkscore-Signature`, секрет задаётся в настройках сервиса.
|
||||
|
||||
```python
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
WEBHOOK_SECRET = "тот же секрет, что в config.toml сервиса"
|
||||
|
||||
def asr_callback(request):
|
||||
signature = request.headers.get("X-Talkscore-Signature", "")
|
||||
expected = hmac.new(WEBHOOK_SECRET.encode(), request.body, hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expected, signature):
|
||||
return 403
|
||||
|
||||
payload = request.json()
|
||||
if payload["status"] != "done":
|
||||
# обработать ошибку: payload["error"]
|
||||
return 200
|
||||
|
||||
save_transcript(payload)
|
||||
return 200
|
||||
```
|
||||
|
||||
Если вебхук не дошёл (три попытки: сразу, через 30 секунд, через 5 минут),
|
||||
результат остаётся в сервисе и его можно забрать опросом:
|
||||
`GET /v1/jobs/{job_id}` с тем же заголовком авторизации.
|
||||
|
||||
## Формат результата
|
||||
|
||||
```json
|
||||
{
|
||||
"job_id": "fd47ba135eae429cbf37fb6ec1d8c34c",
|
||||
"status": "done",
|
||||
"filename": "call.mp3",
|
||||
"duration_sec": 1003.0,
|
||||
"turns": [
|
||||
{
|
||||
"speaker": 1,
|
||||
"start": 7.2,
|
||||
"end": 9.4,
|
||||
"text": "Ну, давайте послушаю вас ещё.",
|
||||
"acoustics": {"loudness_db": -18.4, "hf_ratio": 0.208, "centroid_hz": 1706}
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"speakers": 2,
|
||||
"speech_sec": 557.0,
|
||||
"silence_sec": 446.1,
|
||||
"turns_count": 137,
|
||||
"separation_quality": 0.27,
|
||||
"speakers_reliable": false,
|
||||
"by_speaker": [
|
||||
{"speaker": 1, "speech_sec": 236.8, "share_pct": 42.5},
|
||||
{"speaker": 2, "speech_sec": 320.2, "share_pct": 57.5}
|
||||
]
|
||||
},
|
||||
"sentences": [
|
||||
{"n": 1, "text": "Ну, давайте послушаю вас ещё.", "start": 7.2,
|
||||
"loudness_db": -18.4, "turn": 0}
|
||||
],
|
||||
"timing": {"diarization_sec": 29.1, "asr_sec": 9.7, "realtime_factor": 25.9}
|
||||
}
|
||||
```
|
||||
|
||||
Поле `sentences` - готовый материал для разметки ролей: реплики уже разрезаны
|
||||
по границам предложений, пронумерованы сквозной нумерацией и снабжены громкостью
|
||||
из своей реплики. Делить текст самостоятельно не нужно. `turn` - номер реплики
|
||||
в массиве `turns`, по нему собирают диалог обратно.
|
||||
|
||||
## Самое важное: полю `speaker` доверять нельзя
|
||||
|
||||
Записи делаются одним микрофоном на столе, оба участника в одной акустике.
|
||||
Проверка на восьми разговорах: **разделение по голосам сработало только на одном
|
||||
из восьми**. На остальных один участник получал от 91 до 99 процентов речи,
|
||||
то есть модель просто не различает голоса.
|
||||
|
||||
Что с этим делать:
|
||||
|
||||
1. **Смотрите на `stats.speakers_reliable`.** Если `false` (а это обычный случай),
|
||||
разметку по говорящим нужно строить заново - по смыслу реплик.
|
||||
2. **Роли определяет LLM.** Готовый промпт - в разделе ниже.
|
||||
3. **Границы реплик и тайм-коды достоверны** - их даёт детектор речи, и он
|
||||
работает хорошо. Опираться можно на них, а не на номер говорящего.
|
||||
4. **`acoustics` - подсказка.** У того, кто ближе к микрофону, громкость и доля
|
||||
высоких частот стабильно выше. Это дополнительный сигнал для LLM.
|
||||
|
||||
## Промпт для восстановления ролей
|
||||
|
||||
Расставлять роли поверх реплик диаризации бесполезно: когда она провалилась,
|
||||
в одной реплике оказываются и вопрос, и ответ. Поэтому текст режется на
|
||||
предложения, а модель отвечает одной буквой на предложение - так выход
|
||||
получается в десятки раз короче, чем при возврате текста.
|
||||
|
||||
Подготовка не нужна: сервис отдаёт готовый массив `sentences`. Соберите из него
|
||||
список вида `1. Текст предложения` и подставьте в `{sentences}`, а число
|
||||
предложений - в `{count}`.
|
||||
|
||||
```
|
||||
Ты разбираешь запись разговора в автошколе: менеджер и клиент.
|
||||
Микрофон стоял на столе, автоматическое разделение по голосам не сработало,
|
||||
поэтому реплики склеены - в одной строке может быть и вопрос одного,
|
||||
и ответ другого.
|
||||
|
||||
Ниже пронумерованные предложения по порядку. Определи для каждого, кто его
|
||||
произнёс.
|
||||
|
||||
Как отличить:
|
||||
- МЕНЕДЖЕР (M): рассказывает об условиях, ценах, документах, расписании;
|
||||
отвечает на вопросы; предлагает записаться; говорит «у нас», «мы»,
|
||||
«вам нужно принести», называет суммы и сроки.
|
||||
- КЛИЕНТ (C): спрашивает про стоимость, сроки, расписание; рассказывает
|
||||
о себе и своей ситуации; сомневается; сравнивает с другими автошколами;
|
||||
соглашается или уточняет; говорит «а если», «мне нужно», «я слышал».
|
||||
|
||||
Подсказки:
|
||||
- Вопрос и ответ на него принадлежат разным людям.
|
||||
- Короткие «да», «ага», «понятно», «конечно» обычно принадлежат слушающему,
|
||||
то есть тому, кто НЕ произносил предыдущее длинное объяснение.
|
||||
- Менеджер говорит больше, но не непрерывно: клиент постоянно вставляет
|
||||
короткие реплики.
|
||||
|
||||
Ответь строкой ровно из {count} символов, только M и C, без пробелов и переносов,
|
||||
по одному символу на предложение в том же порядке.
|
||||
|
||||
Предложения:
|
||||
{sentences}
|
||||
```
|
||||
|
||||
После ответа склейте соседние предложения с одинаковой ролью обратно в реплики,
|
||||
сохранив время начала первого предложения каждой группы.
|
||||
|
||||
Время внутри реплики делится между её предложениями поровну - точной привязки
|
||||
к звуку у отдельного предложения нет. Для сборки диалога этого достаточно,
|
||||
для перемотки к конкретной фразе - нет.
|
||||
|
||||
**Где делать этот шаг.** На стороне Talkscore, а не в ASR. Промпт будет меняться,
|
||||
и переразметить сохранённые расшифровки дешевле, чем гонять аудио заново. К тому
|
||||
же ASR остаётся локальным, бесплатным и детерминированным, а обращение к LLM -
|
||||
платное и внешнее: недоступность OpenRouter не должна ронять распознавание.
|
||||
|
||||
## Словарь замен терминов
|
||||
|
||||
В сервисе лежит файл `replacements.txt` со словарём под автошколу: категории
|
||||
прав, документы, госорганы, термины обучения и оплаты, частые ошибки
|
||||
распознавания. Формат `что слышно = как надо`, замена по целым словам,
|
||||
регистр не важен.
|
||||
|
||||
Файл перечитывается перед каждой задачей, перезапуск не нужен. Если в
|
||||
расшифровках попадаются устойчивые ошибки - дописывайте строки туда.
|
||||
|
||||
## Замеры на восьми реальных записях
|
||||
|
||||
Общая длительность 205 минут (от 15 до 44 минут каждая), офисные разговоры
|
||||
менеджера с клиентом, микрофон на столе.
|
||||
|
||||
### Время обработки
|
||||
|
||||
| Этап | Время на 205 минут | Доля |
|
||||
|---|---|---|
|
||||
| Разделение говорящих | 13,8 мин | 78 % |
|
||||
| Распознавание речи | 3,7 мин | 21 % |
|
||||
| Итого на машине с Ryzen 9 9950X | около 8,5 мин | - |
|
||||
|
||||
Разделение говорящих занимает почти всё время - распознавание в четыре раза
|
||||
быстрее. На Ryzen при четырёх процессах по четыре потока выходит около
|
||||
x24 к реальному времени: час записи за две с половиной минуты.
|
||||
|
||||
Разметка ролей через LLM добавляет 2-4 минуты на разговор - модель читает
|
||||
весь текст целиком. Это делается параллельно и на пропускную способность
|
||||
сервиса не влияет.
|
||||
|
||||
### Стоимость разметки ролей
|
||||
|
||||
Замеряно на записи в 15 минут (160 предложений). Обе проверенные модели -
|
||||
рассуждающие, и по умолчанию они тратят на размышления тысячи токенов.
|
||||
Это решающий фактор цены.
|
||||
|
||||
| Модель | Размышления | Выходных токенов | Время | Цена за 15 мин | За час |
|
||||
|---|---|---|---|---|---|
|
||||
| Claude Sonnet 5 | по умолчанию | 17 791 | 137 с | $0,188 | $0,75 |
|
||||
| Claude Sonnet 5 | минимальные | 897 | 9 с | $0,019 | $0,076 |
|
||||
| Claude Sonnet 5 | выключены | 375 | 7 с | $0,014 | $0,055 |
|
||||
| Gemini 3.1 Pro | по умолчанию | 13 715 | 85 с | $0,172 | $0,69 |
|
||||
| Gemini 3.1 Pro | минимальные | 5 354 | 32 с | $0,071 | $0,29 |
|
||||
|
||||
Отключение размышлений даёт разницу в тринадцать раз по цене и в двадцать
|
||||
по времени. Но за это платится качеством: без размышлений модель верно
|
||||
размечает начало разговора, а дальше начинает менять роли местами -
|
||||
объяснения менеджера приписывает клиенту и наоборот.
|
||||
|
||||
Разумный компромисс - минимальные размышления: цена $0,076 за час против
|
||||
$0,75 при полных, а разметка заметно устойчивее, чем при полностью
|
||||
выключенных. Проверьте на своих записях, прежде чем ставить в поток.
|
||||
|
||||
У OpenRouter это задаётся полем запроса:
|
||||
|
||||
```json
|
||||
{"model": "anthropic/claude-sonnet-5",
|
||||
"reasoning": {"effort": "low"},
|
||||
"messages": [...]}
|
||||
```
|
||||
|
||||
### Половинная цена через batch
|
||||
|
||||
У OpenRouter есть отдельные модели с суффиксом `:batch` - вдвое дешевле, ответ
|
||||
в течение часа. Для разбора завершившегося звонка это подходит: результат
|
||||
мгновенно не нужен.
|
||||
|
||||
| Модель | Вход за 1 млн | Выход за 1 млн | 30-минутная запись |
|
||||
|---|---|---|---|
|
||||
| `anthropic/claude-sonnet-5` | $2,00 | $10,00 | $0,188 |
|
||||
| `anthropic/claude-sonnet-5:batch` | $1,00 | $5,00 | **$0,094** |
|
||||
|
||||
Вводная цена Sonnet 5 действует до 31 августа 2026 года, после чего станет
|
||||
$3 и $15 за миллион - все цифры вырастут в полтора раза.
|
||||
|
||||
Кэширование промпта здесь почти ничего не даёт: 95 % счёта - это токены
|
||||
размышлений на выходе, а вход занимает около пяти тысяч токенов.
|
||||
|
||||
### Модели, которые не подошли
|
||||
|
||||
Проверены на той же записи тем же промптом. Экономия была бы большой, но
|
||||
разметка неверная, а ради неё всё и делается.
|
||||
|
||||
| Модель | Итог |
|
||||
|---|---|
|
||||
| deepseek-v4-pro | ответа нет: весь лимит уходит в размышления |
|
||||
| deepseek-v4-flash | формат держит, но склеивает вопрос с ответом |
|
||||
| deepseek-v3.2 | сваливает почти всё на одного говорящего |
|
||||
| deepseek-r1-0528 | упирается в потолок токенов, выдаёт мусор |
|
||||
| claude-haiku-4.5 | в трёх режимах из четырёх зацикливается на `MCMCMC` |
|
||||
|
||||
Общая причина: все они не удерживают правило «вопрос и ответ принадлежат разным
|
||||
людям» на дистанции в две с лишним сотни предложений. Справляются только
|
||||
Sonnet 5 и Gemini 3.1 Pro.
|
||||
|
||||
Отдельно: этим моделям нельзя задавать большой `max_tokens` - они выбирают его
|
||||
целиком на размышления и до ответа не доходят.
|
||||
|
||||
### Проверяйте формат ответа, а не только его наличие
|
||||
|
||||
Модель может вернуть пустую строку, прозу вместо букв, оборванный ответ или
|
||||
зациклиться на `MCMCMC`. Если недостающие буквы молча дописывать, получится
|
||||
правдоподобная на вид разметка из ничего. Проверяйте перед разбором:
|
||||
|
||||
- ответ непустой и состоит только из `M` и `C`;
|
||||
- длина совпадает с числом предложений;
|
||||
- `finish_reason` равен `stop`, а не `length`;
|
||||
- доля смен говорящего не близка к 100 % - это признак зацикливания.
|
||||
|
||||
### Качество
|
||||
|
||||
**Разделение по голосам сработало на одной записи из восьми.** На остальных
|
||||
один участник получал от 91 до 99 процентов речи.
|
||||
|
||||
| Запись | Доли говорящих | Оценка разделимости |
|
||||
|---|---|---|
|
||||
| 9272254c | 44 / 56 | 0,296 |
|
||||
| 470e4858 | 91 / 9 | 0,256 |
|
||||
| 02fef7a0 | 98 / 2 | 0,236 |
|
||||
| 7444931c | 98 / 2 | 0,207 |
|
||||
| 8dc00a27 | 99 / 1 | 0,310 |
|
||||
| 6886a557 | 95 / 5 | 0,134 |
|
||||
| ac20d666 | 1 / 99 | 0,429 |
|
||||
| e287ae8b | 98 / 2 | 0,161 |
|
||||
|
||||
Для сравнения: на заведомо разных голосах оценка разделимости даёт 0,53,
|
||||
на случайной разметке - 0,11. Все записи лежат в диапазоне 0,13-0,43.
|
||||
|
||||
**LLM восстанавливает диалог даже там, где разделение провалилось полностью.**
|
||||
На записи с долями 98/2 Claude Sonnet 5 дал осмысленную структуру:
|
||||
|
||||
```
|
||||
МЕНЕДЖЕР: Категорию Б обсуждали?
|
||||
КЛИЕНТ: Категорию Б.
|
||||
МЕНЕДЖЕР: Теорию Б как хотели изучить?
|
||||
КЛИЕНТ: А-а, онлайн.
|
||||
КЛИЕНТ: Что из себя представляет внутренний экзамен по теории?
|
||||
МЕНЕДЖЕР: Вопросов допускается две ошибки...
|
||||
```
|
||||
|
||||
Итог по разметке: 80 % реплик у менеджера, смена говорящего в 28 % переходов -
|
||||
правдоподобно для консультации.
|
||||
|
||||
**Про Gemini 3.1 Pro.** На одной записи он вернул 100 % реплик менеджеру и ни
|
||||
одной смены, то есть провалился полностью. На другой отработал нормально:
|
||||
82 % менеджера, 32 % смен - на уровне Claude. Причина такого разброса не
|
||||
установлена, поэтому в поток его ставить рискованно без дополнительной
|
||||
проверки на своих данных.
|
||||
|
||||
**Передавать громкость реплик в промпт бесполезно**: с ней 81 % против 80 % без
|
||||
неё, разницы нет. Экономьте токены и не передавайте.
|
||||
|
||||
## Что ещё стоит знать
|
||||
|
||||
- **Форматы**: принимается всё, что читает ffmpeg - mp3, wav, m4a, ogg, opus, wma.
|
||||
- **Размер**: по умолчанию до 500 МБ, настраивается.
|
||||
- **Очередь**: задачи обрабатываются по очереди, статус и место в очереди
|
||||
видны в `GET /v1/jobs/{id}`.
|
||||
- **Диагностика**: `GET /health` работает без токена, показывает версию,
|
||||
очередь и то, каким сервис видит адрес обратившегося.
|
||||
- **Журнал**: `GET /v1/logs?level=ERROR` - последние записи, чтобы разбирать
|
||||
сбои не заходя на машину.
|
||||
- **Описание методов**: `http://АДРЕС:8756/docs` в браузере, открывается
|
||||
с разрешённых адресов.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+127
-26
@@ -1,35 +1,136 @@
|
||||
# Словарь замен: что модель слышит = как должно быть в тексте
|
||||
# Словарь замен для расшифровок разговоров автошколы
|
||||
#
|
||||
# GigaAM обучена только на русском и коверкает английские термины предсказуемо -
|
||||
# здесь это чинится. Регистр при поиске не важен, замена идёт только по целым
|
||||
# словам, поэтому «лед» не тронет «лидер».
|
||||
# GigaAM обучена только на русском и предсказуемо коверкает термины, аббревиатуры
|
||||
# и английские слова. Здесь это чинится. Правила применяются к целым словам,
|
||||
# регистр не важен: правило «лед = лид» не тронет слово «лидер».
|
||||
#
|
||||
# Файл перечитывается перед каждой задачей: правки применяются без перезапуска.
|
||||
# Файл перечитывается перед каждой задачей - правки применяются без перезапуска.
|
||||
# Порядок значения не имеет: длинные правила применяются раньше коротких.
|
||||
|
||||
# Инструменты и сервисы
|
||||
гугл так менеджер = Google Tag Manager
|
||||
гугл тэг менеджер = Google Tag Manager
|
||||
google так менеджер = Google Tag Manager
|
||||
google tack-менеджер = Google Tag Manager
|
||||
так менеджер = Tag Manager
|
||||
джава-скрипт = JavaScript
|
||||
джаваскрипт = JavaScript
|
||||
битрикс 24 = Битрикс24
|
||||
амо црм = amoCRM
|
||||
амоцрм = amoCRM
|
||||
# ── Документы и категории ───────────────────────────────────────────────────
|
||||
ву = В/У
|
||||
в у = В/У
|
||||
водительское удостоверение = водительское удостоверение
|
||||
ноль три ву = 003 В/У
|
||||
справка ноль три = справка 003 В/У
|
||||
мед справка = медсправка
|
||||
мед комиссия = медкомиссия
|
||||
мед заключение = медзаключение
|
||||
снилс = СНИЛС
|
||||
инн = ИНН
|
||||
категория а = категория A
|
||||
категория б = категория B
|
||||
категория бэ = категория B
|
||||
категория в = категория B
|
||||
категория це = категория C
|
||||
категория эс = категория C
|
||||
категория д = категория D
|
||||
категория дэ = категория D
|
||||
подкатегория а один = подкатегория A1
|
||||
b категория = категория B
|
||||
би категория = категория B
|
||||
а категория = категория A
|
||||
|
||||
# Термины продаж и маркетинга
|
||||
# ── Госорганы и системы ─────────────────────────────────────────────────────
|
||||
гибдд = ГИБДД
|
||||
гаи = ГАИ
|
||||
агаи = ГАИ
|
||||
мрэо = МРЭО
|
||||
мреа = МРЭО
|
||||
госуслуги = Госуслуги
|
||||
гос услуги = Госуслуги
|
||||
мфц = МФЦ
|
||||
цодд = ЦОДД
|
||||
|
||||
# ── Обучение ────────────────────────────────────────────────────────────────
|
||||
теоретический экзамен = теоретический экзамен
|
||||
внутренний экзамен = внутренний экзамен
|
||||
теория онлайн = теория онлайн
|
||||
дистанционка = дистанционное обучение
|
||||
очка = очное обучение
|
||||
видео уроки = видеоуроки
|
||||
видио уроки = видеоуроки
|
||||
рубежка = рубежное тестирование
|
||||
рубежи = рубежные тестирования
|
||||
пдд = ПДД
|
||||
пэдэдэ = ПДД
|
||||
билеты пдд = билеты ПДД
|
||||
автодром = автодром
|
||||
площадка = автодром
|
||||
город = город
|
||||
змейка = змейка
|
||||
эстакада = эстакада
|
||||
параллельная парковка = параллельная парковка
|
||||
разворот в ограниченном пространстве = разворот в ограниченном пространстве
|
||||
|
||||
# ── Люди и роли ─────────────────────────────────────────────────────────────
|
||||
инструктор = инструктор
|
||||
старший инструктор = старший инструктор
|
||||
главный инспектор = старший инструктор
|
||||
курсант = курсант
|
||||
курсанты = курсанты
|
||||
мастер производственного обучения = мастер производственного обучения
|
||||
мпо = МПО
|
||||
|
||||
# ── Деньги и оплата ─────────────────────────────────────────────────────────
|
||||
рассрочка = рассрочка
|
||||
предоплата = предоплата
|
||||
доплата = доплата
|
||||
безнал = безналичный расчёт
|
||||
безналичка = безналичный расчёт
|
||||
нал = наличные
|
||||
терминал = терминал
|
||||
эквайринг = эквайринг
|
||||
чек = чек
|
||||
квитанция = квитанция
|
||||
материнский капитал = материнский капитал
|
||||
налоговый вычет = налоговый вычет
|
||||
|
||||
# ── Английские термины и сервисы ────────────────────────────────────────────
|
||||
вотсап = WhatsApp
|
||||
ватсап = WhatsApp
|
||||
воцап = WhatsApp
|
||||
телеграм = Telegram
|
||||
телега = Telegram
|
||||
вайбер = Viber
|
||||
смс = СМС
|
||||
онлайн = онлайн
|
||||
оффлайн = офлайн
|
||||
кэшбэк = кешбэк
|
||||
кешбек = кешбэк
|
||||
приложуха = приложение
|
||||
личный кабинет = личный кабинет
|
||||
црм = CRM
|
||||
срм = CRM
|
||||
кпи = KPI
|
||||
|
||||
# ── Продажи и переговоры ────────────────────────────────────────────────────
|
||||
ледами = лидами
|
||||
леды = лиды
|
||||
лед = лид
|
||||
кол-трекинг = коллтрекинг
|
||||
колл трекинг = коллтрекинг
|
||||
ретаргет = ретаргетинг
|
||||
конверсионка = конверсия
|
||||
заявка = заявка
|
||||
запись на пробное = запись на пробное занятие
|
||||
пробное занятие = пробное занятие
|
||||
акция = акция
|
||||
скидка = скидка
|
||||
абонемент = абонемент
|
||||
договор = договор
|
||||
оферта = оферта
|
||||
предоплату = предоплату
|
||||
|
||||
# Часто путаемые сокращения
|
||||
црм = CRM
|
||||
кпи = KPI
|
||||
срм = CRM
|
||||
# ── Частые ошибки распознавания на плохом звуке ─────────────────────────────
|
||||
щас = сейчас
|
||||
чё = что
|
||||
чо = что
|
||||
тыща = тысяча
|
||||
тыщи = тысячи
|
||||
тыщ = тысяч
|
||||
пятсот = пятьсот
|
||||
шестсот = шестьсот
|
||||
восемсот = восемьсот
|
||||
сюдой = сюда
|
||||
тудой = туда
|
||||
ага = ага
|
||||
угу = угу
|
||||
|
||||
# Добавляйте свои строки ниже. Формат: что слышно = как надо
|
||||
# ── Добавляйте свои строки ниже. Формат: что слышно = как надо ──────────────
|
||||
|
||||
+230
-9
@@ -30,7 +30,8 @@ def client(tmp_path, monkeypatch):
|
||||
import app.config as cfg
|
||||
monkeypatch.setattr(cfg, "BASE_DIR", tmp_path)
|
||||
import app.main as main
|
||||
monkeypatch.setattr(main.pipeline, "ffmpeg", "/bin/true", raising=False)
|
||||
# ffmpeg ищется лениво, поэтому подменяем внутреннее поле, а не свойство
|
||||
monkeypatch.setattr(main.pipeline, "_ffmpeg", "/bin/true", raising=False)
|
||||
main._state["ready"] = True
|
||||
main._worker_stop.set() # воркер не должен разбирать очередь в тестах
|
||||
|
||||
@@ -112,13 +113,233 @@ class TestHealth:
|
||||
|
||||
|
||||
class TestSchemaExposure:
|
||||
"""Схема API не требует токена, поэтому по умолчанию она закрыта."""
|
||||
"""Штатный redoc остаётся выключенным: своя страница только одна."""
|
||||
|
||||
def test_docs_hidden_by_default(self, client):
|
||||
assert client.get("/docs").status_code == 404
|
||||
|
||||
def test_openapi_hidden_by_default(self, client):
|
||||
assert client.get("/openapi.json").status_code == 404
|
||||
|
||||
def test_redoc_hidden_by_default(self, client):
|
||||
def test_redoc_is_off(self, client):
|
||||
assert client.get("/redoc").status_code == 404
|
||||
|
||||
|
||||
class TestHealthDiagnostics:
|
||||
"""Без этих полей отказ по адресу отлаживается вслепую."""
|
||||
|
||||
def test_reports_client_ip(self, client):
|
||||
body = client.get("/health").json()
|
||||
assert "your_ip" in body
|
||||
|
||||
def test_reports_verdict_on_client_ip(self, client):
|
||||
body = client.get("/health").json()
|
||||
assert body["your_ip_allowed"] is True # в тестовом конфиге список пуст
|
||||
|
||||
def test_reports_whether_filter_is_active(self, client):
|
||||
assert client.get("/health").json()["ip_filter_active"] is False
|
||||
|
||||
def test_reports_version(self, client):
|
||||
from app.version import __version__
|
||||
|
||||
assert client.get("/health").json()["version"] == __version__
|
||||
|
||||
def test_does_not_leak_allowlist(self, client):
|
||||
"""Список разрешённых адресов - не для посторонних глаз."""
|
||||
body = client.get("/health").json()
|
||||
assert "allow_ips" not in body and "allowlist" not in body
|
||||
|
||||
|
||||
class TestConfigFileIsNotCode:
|
||||
"""Настройки правятся в config.toml; app/config.py - код и обновляется."""
|
||||
|
||||
def test_update_archive_never_carries_user_config(self):
|
||||
from app.updater import USER_FILES
|
||||
|
||||
assert "config.toml" in USER_FILES
|
||||
|
||||
def test_config_module_warns_it_is_not_settings(self):
|
||||
from pathlib import Path
|
||||
|
||||
src = Path(__file__).resolve().parent.parent / "app" / "config.py"
|
||||
text = src.read_text(encoding="utf-8")
|
||||
assert "ЭТО КОД, А НЕ НАСТРОЙКИ" in text
|
||||
|
||||
|
||||
class TestDocsPage:
|
||||
"""Страница с методами полезна, но открывать её всем подряд незачем."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def docs_on(self, client, monkeypatch):
|
||||
"""По умолчанию документация выключена, здесь проверяется включённая.
|
||||
|
||||
Зависимость от client обязательна: та фикстура переимпортирует
|
||||
app.main, и патч без неё лёг бы на выброшенный модуль.
|
||||
"""
|
||||
monkeypatch.setattr(sys.modules["app.main"].settings, "docs", True)
|
||||
|
||||
def test_docs_available_with_token(self, client):
|
||||
assert client.get("/docs").status_code == 200
|
||||
|
||||
def test_openapi_available_with_token(self, client):
|
||||
assert client.get("/openapi.json").status_code == 200
|
||||
|
||||
def test_docs_need_token(self, client):
|
||||
"""Раньше страница открывалась без токена - с выключенным списком
|
||||
адресов это означало открытый доступ из интернета."""
|
||||
client.headers.pop("Authorization")
|
||||
assert client.get("/docs").status_code == 401
|
||||
|
||||
def test_schema_declares_bearer_auth(self, client):
|
||||
schema = client.get("/openapi.json").json()
|
||||
assert "bearer" in schema["components"]["securitySchemes"]
|
||||
|
||||
def test_schema_lists_all_endpoints(self, client):
|
||||
paths = client.get("/openapi.json").json()["paths"]
|
||||
assert "/v1/jobs" in paths and "/health" in paths
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def restricted_client(tmp_path, monkeypatch):
|
||||
"""Приложение со списком адресов, куда клиент тестов не входит."""
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text(
|
||||
f'[server]\nhost="127.0.0.1"\nport=8756\n'
|
||||
f'[security]\ntoken="{TOKEN}"\nallow_ips="10.1.2.3"\n'
|
||||
f'[processing]\nthreads=1\n',
|
||||
encoding="utf-8")
|
||||
monkeypatch.setenv("TALKSCORE_ASR_HOME", str(tmp_path))
|
||||
for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"):
|
||||
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
|
||||
for mod in [m for m in list(sys.modules) if m.startswith("app.")]:
|
||||
del sys.modules[mod]
|
||||
import app.config as cfg
|
||||
monkeypatch.setattr(cfg, "BASE_DIR", tmp_path)
|
||||
import app.main as main
|
||||
monkeypatch.setattr(main.pipeline, "_ffmpeg", "/bin/true", raising=False)
|
||||
main._state["ready"] = True
|
||||
main._worker_stop.set()
|
||||
with TestClient(main.app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
class TestDocsAccessControl:
|
||||
def test_docs_closed_for_foreign_ip(self, restricted_client, monkeypatch):
|
||||
monkeypatch.setattr(sys.modules["app.main"].settings, "docs", True)
|
||||
assert restricted_client.get("/docs").status_code == 403
|
||||
|
||||
def test_openapi_closed_for_foreign_ip(self, restricted_client, monkeypatch):
|
||||
monkeypatch.setattr(sys.modules["app.main"].settings, "docs", True)
|
||||
assert restricted_client.get("/openapi.json").status_code == 403
|
||||
|
||||
def test_docs_hidden_when_disabled(self, restricted_client):
|
||||
"""Выключенная документация отвечает одинаково всем: её как бы нет."""
|
||||
assert restricted_client.get("/docs").status_code == 404
|
||||
|
||||
def test_health_stays_open_for_foreign_ip(self, restricted_client):
|
||||
"""Мониторинг должен работать всегда."""
|
||||
assert restricted_client.get("/health").status_code == 200
|
||||
|
||||
|
||||
class TestLogs:
|
||||
def test_logs_require_token(self, client):
|
||||
client.headers.pop("Authorization")
|
||||
assert client.get("/v1/logs").status_code == 401
|
||||
|
||||
def test_logs_return_records(self, client):
|
||||
import logging
|
||||
|
||||
from app.logbuffer import install
|
||||
|
||||
install()
|
||||
logging.getLogger("talkscore-asr").error("тестовая запись")
|
||||
body = client.get("/v1/logs").json()
|
||||
assert any("тестовая запись" in r["message"] for r in body["records"])
|
||||
|
||||
def test_logs_filter_by_level(self, client):
|
||||
import logging
|
||||
|
||||
from app.logbuffer import install
|
||||
|
||||
install()
|
||||
log = logging.getLogger("talkscore-asr")
|
||||
log.info("обычная строка")
|
||||
log.error("строка об ошибке")
|
||||
body = client.get("/v1/logs?level=ERROR").json()
|
||||
assert all(r["level"] == "ERROR" for r in body["records"])
|
||||
|
||||
def test_buffer_keeps_only_recent(self):
|
||||
from app.logbuffer import LogBuffer
|
||||
|
||||
buf = LogBuffer(capacity=10)
|
||||
import logging
|
||||
for i in range(50):
|
||||
buf.emit(logging.LogRecord("t", logging.INFO, "f", 1, f"строка {i}", None, None))
|
||||
rows = buf.tail(limit=100)
|
||||
assert len(rows) == 10 and "строка 49" in rows[-1]["message"]
|
||||
|
||||
|
||||
class TestBrokenConfig:
|
||||
"""Со сломанным config.toml сервис должен объяснить причину, а не падать стеком."""
|
||||
|
||||
def test_duplicate_section_gives_readable_error(self, tmp_path):
|
||||
from app.config import ConfigError, load_settings
|
||||
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text('[security]\ntoken="a"\n[security]\ntrust_proxy="127.0.0.1"\n',
|
||||
encoding="utf-8")
|
||||
with pytest.raises(ConfigError, match="дважды"):
|
||||
load_settings(config)
|
||||
|
||||
def test_error_names_the_file(self, tmp_path):
|
||||
from app.config import ConfigError, load_settings
|
||||
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text('[server]\n[server]\n', encoding="utf-8")
|
||||
with pytest.raises(ConfigError, match="config.toml"):
|
||||
load_settings(config)
|
||||
|
||||
def test_valid_config_still_loads(self, tmp_path):
|
||||
from app.config import load_settings
|
||||
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text('[security]\ntoken="a"\ntrust_proxy="127.0.0.1"\n', encoding="utf-8")
|
||||
assert load_settings(config).token == "a"
|
||||
|
||||
|
||||
|
||||
class TestDocsAccess:
|
||||
"""Документация показывает устройство API, поэтому закрыта так же, как маршруты."""
|
||||
|
||||
@staticmethod
|
||||
def module():
|
||||
return sys.modules["app.main"]
|
||||
|
||||
@staticmethod
|
||||
def anonymous(client, path):
|
||||
"""Фикстура подставляет верный токен всем запросам - здесь он мешает."""
|
||||
return client.get(path, headers={"Authorization": ""})
|
||||
|
||||
def test_disabled_docs_answer_404(self, client):
|
||||
# Именно 404, а не 403: незачем подтверждать, что страница есть.
|
||||
for path in ("/docs", "/openapi.json"):
|
||||
assert client.get(path).status_code == 404
|
||||
|
||||
def test_enabled_docs_need_token(self, client, monkeypatch):
|
||||
monkeypatch.setattr(self.module().settings, "docs", True)
|
||||
for path in ("/docs", "/openapi.json"):
|
||||
assert self.anonymous(client, path).status_code == 401
|
||||
assert self.anonymous(client, f"{path}?token=chuzhoy").status_code == 401
|
||||
|
||||
def test_token_in_query_opens_docs(self, client, monkeypatch):
|
||||
"""Браузер по ссылке заголовок не подставит, поэтому нужен ?token=."""
|
||||
monkeypatch.setattr(self.module().settings, "docs", True)
|
||||
for path in ("/docs", "/openapi.json"):
|
||||
assert self.anonymous(client, f"{path}?token={TOKEN}").status_code == 200
|
||||
|
||||
def test_token_in_header_opens_docs(self, client, monkeypatch):
|
||||
monkeypatch.setattr(self.module().settings, "docs", True)
|
||||
assert client.get("/docs").status_code == 200
|
||||
|
||||
def test_schema_link_carries_token(self, client, monkeypatch):
|
||||
"""Иначе страница откроется и тут же покажет ошибку доступа к схеме."""
|
||||
monkeypatch.setattr(self.module().settings, "docs", True)
|
||||
monkeypatch.setattr(self.module().settings, "token", "token s probelom i-slashem")
|
||||
page = self.anonymous(client, "/docs?token=token s probelom i-slashem").text
|
||||
assert "openapi.json?token=token%20s%20probelom%20i-slashem" in page
|
||||
# Незакодированный пробел разорвал бы адрес, и схема не загрузилась бы.
|
||||
assert "openapi.json?token=token s" not in page
|
||||
|
||||
+10
-5
@@ -70,8 +70,13 @@ class TestDownloadScript:
|
||||
|
||||
|
||||
class TestUpdateInStartup:
|
||||
def test_start_bat_runs_updater_before_service(self, build_mod):
|
||||
"""Обновление должно применяться до запуска, иначе смысла в нём нет."""
|
||||
text = build_mod.START_BAT
|
||||
assert "-m app.updater" in text
|
||||
assert text.index("-m app.updater") < text.index("-m app.main")
|
||||
def test_bat_only_delegates_to_launcher(self, build_mod):
|
||||
"""Логика запуска живёт в app, иначе её не доставить обновлением.
|
||||
|
||||
Сам порядок «обновиться, потом запуститься» проверяется в
|
||||
tests/test_launcher.py - здесь важно лишь, что .bat ничего не решает.
|
||||
"""
|
||||
for text in (build_mod.START_BAT, build_mod.HTTPS_BAT):
|
||||
assert "-m app.launcher" in text
|
||||
assert "-m app.main" not in text
|
||||
assert "caddy" not in text.lower()
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Тесты параллельной обработки.
|
||||
|
||||
Замеры показали: обе стадии упираются в 4 потока, а на 16 работают вчетверо
|
||||
медленнее. Значит ядра нужно занимать не шириной одной задачи, а несколькими
|
||||
задачами сразу - и тогда очередь обязана быть устойчивой к гонкам.
|
||||
"""
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from app.store import JobStatus, JobStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
return JobStore(tmp_path / "jobs.db")
|
||||
|
||||
|
||||
class TestClaimIsAtomic:
|
||||
def test_claim_marks_running(self, store):
|
||||
job_id = store.create(filename="a.wav", duration_sec=1.0)
|
||||
assert store.claim_next() == job_id
|
||||
assert store.get(job_id)["status"] == JobStatus.RUNNING
|
||||
|
||||
def test_second_claim_gets_nothing(self, store):
|
||||
store.create(filename="a.wav", duration_sec=1.0)
|
||||
store.claim_next()
|
||||
assert store.claim_next() is None
|
||||
|
||||
def test_each_job_claimed_once_under_load(self, store):
|
||||
"""Главное требование: два воркера не должны взять одну задачу."""
|
||||
ids = {store.create(filename=f"{i}.wav", duration_sec=1.0) for i in range(50)}
|
||||
claimed: list[str] = []
|
||||
lock = threading.Lock()
|
||||
|
||||
def worker():
|
||||
while True:
|
||||
job_id = store.claim_next()
|
||||
if job_id is None:
|
||||
return
|
||||
with lock:
|
||||
claimed.append(job_id)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(8)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert len(claimed) == len(set(claimed)) == 50
|
||||
assert set(claimed) == ids
|
||||
|
||||
def test_claims_oldest_first(self, store):
|
||||
first = store.create(filename="1.wav", duration_sec=1.0)
|
||||
store.create(filename="2.wav", duration_sec=1.0)
|
||||
assert store.claim_next() == first
|
||||
|
||||
|
||||
class TestWorkerSettings:
|
||||
def test_default_threads_is_capped(self, tmp_path, monkeypatch):
|
||||
"""Дефолт умеренный: оптимум зависит от процессора и подбирается замером."""
|
||||
from app.config import load_settings
|
||||
|
||||
monkeypatch.setattr("os.cpu_count", lambda: 32)
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text('[processing]\nthreads=0\n', encoding="utf-8")
|
||||
assert load_settings(config).effective_threads() == 8
|
||||
|
||||
def test_explicit_threads_respected(self, tmp_path):
|
||||
from app.config import load_settings
|
||||
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text('[processing]\nthreads=6\n', encoding="utf-8")
|
||||
assert load_settings(config).effective_threads() == 6
|
||||
|
||||
def test_workers_derived_from_cores(self, tmp_path, monkeypatch):
|
||||
from app.config import load_settings
|
||||
|
||||
monkeypatch.setattr("os.cpu_count", lambda: 32)
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text('[processing]\nthreads=4\nworkers=0\n', encoding="utf-8")
|
||||
assert 1 <= load_settings(config).effective_workers() <= 4
|
||||
|
||||
def test_workers_never_below_one(self, tmp_path, monkeypatch):
|
||||
from app.config import load_settings
|
||||
|
||||
monkeypatch.setattr("os.cpu_count", lambda: 1)
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text('[processing]\nthreads=4\nworkers=0\n', encoding="utf-8")
|
||||
assert load_settings(config).effective_workers() >= 1
|
||||
|
||||
def test_explicit_workers_respected(self, tmp_path):
|
||||
from app.config import load_settings
|
||||
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text('[processing]\nworkers=3\n', encoding="utf-8")
|
||||
assert load_settings(config).effective_workers() == 3
|
||||
|
||||
|
||||
class TestLazyWarmup:
|
||||
"""Воркеры создаются заранее, а модели грузят при первой своей задаче."""
|
||||
|
||||
def test_transcribe_warms_up_when_needed(self, tmp_path, monkeypatch):
|
||||
import sys
|
||||
import types
|
||||
|
||||
for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"):
|
||||
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
|
||||
from app.pipeline import Pipeline
|
||||
|
||||
p = Pipeline(models_dir=tmp_path, threads=1,
|
||||
replacements_path=tmp_path / "r.txt", base_dir=tmp_path)
|
||||
called = {"warmup": 0}
|
||||
monkeypatch.setattr(p, "warmup", lambda: called.__setitem__("warmup", 1))
|
||||
# transcribe упадёт дальше на чтении файла, но warmup обязан быть вызван
|
||||
with pytest.raises(Exception):
|
||||
p.transcribe(tmp_path / "нет.wav")
|
||||
assert called["warmup"] == 1
|
||||
|
||||
def test_no_runtime_error_about_warmup(self, tmp_path, monkeypatch):
|
||||
"""Прежде здесь падало «модели не загружены, вызовите warmup()»."""
|
||||
import sys
|
||||
import types
|
||||
|
||||
for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"):
|
||||
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
|
||||
from app.pipeline import Pipeline
|
||||
|
||||
p = Pipeline(models_dir=tmp_path, threads=1,
|
||||
replacements_path=tmp_path / "r.txt", base_dir=tmp_path)
|
||||
monkeypatch.setattr(p, "warmup", lambda: None)
|
||||
try:
|
||||
p.transcribe(tmp_path / "нет.wav")
|
||||
except RuntimeError as exc:
|
||||
assert "warmup" not in str(exc)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class TestBenchmarkEndpoint:
|
||||
def test_benchmark_requires_token(self, tmp_path, monkeypatch):
|
||||
import sys
|
||||
import types
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text('[security]\ntoken="t"\nallow_ips=""\n', encoding="utf-8")
|
||||
monkeypatch.setenv("TALKSCORE_ASR_HOME", str(tmp_path))
|
||||
for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"):
|
||||
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
|
||||
for mod in [m for m in list(sys.modules) if m.startswith("app.")]:
|
||||
del sys.modules[mod]
|
||||
import app.config as cfg
|
||||
monkeypatch.setattr(cfg, "BASE_DIR", tmp_path)
|
||||
import app.main as main
|
||||
main._worker_stop.set()
|
||||
|
||||
with TestClient(main.app) as c:
|
||||
assert c.post("/v1/benchmark", files={"file": ("a.wav", b"x")}).status_code == 401
|
||||
|
||||
|
||||
class TestSeparationQuality:
|
||||
"""Метрика нужна, чтобы было видно, когда разметке по голосам верить нельзя."""
|
||||
|
||||
def test_distinct_voices_score_high(self):
|
||||
import numpy as np
|
||||
|
||||
from app.acoustics import separation_quality
|
||||
|
||||
emb = np.vstack([np.random.RandomState(0).randn(8, 64),
|
||||
np.random.RandomState(1).randn(8, 64) + 6])
|
||||
labels = np.array([0] * 8 + [1] * 8)
|
||||
assert separation_quality(emb, labels) > 0.4
|
||||
|
||||
def test_indistinguishable_voices_score_low(self):
|
||||
import numpy as np
|
||||
|
||||
from app.acoustics import separation_quality
|
||||
|
||||
emb = np.random.RandomState(2).randn(16, 64)
|
||||
labels = np.array([0] * 8 + [1] * 8)
|
||||
assert separation_quality(emb, labels) < 0.3
|
||||
|
||||
def test_too_few_segments_returns_zero(self):
|
||||
import numpy as np
|
||||
|
||||
from app.acoustics import separation_quality
|
||||
|
||||
assert separation_quality(np.random.randn(2, 64), np.array([0, 1])) == 0.0
|
||||
|
||||
def test_acoustics_reflect_high_frequency_content(self):
|
||||
import numpy as np
|
||||
|
||||
from app.acoustics import segment_acoustics
|
||||
|
||||
sr = 16000
|
||||
t = np.arange(sr) / sr
|
||||
low = np.sin(2 * np.pi * 300 * t).astype(np.float32)
|
||||
high = np.sin(2 * np.pi * 5000 * t).astype(np.float32)
|
||||
assert segment_acoustics(high)["hf_ratio"] > segment_acoustics(low)["hf_ratio"]
|
||||
|
||||
def test_acoustics_reflect_loudness(self):
|
||||
import numpy as np
|
||||
|
||||
from app.acoustics import segment_acoustics
|
||||
|
||||
loud = (np.random.RandomState(0).randn(16000) * 0.3).astype(np.float32)
|
||||
quiet = loud * 0.1
|
||||
assert segment_acoustics(loud)["loudness_db"] > segment_acoustics(quiet)["loudness_db"]
|
||||
|
||||
|
||||
class TestProcessPool:
|
||||
"""Расчёты уходят в процессы: библиотеки держат GIL, в потоках выигрыша нет."""
|
||||
|
||||
def test_worker_module_does_not_import_main(self):
|
||||
"""На Windows дочерний процесс заново импортирует модуль с функцией.
|
||||
|
||||
Если бы он тянул app.main, в каждом процессе поднимался бы ещё один
|
||||
веб-сервер со своей очередью. Проверяем сами импорты, а не текст файла.
|
||||
"""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
src = (Path(__file__).resolve().parent.parent / "app" / "worker.py").read_text(encoding="utf-8")
|
||||
imported = set()
|
||||
for node in ast.walk(ast.parse(src)):
|
||||
if isinstance(node, ast.Import):
|
||||
imported.update(a.name for a in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
imported.add(node.module)
|
||||
assert "app.main" not in imported
|
||||
|
||||
def test_pool_failure_falls_back_to_single_process(self, monkeypatch):
|
||||
"""Сбой процессов не должен ронять сервис."""
|
||||
import sys
|
||||
import types
|
||||
|
||||
for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"):
|
||||
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
|
||||
for mod in [m for m in list(sys.modules) if m.startswith("app.")]:
|
||||
del sys.modules[mod]
|
||||
|
||||
import app.main as main
|
||||
|
||||
monkeypatch.setattr("concurrent.futures.ProcessPoolExecutor",
|
||||
lambda **kw: (_ for _ in ()).throw(OSError("нет процессов")))
|
||||
assert main._make_pool(4) is None
|
||||
|
||||
def test_run_job_needs_initialised_worker(self):
|
||||
import app.worker as w
|
||||
|
||||
w._pipeline = None
|
||||
try:
|
||||
w.run_job("нет.wav", 2, "ffmpeg")
|
||||
except RuntimeError as exc:
|
||||
assert "не инициализирован" in str(exc)
|
||||
else:
|
||||
raise AssertionError("ожидалась ошибка о неинициализированном воркере")
|
||||
|
||||
|
||||
class TestSpawnSafety:
|
||||
"""На Windows дочерний процесс заново импортирует app.main."""
|
||||
|
||||
def test_importing_main_does_not_open_store(self, tmp_path, monkeypatch):
|
||||
"""Иначе каждый новый процесс объявлял бы чужие задачи сорванными."""
|
||||
import sys
|
||||
import types
|
||||
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text('[security]\ntoken="t"\n', encoding="utf-8")
|
||||
monkeypatch.setenv("TALKSCORE_ASR_HOME", str(tmp_path))
|
||||
for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"):
|
||||
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
|
||||
for mod in [m for m in list(sys.modules) if m.startswith("app.")]:
|
||||
del sys.modules[mod]
|
||||
|
||||
import app.config as cfg
|
||||
monkeypatch.setattr(cfg, "BASE_DIR", tmp_path)
|
||||
import app.main as main
|
||||
|
||||
assert main.store is None
|
||||
assert not (tmp_path / "data" / "jobs.db").exists()
|
||||
|
||||
|
||||
class TestSeparationNeverBreaksJob:
|
||||
"""Оценка разделения вспомогательная и не должна ронять расшифровку."""
|
||||
|
||||
def test_embedding_uses_short_piece(self):
|
||||
"""Длинный фрагмент модель отпечатков не переваривает."""
|
||||
from app.pipeline import EMBED_SEC
|
||||
|
||||
assert EMBED_SEC <= 10
|
||||
|
||||
def test_failure_in_quality_does_not_break_transcribe(self, tmp_path, monkeypatch):
|
||||
import sys
|
||||
import types
|
||||
|
||||
import numpy as np
|
||||
|
||||
for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"):
|
||||
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
|
||||
from app.pipeline import Pipeline
|
||||
|
||||
p = Pipeline(models_dir=tmp_path, threads=1,
|
||||
replacements_path=tmp_path / "r.txt", base_dir=tmp_path)
|
||||
p._asr = types.SimpleNamespace(recognize=lambda *a, **k: "текст")
|
||||
monkeypatch.setattr(p, "warmup", lambda: None)
|
||||
monkeypatch.setattr(p, "_reload_replacements", lambda: None)
|
||||
monkeypatch.setattr(p, "_diarizer", lambda n: types.SimpleNamespace(
|
||||
process=lambda s: types.SimpleNamespace(
|
||||
sort_by_start_time=lambda: [types.SimpleNamespace(start=0.0, end=2.0, speaker=0)])))
|
||||
monkeypatch.setattr(p, "_separation_quality",
|
||||
lambda *a: (_ for _ in ()).throw(RuntimeError("модель упала")))
|
||||
monkeypatch.setattr("app.pipeline.read_wav", lambda _: np.zeros(16000 * 3, dtype=np.float32))
|
||||
|
||||
result = p.transcribe(tmp_path / "any.wav")
|
||||
assert result["stats"]["separation_quality"] == 0.0
|
||||
assert result["turns"] # расшифровка на месте
|
||||
|
||||
|
||||
class TestNormalization:
|
||||
"""Выравнивание громкости решает исход диаризации на записях с одним микрофоном."""
|
||||
|
||||
def test_filter_applied_by_default(self, tmp_path, monkeypatch):
|
||||
import subprocess
|
||||
|
||||
from app.pipeline import NORMALIZE_FILTER, to_wav16k
|
||||
|
||||
captured = {}
|
||||
monkeypatch.setattr(subprocess, "run",
|
||||
lambda cmd, **kw: captured.setdefault("cmd", cmd))
|
||||
monkeypatch.setattr("wave.open", lambda *a, **k: type(
|
||||
"W", (), {"getnframes": lambda s: 16000, "getframerate": lambda s: 16000,
|
||||
"__enter__": lambda s: s, "__exit__": lambda *a: None})())
|
||||
to_wav16k(tmp_path / "in.mp3", tmp_path / "out.wav", "ffmpeg")
|
||||
assert NORMALIZE_FILTER in captured["cmd"]
|
||||
|
||||
def test_can_be_disabled(self, tmp_path, monkeypatch):
|
||||
import subprocess
|
||||
|
||||
from app.pipeline import NORMALIZE_FILTER, to_wav16k
|
||||
|
||||
captured = {}
|
||||
monkeypatch.setattr(subprocess, "run",
|
||||
lambda cmd, **kw: captured.setdefault("cmd", cmd))
|
||||
monkeypatch.setattr("wave.open", lambda *a, **k: type(
|
||||
"W", (), {"getnframes": lambda s: 16000, "getframerate": lambda s: 16000,
|
||||
"__enter__": lambda s: s, "__exit__": lambda *a: None})())
|
||||
to_wav16k(tmp_path / "in.mp3", tmp_path / "out.wav", "ffmpeg", normalize=False)
|
||||
assert NORMALIZE_FILTER not in captured["cmd"]
|
||||
|
||||
def test_settings_default_to_normalizing(self, tmp_path):
|
||||
from app.config import load_settings
|
||||
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text('[processing]\nthreads=4\n', encoding="utf-8")
|
||||
assert load_settings(config).normalize is True
|
||||
|
||||
def test_parameters_are_the_measured_ones(self):
|
||||
"""f=400:g=3 давал 8.6 процента вместо 43.2 - параметры менять нельзя наугад."""
|
||||
from app.pipeline import NORMALIZE_FILTER
|
||||
|
||||
assert "f=200" in NORMALIZE_FILTER and "g=5" in NORMALIZE_FILTER
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Проверки читаемости консольного вывода."""
|
||||
import logging
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.console import ConsoleFormatter, enable_ansi, setup_logging
|
||||
|
||||
|
||||
def record(level: int, message: str) -> logging.LogRecord:
|
||||
return logging.LogRecord("talkscore-asr", level, __file__, 1, message, None, None)
|
||||
|
||||
|
||||
class TestWrapping:
|
||||
def test_short_message_stays_on_one_line(self):
|
||||
out = ConsoleFormatter(colored=False).format(record(logging.INFO, "готово"))
|
||||
assert out.count("\n") == 0
|
||||
assert out.endswith("готово")
|
||||
|
||||
def test_long_message_wraps(self):
|
||||
out = ConsoleFormatter(colored=False, width=lambda: 80).format(
|
||||
record(logging.INFO, "слово " * 60))
|
||||
assert out.count("\n") >= 1
|
||||
assert all(len(line) <= 80 for line in out.splitlines())
|
||||
|
||||
def test_continuation_aligned_under_message(self):
|
||||
"""Перенос идёт под текст, а не под начало строки - так видно границу сообщений."""
|
||||
out = ConsoleFormatter(colored=False, width=lambda: 70).format(
|
||||
record(logging.WARNING, "слово " * 40))
|
||||
first, second = out.splitlines()[:2]
|
||||
head = len(first) - len(first.lstrip()) + first.index("слово")
|
||||
assert second.startswith(" " * head)
|
||||
assert second.lstrip()[0] != " "
|
||||
|
||||
def test_existing_newlines_are_kept(self):
|
||||
out = ConsoleFormatter(colored=False).format(
|
||||
record(logging.INFO, "первая\nвторая"))
|
||||
assert "первая" in out.splitlines()[0]
|
||||
assert "вторая" in out.splitlines()[1]
|
||||
|
||||
|
||||
class TestColour:
|
||||
def test_no_escape_codes_when_colour_off(self):
|
||||
"""Главное требование: без цвета в выводе не должно быть ANSI-мусора."""
|
||||
for level in (logging.INFO, logging.WARNING, logging.ERROR):
|
||||
out = ConsoleFormatter(colored=False).format(record(level, "текст"))
|
||||
assert "\033" not in out
|
||||
|
||||
def test_warning_and_error_are_painted(self):
|
||||
for level in (logging.WARNING, logging.ERROR):
|
||||
out = ConsoleFormatter(colored=True).format(record(level, "текст"))
|
||||
assert "\033[" in out
|
||||
|
||||
def test_info_level_is_not_painted(self):
|
||||
"""Обычные сообщения остаются неокрашенными, иначе цвет перестаёт что-то значить."""
|
||||
out = ConsoleFormatter(colored=True).format(record(logging.INFO, "текст"))
|
||||
assert "\033[3" not in out # ни жёлтого, ни красного
|
||||
|
||||
|
||||
class TestAnsiDetection:
|
||||
def test_disabled_when_output_is_redirected(self):
|
||||
"""Перенаправленный в файл вывод красить нельзя - в файл попадут коды."""
|
||||
with patch.object(sys.stdout, "isatty", return_value=False):
|
||||
assert enable_ansi() is False
|
||||
|
||||
|
||||
class TestSetup:
|
||||
def test_setup_replaces_handlers(self):
|
||||
root = logging.getLogger()
|
||||
before = root.handlers[:]
|
||||
try:
|
||||
setup_logging(logging.INFO)
|
||||
assert len(root.handlers) == 1
|
||||
assert isinstance(root.handlers[0].formatter, ConsoleFormatter)
|
||||
finally:
|
||||
root.handlers[:] = before
|
||||
|
||||
|
||||
class TestPortCheck:
|
||||
"""Занятый порт должен диагностироваться до запуска, а не строкой Errno 10048."""
|
||||
|
||||
def test_free_port_is_not_busy(self):
|
||||
import socket as sk
|
||||
|
||||
from app.main import _port_busy
|
||||
|
||||
with sk.socket() as probe:
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
free = probe.getsockname()[1]
|
||||
# Сокет закрыт, порт снова свободен.
|
||||
assert _port_busy("127.0.0.1", free) is False
|
||||
|
||||
def test_listening_port_is_busy(self):
|
||||
import socket as sk
|
||||
|
||||
from app.main import _port_busy
|
||||
|
||||
with sk.socket() as server:
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(1)
|
||||
port = server.getsockname()[1]
|
||||
assert _port_busy("127.0.0.1", port) is True
|
||||
|
||||
def test_wildcard_host_is_probed_via_loopback(self):
|
||||
"""При host=0.0.0.0 подключаться к 0.0.0.0 нельзя, проверяем петлю."""
|
||||
import socket as sk
|
||||
|
||||
from app.main import _port_busy
|
||||
|
||||
with sk.socket() as server:
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(1)
|
||||
port = server.getsockname()[1]
|
||||
assert _port_busy("0.0.0.0", port) is True
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Проверки генерации Caddyfile и запуска."""
|
||||
from app.launcher import MARK, ensure_caddyfile, render_caddyfile
|
||||
|
||||
|
||||
class TestRender:
|
||||
def test_domains_go_into_site_block(self):
|
||||
text = render_caddyfile("a.example.com, b.example.com", 8756, 500)
|
||||
assert "a.example.com, b.example.com {" in text
|
||||
|
||||
def test_extra_spaces_and_empty_entries_are_dropped(self):
|
||||
text = render_caddyfile(" a.example.com ,, b.example.com ,", 8756, 500)
|
||||
assert "a.example.com, b.example.com {" in text
|
||||
|
||||
def test_port_and_limit_come_from_settings(self):
|
||||
text = render_caddyfile("a.example.com", 9000, 250)
|
||||
assert "reverse_proxy 127.0.0.1:9000" in text
|
||||
assert "max_size 250MB" in text
|
||||
|
||||
def test_xff_is_left_to_caddy_default(self):
|
||||
"""Caddy сам игнорирует X-Forwarded-For от клиента и ставит реальный адрес.
|
||||
|
||||
Свой header_up был лишним - Caddy предупреждал об этом при каждом
|
||||
запуске. Важнее обратное: trusted_proxies появляться не должен, иначе
|
||||
Caddy начнёт верить заголовку клиента и адрес станет подделываемым.
|
||||
"""
|
||||
# Комментарии не в счёт: в них эти слова упомянуты как предостережение.
|
||||
directives = "\n".join(
|
||||
line for line in render_caddyfile("a.example.com", 8756, 500).splitlines()
|
||||
if not line.strip().startswith("#"))
|
||||
assert "header_up" not in directives
|
||||
assert "trusted_proxies" not in directives
|
||||
|
||||
def test_caddy_log_goes_to_file(self):
|
||||
"""Иначе Caddy забивает консоль сообщениями про сертификаты."""
|
||||
text = render_caddyfile("a.example.com", 8756, 500)
|
||||
assert "output file logs/caddy.log" in text
|
||||
|
||||
def test_generated_file_is_marked(self):
|
||||
assert render_caddyfile("a.example.com", 8756, 500).startswith(MARK)
|
||||
|
||||
def test_no_blank_line_before_global_block(self):
|
||||
"""caddy fmt считает такой файл неформатированным и ругается при запуске."""
|
||||
lines = render_caddyfile("a.example.com", 8756, 500).splitlines()
|
||||
assert lines[lines.index("{") - 1].strip() != ""
|
||||
|
||||
|
||||
class TestEnsure:
|
||||
def test_creates_file_when_missing(self, tmp_path):
|
||||
content = render_caddyfile("a.example.com", 8756, 500)
|
||||
written = ensure_caddyfile(tmp_path, content)
|
||||
assert written == tmp_path / "Caddyfile"
|
||||
assert written.read_text(encoding="utf-8") == content
|
||||
|
||||
def test_overwrites_own_previous_version(self, tmp_path):
|
||||
ensure_caddyfile(tmp_path, render_caddyfile("old.example.com", 8756, 500))
|
||||
fresh = render_caddyfile("new.example.com", 8756, 500)
|
||||
ensure_caddyfile(tmp_path, fresh)
|
||||
assert (tmp_path / "Caddyfile").read_text(encoding="utf-8") == fresh
|
||||
assert not (tmp_path / "Caddyfile.new").exists()
|
||||
|
||||
def test_keeps_hand_written_file(self, tmp_path):
|
||||
"""Чужой файл не трогаем: молча стереть настройку хуже, чем оставить старую."""
|
||||
handmade = "site.example.com {\n\treverse_proxy 127.0.0.1:1234\n}\n"
|
||||
(tmp_path / "Caddyfile").write_text(handmade, encoding="utf-8")
|
||||
written = ensure_caddyfile(tmp_path, render_caddyfile("a.example.com", 8756, 500))
|
||||
assert (tmp_path / "Caddyfile").read_text(encoding="utf-8") == handmade
|
||||
assert written == tmp_path / "Caddyfile.new"
|
||||
assert MARK in written.read_text(encoding="utf-8")
|
||||
|
||||
def test_no_rewrite_when_nothing_changed(self, tmp_path):
|
||||
content = render_caddyfile("a.example.com", 8756, 500)
|
||||
ensure_caddyfile(tmp_path, content)
|
||||
before = (tmp_path / "Caddyfile").stat().st_mtime_ns
|
||||
assert ensure_caddyfile(tmp_path, content) is None
|
||||
assert (tmp_path / "Caddyfile").stat().st_mtime_ns == before
|
||||
|
||||
|
||||
class TestStartupOrder:
|
||||
def test_updater_runs_before_service(self, tmp_path, monkeypatch):
|
||||
"""Обновиться нужно до запуска, иначе обновление бессмысленно."""
|
||||
import app.launcher as launcher
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
class FakeSettings:
|
||||
base_dir = tmp_path
|
||||
port = 8756
|
||||
max_upload_mb = 500
|
||||
https_domains = ""
|
||||
|
||||
monkeypatch.setattr("app.config.load_settings", lambda: FakeSettings())
|
||||
monkeypatch.setattr("app.updater.main", lambda: calls.append("updater"))
|
||||
monkeypatch.setitem(
|
||||
__import__("sys").modules, "app.main",
|
||||
type("M", (), {"run": staticmethod(lambda: calls.append("run"))}))
|
||||
|
||||
assert launcher.main([]) == 0
|
||||
assert calls == ["updater", "run"]
|
||||
|
||||
def test_https_without_domains_stops_with_explanation(self, tmp_path, monkeypatch):
|
||||
"""Пустые домены - это ошибка настройки, а не повод падать внутри Caddy."""
|
||||
import app.launcher as launcher
|
||||
|
||||
class FakeSettings:
|
||||
base_dir = tmp_path
|
||||
port = 8756
|
||||
max_upload_mb = 500
|
||||
https_domains = " "
|
||||
|
||||
monkeypatch.setattr("app.config.load_settings", lambda: FakeSettings())
|
||||
monkeypatch.setattr("app.updater.main", lambda: None)
|
||||
assert launcher.main(["https"]) == 1
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Тесты определения адреса клиента за обратным прокси.
|
||||
|
||||
Caddy держит HTTPS и проксирует запросы на localhost. Без учёта
|
||||
X-Forwarded-For список разрешённых адресов увидит вместо клиента localhost
|
||||
и пустит кого угодно. Но доверять заголовку можно только от самого прокси -
|
||||
иначе его подделает любой желающий.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app.security import client_address, parse_allowlist
|
||||
|
||||
|
||||
class TestClientAddress:
|
||||
def test_direct_connection_uses_peer(self):
|
||||
assert client_address("203.0.113.5", None, parse_allowlist("")) == "203.0.113.5"
|
||||
|
||||
def test_forwarded_header_used_from_trusted_proxy(self):
|
||||
trusted = parse_allowlist("127.0.0.1")
|
||||
assert client_address("127.0.0.1", "203.0.113.5", trusted) == "203.0.113.5"
|
||||
|
||||
def test_forwarded_header_ignored_from_untrusted_peer(self):
|
||||
"""Подделка заголовка не должна давать доступ."""
|
||||
trusted = parse_allowlist("127.0.0.1")
|
||||
assert client_address("198.51.100.9", "10.0.0.1", trusted) == "198.51.100.9"
|
||||
|
||||
def test_takes_first_address_from_chain(self):
|
||||
trusted = parse_allowlist("127.0.0.1")
|
||||
assert client_address("127.0.0.1", "203.0.113.5, 70.41.3.18", trusted) == "203.0.113.5"
|
||||
|
||||
def test_empty_header_falls_back_to_peer(self):
|
||||
trusted = parse_allowlist("127.0.0.1")
|
||||
assert client_address("127.0.0.1", "", trusted) == "127.0.0.1"
|
||||
|
||||
def test_no_trusted_proxies_ignores_header(self):
|
||||
"""Пока прокси не настроен, заголовку верить нельзя вообще."""
|
||||
assert client_address("127.0.0.1", "203.0.113.5", parse_allowlist("")) == "127.0.0.1"
|
||||
|
||||
def test_malformed_header_falls_back_to_peer(self):
|
||||
trusted = parse_allowlist("127.0.0.1")
|
||||
assert client_address("127.0.0.1", "не-адрес", trusted) == "127.0.0.1"
|
||||
|
||||
def test_ipv6_proxy_supported(self):
|
||||
trusted = parse_allowlist("::1")
|
||||
assert client_address("::1", "203.0.113.5", trusted) == "203.0.113.5"
|
||||
+15
-1
@@ -1,7 +1,7 @@
|
||||
"""Тесты доступа: токен и список разрешённых адресов."""
|
||||
import pytest
|
||||
|
||||
from app.security import check_token, ip_allowed, parse_allowlist
|
||||
from app.security import token_matches, check_token, ip_allowed, parse_allowlist
|
||||
|
||||
|
||||
class TestToken:
|
||||
@@ -62,3 +62,17 @@ class TestAllowlist:
|
||||
def test_unknown_client_ip_denied_when_list_set(self):
|
||||
nets = parse_allowlist("10.0.0.1")
|
||||
assert ip_allowed(None, nets) is False
|
||||
|
||||
|
||||
class TestNonAsciiToken:
|
||||
"""compare_digest на строках с не-ASCII бросает TypeError: был бы 500 вместо 401."""
|
||||
|
||||
def test_cyrillic_token_matches_itself(self):
|
||||
assert token_matches("секрет-ключ", "секрет-ключ") is True
|
||||
|
||||
def test_cyrillic_token_rejects_other(self):
|
||||
assert token_matches("другой", "секрет-ключ") is False
|
||||
|
||||
def test_cyrillic_token_via_header(self):
|
||||
assert check_token("Bearer секрет-ключ", "секрет-ключ") is True
|
||||
assert check_token("Bearer чужой", "секрет-ключ") is False
|
||||
|
||||
+6
-6
@@ -51,18 +51,18 @@ class TestJobLifecycle:
|
||||
|
||||
|
||||
class TestQueue:
|
||||
def test_take_next_returns_oldest_queued(self, store):
|
||||
def test_claim_returns_oldest_queued(self, store):
|
||||
first = store.create(filename="1.wav", duration_sec=1.0)
|
||||
store.create(filename="2.wav", duration_sec=1.0)
|
||||
assert store.take_next() == first
|
||||
assert store.claim_next() == first
|
||||
|
||||
def test_take_next_skips_running(self, store):
|
||||
def test_claim_skips_running(self, store):
|
||||
job_id = store.create(filename="1.wav", duration_sec=1.0)
|
||||
store.mark_running(job_id)
|
||||
assert store.take_next() is None
|
||||
assert store.claim_next() is None
|
||||
|
||||
def test_take_next_on_empty_queue(self, store):
|
||||
assert store.take_next() is None
|
||||
def test_claim_on_empty_queue(self, store):
|
||||
assert store.claim_next() is None
|
||||
|
||||
def test_queue_position_counts_only_waiting(self, store):
|
||||
a = store.create(filename="1.wav", duration_sec=1.0)
|
||||
|
||||
+48
-1
@@ -1,7 +1,7 @@
|
||||
"""Тесты постобработки текста: словарь замен и типографика."""
|
||||
import pytest
|
||||
|
||||
from app.text import apply_replacements, load_replacements, normalize_typography
|
||||
from app.text import apply_replacements, load_replacements, normalize_typography, split_sentences
|
||||
|
||||
|
||||
class TestReplacements:
|
||||
@@ -74,3 +74,50 @@ class TestTypography:
|
||||
|
||||
def test_keeps_hyphen_in_compound_word(self):
|
||||
assert normalize_typography("look-alike и из-за") == "look-alike и из-за"
|
||||
|
||||
|
||||
class TestSplitSentences:
|
||||
"""Разметку ролей делает LLM по предложениям: реплику целиком разметить нельзя."""
|
||||
|
||||
@staticmethod
|
||||
def turn(text, start=0.0, end=10.0, loudness=-20.0):
|
||||
return {"text": text, "start": start, "end": end,
|
||||
"acoustics": {"loudness_db": loudness}}
|
||||
|
||||
def test_splits_on_sentence_endings(self):
|
||||
out = split_sentences([self.turn("Первое. Второе! Третье? Четвёртое…")])
|
||||
assert [s["text"] for s in out] == ["Первое.", "Второе!", "Третье?", "Четвёртое…"]
|
||||
|
||||
def test_numbering_is_continuous_across_turns(self):
|
||||
out = split_sentences([self.turn("А. Б."), self.turn("В. Г.")])
|
||||
assert [s["n"] for s in out] == [1, 2, 3, 4]
|
||||
|
||||
def test_turn_index_is_kept(self):
|
||||
"""По нему собирают диалог обратно после разметки."""
|
||||
out = split_sentences([self.turn("А. Б."), self.turn("В.")])
|
||||
assert [s["turn"] for s in out] == [0, 0, 1]
|
||||
|
||||
def test_time_is_spread_inside_turn(self):
|
||||
out = split_sentences([self.turn("А. Б. В.", start=0.0, end=30.0)])
|
||||
assert [s["start"] for s in out] == [0.0, 10.0, 20.0]
|
||||
|
||||
def test_loudness_comes_from_turn(self):
|
||||
out = split_sentences([self.turn("А. Б.", loudness=-17.5)])
|
||||
assert all(s["loudness_db"] == -17.5 for s in out)
|
||||
|
||||
def test_turn_without_acoustics_gives_none(self):
|
||||
"""Короткие фрагменты акустику не считают - поле должно быть пустым, не падать."""
|
||||
out = split_sentences([{"text": "А.", "start": 0.0, "end": 1.0, "acoustics": {}}])
|
||||
assert out[0]["loudness_db"] is None
|
||||
|
||||
def test_text_without_punctuation_stays_one_sentence(self):
|
||||
out = split_sentences([self.turn("совсем без знаков препинания")])
|
||||
assert len(out) == 1
|
||||
|
||||
def test_empty_text_is_skipped(self):
|
||||
assert split_sentences([self.turn(" ")]) == []
|
||||
|
||||
def test_abbreviation_dots_do_not_split(self):
|
||||
"""Точка без пробела после неё концом предложения не считается."""
|
||||
out = split_sentences([self.turn("Нужен документ п.2.1 из списка.")])
|
||||
assert len(out) == 1
|
||||
|
||||
@@ -71,3 +71,17 @@ class TestSpeechStats:
|
||||
stats = speech_stats([])
|
||||
assert stats["speakers"] == 0
|
||||
assert stats["speech_sec"] == 0
|
||||
|
||||
|
||||
class TestChunkLimit:
|
||||
def test_default_chunk_is_conservative(self):
|
||||
"""Пределы у разных сборок onnxruntime разные, берём с запасом."""
|
||||
from app.pipeline import MAX_CHUNK_SEC
|
||||
|
||||
assert MAX_CHUNK_SEC <= 60
|
||||
|
||||
def test_long_turn_split_below_limit(self):
|
||||
from app.pipeline import MAX_CHUNK_SEC
|
||||
|
||||
for a, b in chunk_ranges(0.0, 600.0, max_len=MAX_CHUNK_SEC):
|
||||
assert b - a <= MAX_CHUNK_SEC
|
||||
|
||||
@@ -126,3 +126,70 @@ class TestInstall:
|
||||
z.writestr("app/main.py", "код")
|
||||
with pytest.raises(UpdateError, match="путь"):
|
||||
install_update(buf.getvalue(), base, verify=lambda _: True)
|
||||
|
||||
|
||||
class TestPipelineImportSafety:
|
||||
def test_pipeline_constructs_without_ffmpeg(self, tmp_path, monkeypatch):
|
||||
"""Отсутствие ffmpeg не должно ронять импорт: иначе непонятно, что чинить."""
|
||||
import sys
|
||||
import types
|
||||
|
||||
for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"):
|
||||
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
|
||||
monkeypatch.setattr("shutil.which", lambda _: None)
|
||||
|
||||
from app.pipeline import Pipeline
|
||||
|
||||
p = Pipeline(models_dir=tmp_path, threads=1,
|
||||
replacements_path=tmp_path / "r.txt", base_dir=tmp_path)
|
||||
assert p is not None # конструктор не упал
|
||||
|
||||
def test_ffmpeg_lookup_fails_only_when_used(self, tmp_path, monkeypatch):
|
||||
import sys
|
||||
import types
|
||||
|
||||
for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"):
|
||||
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
|
||||
monkeypatch.setattr("shutil.which", lambda _: None)
|
||||
|
||||
from app.pipeline import ModelsMissing, Pipeline
|
||||
|
||||
p = Pipeline(models_dir=tmp_path, threads=1,
|
||||
replacements_path=tmp_path / "r.txt", base_dir=tmp_path)
|
||||
with pytest.raises(ModelsMissing, match="ffmpeg"):
|
||||
_ = p.ffmpeg
|
||||
|
||||
|
||||
class TestApiDiagnostics:
|
||||
"""404 на приватном репозитории Gitea означает проблему с токеном, не отсутствие релиза."""
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status):
|
||||
self.status_code = status
|
||||
|
||||
def raise_for_status(self):
|
||||
raise AssertionError("до сюда доходить не должно")
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, status):
|
||||
self.status = status
|
||||
|
||||
def get(self, url, headers=None, timeout=None):
|
||||
return TestApiDiagnostics.FakeResponse(self.status)
|
||||
|
||||
@pytest.mark.parametrize("status", [401, 403, 404])
|
||||
def test_auth_errors_mention_token(self, status):
|
||||
# Импорт внутри теста: другие тесты перезагружают app.*, и класс
|
||||
# исключения, взятый на уровне модуля, перестал бы совпадать.
|
||||
from app.updater import UpdateError as Err
|
||||
from app.updater import _api
|
||||
|
||||
with pytest.raises(Err, match="токен"):
|
||||
_api(self.FakeSession(status), "http://x", token="какой-то")
|
||||
|
||||
def test_missing_token_says_where_to_put_it(self):
|
||||
from app.updater import UpdateError as Err
|
||||
from app.updater import _api
|
||||
|
||||
with pytest.raises(Err, match="config.toml"):
|
||||
_api(self.FakeSession(404), "http://x", token="")
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Тесты установки обновления.
|
||||
|
||||
Первая версия перемещала папку app целиком - и на Windows это сломало сервис:
|
||||
код выполнялся из этой самой папки, перемещение не прошло, откат тоже не смог
|
||||
её вернуть, и app просто исчезла. Отсюда требования ниже: не двигать и не
|
||||
удалять рабочую папку, проверять новый код до установки, уметь откатиться
|
||||
пофайлово.
|
||||
"""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
from app.updater import UpdateError, install_update
|
||||
|
||||
|
||||
def make_zip(files: dict[str, str]) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as z:
|
||||
for name, text in files.items():
|
||||
z.writestr(name, text)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base(tmp_path):
|
||||
app = tmp_path / "app"
|
||||
app.mkdir()
|
||||
(app / "main.py").write_text("старый main", encoding="utf-8")
|
||||
(app / "version.py").write_text('__version__ = "0.1.0"', encoding="utf-8")
|
||||
return tmp_path
|
||||
|
||||
|
||||
class TestNoDestructiveMoves:
|
||||
def test_app_directory_is_never_removed(self, base, monkeypatch):
|
||||
"""rmtree по рабочей папке - прямой путь к потере сервиса."""
|
||||
import shutil
|
||||
|
||||
removed = []
|
||||
monkeypatch.setattr(shutil, "rmtree",
|
||||
lambda p, **kw: removed.append(str(p)))
|
||||
install_update(make_zip({"app/main.py": "новый"}), base, verify=lambda _: True)
|
||||
assert not any(str(p).endswith("app") for p in removed)
|
||||
|
||||
def test_app_directory_is_never_moved(self, base, monkeypatch):
|
||||
import shutil
|
||||
|
||||
moved = []
|
||||
real_move = shutil.move
|
||||
monkeypatch.setattr(shutil, "move",
|
||||
lambda s, d, **kw: (moved.append(str(s)), real_move(s, d))[1])
|
||||
install_update(make_zip({"app/main.py": "новый"}), base, verify=lambda _: True)
|
||||
assert not any(str(p).rstrip("/\\").endswith("app") for p in moved)
|
||||
|
||||
def test_directory_keeps_same_inode(self, base):
|
||||
"""Папка должна остаться той же самой, а не быть пересозданной."""
|
||||
before = (base / "app").stat().st_ino
|
||||
install_update(make_zip({"app/main.py": "новый"}), base, verify=lambda _: True)
|
||||
assert (base / "app").stat().st_ino == before
|
||||
|
||||
|
||||
class TestVerifyBeforeInstall:
|
||||
def test_broken_version_never_touches_working_files(self, base):
|
||||
"""Проверять надо до установки, иначе сломанный код уже стоит."""
|
||||
with pytest.raises(UpdateError):
|
||||
install_update(make_zip({"app/main.py": "сломанный"}), base,
|
||||
verify=lambda _: False)
|
||||
assert (base / "app" / "main.py").read_text(encoding="utf-8") == "старый main"
|
||||
|
||||
def test_verify_receives_staging_not_live_dir(self, base):
|
||||
seen = {}
|
||||
|
||||
def verify(path):
|
||||
seen["path"] = path
|
||||
# На момент проверки рабочий файл ещё не тронут
|
||||
seen["live_intact"] = (base / "app" / "main.py").read_text(encoding="utf-8")
|
||||
return True
|
||||
|
||||
install_update(make_zip({"app/main.py": "новый"}), base, verify=verify)
|
||||
assert seen["live_intact"] == "старый main"
|
||||
assert seen["path"] != base
|
||||
|
||||
|
||||
class TestInstallResult:
|
||||
def test_files_are_updated(self, base):
|
||||
install_update(make_zip({"app/main.py": "новый",
|
||||
"app/version.py": '__version__ = "0.2.0"'}),
|
||||
base, verify=lambda _: True)
|
||||
assert (base / "app" / "main.py").read_text(encoding="utf-8") == "новый"
|
||||
assert '0.2.0' in (base / "app" / "version.py").read_text(encoding="utf-8")
|
||||
|
||||
def test_new_files_appear(self, base):
|
||||
install_update(make_zip({"app/main.py": "новый", "app/extra.py": "доп"}),
|
||||
base, verify=lambda _: True)
|
||||
assert (base / "app" / "extra.py").is_file()
|
||||
|
||||
def test_leftover_files_from_old_version_are_removed(self, base):
|
||||
"""Файл, которого больше нет в релизе, не должен оставаться и импортироваться."""
|
||||
(base / "app" / "obsolete.py").write_text("устаревший", encoding="utf-8")
|
||||
install_update(make_zip({"app/main.py": "новый"}), base, verify=lambda _: True)
|
||||
assert not (base / "app" / "obsolete.py").exists()
|
||||
|
||||
def test_rollback_restores_files_on_copy_failure(self, base, monkeypatch):
|
||||
"""Если копирование упало на середине, возвращаем прежние файлы."""
|
||||
import shutil
|
||||
|
||||
calls = {"n": 0}
|
||||
real_copy = shutil.copy2
|
||||
|
||||
def flaky_copy(src, dst, **kw):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 2:
|
||||
raise OSError("файл занят")
|
||||
return real_copy(src, dst)
|
||||
|
||||
monkeypatch.setattr(shutil, "copy2", flaky_copy)
|
||||
with pytest.raises(OSError):
|
||||
install_update(make_zip({"app/main.py": "новый", "app/a.py": "a",
|
||||
"app/b.py": "b"}), base, verify=lambda _: True)
|
||||
assert (base / "app" / "main.py").read_text(encoding="utf-8") == "старый main"
|
||||
|
||||
def test_no_temp_dirs_left_behind(self, base):
|
||||
install_update(make_zip({"app/main.py": "новый"}), base, verify=lambda _: True)
|
||||
leftovers = [p.name for p in base.iterdir() if p.name.startswith(".update")]
|
||||
assert leftovers == []
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Тесты доставки результата на сторонний адрес."""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.webhook import deliver, sign_payload
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status):
|
||||
self.status_code = status
|
||||
|
||||
|
||||
class TestSignature:
|
||||
def test_signature_matches_hmac(self):
|
||||
body = b'{"job_id":"x"}'
|
||||
expected = hmac.new(b"secret", body, hashlib.sha256).hexdigest()
|
||||
assert sign_payload(body, "secret") == expected
|
||||
|
||||
def test_different_secret_gives_different_signature(self):
|
||||
body = b'{"job_id":"x"}'
|
||||
assert sign_payload(body, "a") != sign_payload(body, "b")
|
||||
|
||||
def test_signature_changes_with_body(self):
|
||||
assert sign_payload(b"one", "s") != sign_payload(b"two", "s")
|
||||
|
||||
|
||||
class TestDelivery:
|
||||
def test_successful_delivery_sends_once(self, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_post(url, data=None, headers=None, timeout=None):
|
||||
calls.append((url, data, headers))
|
||||
return FakeResponse(200)
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
assert deliver("http://x/hook", {"job_id": "1"}, "s", delays=(0,)) is True
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_signature_header_present(self, monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_post(url, data=None, headers=None, timeout=None):
|
||||
seen.update(headers)
|
||||
return FakeResponse(200)
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
deliver("http://x", {"job_id": "1"}, "секрет", delays=(0,))
|
||||
assert "X-Talkscore-Signature" in seen
|
||||
|
||||
def test_no_signature_without_secret(self, monkeypatch):
|
||||
seen = {}
|
||||
monkeypatch.setattr("requests.post",
|
||||
lambda url, data=None, headers=None, timeout=None:
|
||||
(seen.update(headers), FakeResponse(200))[1])
|
||||
deliver("http://x", {"job_id": "1"}, "", delays=(0,))
|
||||
assert "X-Talkscore-Signature" not in seen
|
||||
|
||||
def test_retries_on_server_error(self, monkeypatch):
|
||||
attempts = []
|
||||
monkeypatch.setattr("requests.post",
|
||||
lambda url, data=None, headers=None, timeout=None:
|
||||
(attempts.append(1), FakeResponse(500))[1])
|
||||
assert deliver("http://x", {"job_id": "1"}, "", delays=(0, 0, 0)) is False
|
||||
assert len(attempts) == 3
|
||||
|
||||
def test_retries_on_network_failure(self, monkeypatch):
|
||||
attempts = []
|
||||
|
||||
def boom(url, data=None, headers=None, timeout=None):
|
||||
attempts.append(1)
|
||||
raise OSError("сеть недоступна")
|
||||
|
||||
monkeypatch.setattr("requests.post", boom)
|
||||
assert deliver("http://x", {"job_id": "1"}, "", delays=(0, 0)) is False
|
||||
assert len(attempts) == 2
|
||||
|
||||
def test_stops_after_first_success(self, monkeypatch):
|
||||
attempts = []
|
||||
|
||||
def flaky(url, data=None, headers=None, timeout=None):
|
||||
attempts.append(1)
|
||||
return FakeResponse(500 if len(attempts) == 1 else 200)
|
||||
|
||||
monkeypatch.setattr("requests.post", flaky)
|
||||
assert deliver("http://x", {"job_id": "1"}, "", delays=(0, 0, 0)) is True
|
||||
assert len(attempts) == 2
|
||||
|
||||
def test_payload_is_valid_json_utf8(self, monkeypatch):
|
||||
seen = {}
|
||||
monkeypatch.setattr("requests.post",
|
||||
lambda url, data=None, headers=None, timeout=None:
|
||||
(seen.update({"body": data}), FakeResponse(200))[1])
|
||||
deliver("http://x", {"text": "русский текст"}, "", delays=(0,))
|
||||
assert json.loads(seen["body"].decode("utf-8"))["text"] == "русский текст"
|
||||
Reference in New Issue
Block a user