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

- 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
+40 -29
View File
@@ -14,6 +14,7 @@ import (
"strings"
"sync"
"testing"
"time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
@@ -24,6 +25,7 @@ import (
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/service"
@@ -167,8 +169,16 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
pbrepo.BindPanelRules(app)
fileRepo := pbrepo.NewFileRepository(app)
jobRepo := pbrepo.NewTranscriptJobRepository(app)
recordRepo := pbrepo.NewAudioRecordRepository(app)
textRepo := pbrepo.NewTextRepository(app)
repos := service.Repositories{
Records: recordRepo,
Files: pbrepo.NewFileRepository(app),
Texts: textRepo,
Structures: pbrepo.NewStructureRepository(app),
Recognitions: pbrepo.NewRecognitionRepository(app),
Events: pbrepo.NewRecordEventRepository(app),
}
// Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на
// имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки
@@ -178,16 +188,16 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
logger := slog.New(slog.NewTextHandler(journal, nil))
trsService := service.NewTranscribeService(
jobRepo,
fileRepo,
repos,
metaviewer,
&stubConverter{},
&recognizer.MemoryAudioRecognizer{},
&TestTgSender{},
entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour},
logger,
)
handler := NewTranscribeHandler(jobRepo, trsService, logger)
handler := NewTranscribeHandler(recordRepo, textRepo, trsService, logger)
// Роутер собирается тем же способом, что и боевой: маршруты вешает сам
// обработчик, и проверка судит ту же цепочку, что и прод.
@@ -256,16 +266,16 @@ func countFiles(t *testing.T, env *testEnv) int {
return len(records)
}
// countJobs считает заведённые задачи расшифровки.
// countJobs считает заведённые аудиозаписи.
func countJobs(t *testing.T, env *testEnv) int {
records, err := env.app.FindAllRecords(migrations.JobsCollection)
records, err := env.app.FindAllRecords(migrations.RecordsCollection)
require.NoError(t, err)
return len(records)
}
// jobWithFile заводит задачу вместе с её записью: ссылка на файл обязательна
// схемой, потому что без неё задача не пройдёт ни одного шага.
func jobWithFile(t *testing.T, env *testEnv) *entity.TranscribeJob {
func jobWithFile(t *testing.T, env *testEnv) *entity.AudioRecord {
t.Helper()
repo := pbrepo.NewFileRepository(env.app)
@@ -276,17 +286,18 @@ func jobWithFile(t *testing.T, env *testEnv) *entity.TranscribeJob {
// Владелец — учётная запись проверки: задача, пришедшая из веба, без
// владельца больше не заводится, и фикстура без него описывала бы состояние,
// которого в проде не бывает.
file, err := repo.CreateLocal("sample.mp3", work, env.account.Id)
file, err := repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, env.account.Id)
require.NoError(t, err)
job := &entity.TranscribeJob{
State: entity.StateCreated,
Source: entity.SourceApi,
OwnerID: &env.account.Id,
FileID: &file.Id,
record := &entity.AudioRecord{
State: entity.StateUploaded,
StateEnteredAt: clock.Now(),
Source: entity.SourceApi,
OwnerID: &env.account.Id,
OriginalFileID: &file.Id,
}
require.NoError(t, env.handler.jobRepo.Create(job))
return job
require.NoError(t, env.handler.recordRepo.Create(record))
return record
}
// storedContent читает содержимое файла из хранилища.
@@ -327,21 +338,21 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
require.NoError(t, err)
assert.NotEmpty(t, response.JobID)
assert.Equal(t, entity.StateCreated, response.State)
assert.Equal(t, entity.StateUploaded, response.State)
// Задача действительно заведена, а не только названа в ответе: иначе
// отправитель получит идентификатор записи, которой не будет никогда.
require.Equal(t, 1, countJobs(t, env))
job, err := env.handler.jobRepo.GetByID(response.JobID, env.account.Id)
job, err := env.handler.recordRepo.GetByID(response.JobID, env.account.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateCreated, job.State)
require.NotNil(t, job.FileID)
assert.NotEmpty(t, *job.FileID)
assert.Equal(t, entity.StateUploaded, job.State)
require.NotNil(t, job.OriginalFileID)
assert.NotEmpty(t, *job.OriginalFileID)
// Содержимое лежит в хранилище одним файлом и целиком.
require.Equal(t, 1, countFiles(t, env))
assert.Equal(t, content, storedContent(t, env, *job.FileID))
assert.Equal(t, content, storedContent(t, env, *job.OriginalFileID))
}
func TestCreateTranscribeJob_NoFile(t *testing.T) {
@@ -404,7 +415,7 @@ func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
require.NoError(t, err)
assert.NotEmpty(t, response.JobID)
assert.Equal(t, entity.StateCreated, response.State)
assert.Equal(t, entity.StateUploaded, response.State)
}
func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) {
@@ -513,7 +524,7 @@ const senderNameMarker = "SENDERNAMELEAKMARKER7Q2"
// долг `docs/conventions/logging.md`: `msg` обязан стать короткой категорией.
// Когда долг закроют, правка будет здесь и одна.
const (
msgIntake = "Creating transcribe job"
msgIntake = "Creating audio record"
// Отказ пишет доменная граница — приём, — а не транспорт: конвенция просит
// логировать ошибку один раз, и повторная запись транспорта снята.
msgIntakeErr = "Failed to get file info"
@@ -603,15 +614,15 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
var response CreateTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
job, err := env.handler.jobRepo.GetByID(response.JobID, env.account.Id)
job, err := env.handler.recordRepo.GetByID(response.JobID, env.account.Id)
require.NoError(t, err)
require.NotNil(t, job.FileID)
require.NotNil(t, job.OriginalFileID)
// Отбор по идентификатору **этого** прогона: иначе утверждение прошло бы по
// строке, оставленной соседней проверкой, и прослеживаемость числилась бы
// сохранённой при пустом журнале.
journal := env.journal.String()
assert.Contains(t, journal, *job.FileID, "по журналу видно, какой файл заведён")
assert.Contains(t, journal, *job.OriginalFileID, "по журналу видно, какой файл заведён")
assert.Contains(t, journal, ".mp3", "расширение принятой записи в журнале остаётся")
// Разделитель ключа и значения задаёт обработчик: сегодня текстовый, по
@@ -698,7 +709,7 @@ func TestGetTranscribeJobStatus_Success(t *testing.T) {
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
assert.Equal(t, job.Id, response.JobID)
assert.Equal(t, entity.StateCreated, response.State)
assert.Equal(t, entity.StateUploaded, response.State)
assert.NotZero(t, response.CreatedAt)
}
@@ -761,7 +772,7 @@ func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) {
require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String())
jobs, err := env.app.FindAllRecords(migrations.JobsCollection)
jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
require.NoError(t, err)
assert.Len(t, jobs, 1, "задача заведена, несмотря на ушедшего отправителя")
}