внутренняя модель перестроена вокруг аудиозаписи

- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
This commit is contained in:
av
2026-08-14 20:20:33 +03:00
parent d079f03350
commit 1576d06735
84 changed files with 8973 additions and 2865 deletions
+50
View File
@@ -7,18 +7,59 @@ import (
"os"
"sort"
"strings"
"time"
"github.com/BurntSushi/toml"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
type Config struct {
Server ServerConfig `toml:"server"`
Storage StorageConfig `toml:"storage"`
Pipeline PipelineConfig `toml:"pipeline"`
Yandex YandexConfig `toml:"yandex"`
Telegram TelegramConfig `toml:"telegram"`
Auth AuthConfig `toml:"auth"`
}
// PipelineConfig — настройки конвейера расшифровки.
type PipelineConfig struct {
// Workers — число одинаковых воркеров. Ноль — законное значение: сервис
// поднимается, записи принимаются и не двигаются. Это режим, а не поломка.
Workers int `toml:"workers"`
// OwnWorkLimitMinutes — предел простоя записи там, где работу делаем мы
// сами. Сторож ловит **зависание**, а не долгую работу: живой шаг
// наблюдается по самому процессу, а остановка обратима — снятие признака
// возвращает запись на её рубеж.
OwnWorkLimitMinutes int `toml:"own_work_limit_minutes"`
// ForeignWorkLimitMinutes — предел простоя там, где ждём чужую операцию.
// Сколько идёт распознавание долгой записи, никто не мерил, поэтому ошибка
// идёт в сторону долгого: ложная остановка хуже поздней.
ForeignWorkLimitMinutes int `toml:"foreign_work_limit_minutes"`
}
// StuckLimits переводит настройки в пределы простоя.
func (c PipelineConfig) StuckLimits() entity.StuckLimits {
return entity.StuckLimits{
Own: time.Duration(c.OwnWorkLimitMinutes) * time.Minute,
Foreign: time.Duration(c.ForeignWorkLimitMinutes) * time.Minute,
}
}
// Validate проверяет числа конвейера. Отрицательное число воркеров — ошибка
// настройки, а не режим: ноль объявлен законным значением, и отличать его от
// опечатки обязан старт.
func (c PipelineConfig) Validate() error {
if c.Workers < 0 {
return errors.New("pipeline: число воркеров не может быть отрицательным")
}
if c.OwnWorkLimitMinutes <= 0 || c.ForeignWorkLimitMinutes <= 0 {
return errors.New("pipeline: пределы простоя задаются положительным числом минут")
}
return nil
}
type ServerConfig struct {
Port int `toml:"port"`
ShutdownTimeout int `toml:"shutdown_timeout"`
@@ -143,6 +184,15 @@ func defaultConfig() *Config {
Storage: StorageConfig{
DataDir: "data",
},
Pipeline: PipelineConfig{
Workers: 3,
// Час на свою работу и сутки на чужую. Час меньше времени, которое
// многочасовая запись занимает на приведении, и это принято
// сознательно: сторож ловит зависание, живой шаг наблюдается по
// самому процессу, а остановка обратима.
OwnWorkLimitMinutes: 60,
ForeignWorkLimitMinutes: 24 * 60,
},
Yandex: YandexConfig{
FolderID: "",
SpeechKitAPIKey: "",
+69
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)
// Проверка входа — единственная страховка от того, чтобы сервис поднялся с
@@ -274,3 +275,71 @@ func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) {
t.Fatalf("место отказа не названо, чинить нечего: %v", err)
}
}
// Числа конвейера приезжают из файла, а не выдумываются кодом.
func TestLoadConfigReadsPipelineSettings(t *testing.T) {
path := writeConfig(t, `
[pipeline]
workers = 7
own_work_limit_minutes = 90
foreign_work_limit_minutes = 720
[telegram]
enabled = false
`)
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("конфиг не прочитан: %v", err)
}
if cfg.Pipeline.Workers != 7 {
t.Errorf("число воркеров не прочитано: %d", cfg.Pipeline.Workers)
}
limits := cfg.Pipeline.StuckLimits()
if limits.Own != 90*time.Minute {
t.Errorf("предел своей работы не прочитан: %v", limits.Own)
}
if limits.Foreign != 720*time.Minute {
t.Errorf("предел чужой работы не прочитан: %v", limits.Foreign)
}
}
// Умолчания есть у всех трёх чисел: файл без секции конвейера годен, и сервис
// поднимается с рабочими значениями.
func TestPipelineSettingsHaveDefaults(t *testing.T) {
path := writeConfig(t, "[telegram]\nenabled = false\n")
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("конфиг не прочитан: %v", err)
}
if cfg.Pipeline.Workers <= 0 {
t.Errorf("умолчание числа воркеров негодно: %d", cfg.Pipeline.Workers)
}
if err := cfg.Pipeline.Validate(); err != nil {
t.Errorf("умолчания не проходят собственную проверку: %v", err)
}
}
// Ноль воркеров — объявленный режим, а отрицательное число и нулевой предел —
// опечатка: подниматься с ней значит остановить всякую запись первым же
// захватом.
func TestPipelineValidateSeparatesModeFromTypo(t *testing.T) {
valid := PipelineConfig{Workers: 0, OwnWorkLimitMinutes: 60, ForeignWorkLimitMinutes: 1440}
if err := valid.Validate(); err != nil {
t.Errorf("ноль воркеров объявлен законным значением: %v", err)
}
for name, cfg := range map[string]PipelineConfig{
"отрицательное число воркеров": {Workers: -1, OwnWorkLimitMinutes: 60, ForeignWorkLimitMinutes: 1440},
"нулевой предел своей работы": {Workers: 1, OwnWorkLimitMinutes: 0, ForeignWorkLimitMinutes: 1440},
"нулевой предел чужой работы": {Workers: 1, OwnWorkLimitMinutes: 60, ForeignWorkLimitMinutes: 0},
} {
if err := cfg.Validate(); err == nil {
t.Errorf("%s принято за режим", name)
}
}
}