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

- 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
+148 -219
View File
@@ -2,267 +2,196 @@ package service
import (
"context"
"encoding/json"
"errors"
"io"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Шаги распознавания переписаны переездом на новое хранилище целиком: они берут
// содержимое по записи, заводят запись о копии во внешнем хранилище и пишут
// результат условием по держателю захвата. Подставной распознаватель проекта
// умеет только «завершено с фиксированным текстом», поэтому ветки ожидания,
// отказа операции и пустого текста изобразить нечем — для них нужен управляемый
// двойник.
// scriptedRecognizer отдаёт заданный исход проверки операции и заданный текст.
type scriptedRecognizer struct {
result *entity.RecognitionResult
text string
recognizeErr error
recognizeCalls int
lastObjectKey string
// countingRecognizer считает обращения наружу: за них платят по факту, и повтор
// оплаченного шага — самый дорогой класс дефекта в этом сервисе.
type countingRecognizer struct {
uploads atomic.Int64
submits atomic.Int64
fetches atomic.Int64
parses atomic.Int64
objectHere atomic.Bool
inProgress atomic.Int64
}
func (r *scriptedRecognizer) Recognize(_ context.Context, file io.Reader, fileName string) (string, error) {
r.recognizeCalls++
r.lastObjectKey = fileName
if r.recognizeErr != nil {
return "", r.recognizeErr
func (r *countingRecognizer) Provider() string { return "counting" }
func (r *countingRecognizer) Model() string { return "counting" }
func (r *countingRecognizer) Upload(_ context.Context, _ io.Reader, objectKey string) (string, error) {
r.uploads.Add(1)
r.objectHere.Store(true)
return "counting://" + objectKey, nil
}
func (r *countingRecognizer) ObjectExists(context.Context, string, int64) (bool, error) {
return r.objectHere.Load(), nil
}
func (r *countingRecognizer) SourceURI(objectKey string) string {
return "counting://" + objectKey
}
func (r *countingRecognizer) Submit(context.Context, string) (string, error) {
r.submits.Add(1)
return uuid.NewString(), nil
}
func (r *countingRecognizer) CheckStatus(context.Context, string) (*entity.RecognitionResult, error) {
if r.inProgress.Load() > 0 {
r.inProgress.Add(-1)
return entity.NewInProgressResult(), nil
}
// Содержимое обязано быть читаемым: шаг отдаёт его наружу потоком.
if _, err := io.Copy(io.Discard, file); err != nil {
return "", err
return entity.NewCompletedResult(), nil
}
func (r *countingRecognizer) Fetch(context.Context, string) (*entity.RecognitionOutcome, error) {
r.fetches.Add(1)
return r.Parse(countingPayload(t0Replicas))
}
func (r *countingRecognizer) Parse(raw []byte) (*entity.RecognitionOutcome, error) {
r.parses.Add(1)
var replicas []entity.Replica
if err := json.Unmarshal(raw, &replicas); err != nil {
return nil, errors.New("не разобрать сохранённый ответ")
}
return "operation-id", nil
var plain []byte
for _, replica := range replicas {
if len(plain) > 0 {
plain = append(plain, ' ')
}
plain = append(plain, replica.Text...)
}
return &entity.RecognitionOutcome{Replicas: replicas, PlainText: string(plain), Raw: raw}, nil
}
func (r *scriptedRecognizer) GetRecognitionText(context.Context, string) (string, error) {
return r.text, nil
var t0Replicas = []entity.Replica{
{StartMs: 0, EndMs: 900, Text: "Первая реплика."},
{StartMs: 900, EndMs: 1800, Text: "Вторая реплика."},
}
func (r *scriptedRecognizer) CheckRecognitionStatus(context.Context, string) (*entity.RecognitionResult, error) {
return r.result, nil
func countingPayload(replicas []entity.Replica) []byte {
raw, err := json.Marshal(replicas)
if err != nil {
panic(err)
}
return raw
}
// convertedJob доводит задачу до состояния, с которого работает шаг
// распознавания: запись принята и сконвертирована.
func convertedJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob {
t.Helper()
// Критерий приёмки 4. Структура реплик строится из сохранённого ответа
// провайдера без единого обращения к нему: результат операции не
// переспрашивается, и архив пересчитывается из сохранённого без рубля.
func TestStructureIsBuiltFromStoredPayload(t *testing.T) {
rec := &countingRecognizer{}
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
job := newTelegramJob(t, env)
record := newTelegramRecord(t, env)
drain(t, env, record.Id)
acquired, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "setup", time.Now().Add(-time.Hour))
after := readRecord(t, env, record.Id)
require.Equal(t, entity.StateDone, after.State)
require.NotNil(t, after.RecognitionID)
// Сохранённый ответ на месте и читается целиком.
raw, err := env.repos.Recognitions.ReadRaw(*after.RecognitionID)
require.NoError(t, err)
acquired.MoveToState(entity.StateConverted)
require.NoError(t, env.jobRepo.Save(acquired, "setup"))
require.NotEmpty(t, raw, "сырой ответ провайдера сохранён")
return job
}
// А теперь — построение структуры из сохранённого, без обращений наружу.
fetchesBefore := rec.fetches.Load()
submitsBefore := rec.submits.Load()
// withRecognizer пересобирает сервис с управляемым распознавателем поверх того
// же хранилища.
func withRecognizer(env *pipelineEnv, rec contract.AudioRecognizer) *TranscribeService {
return NewTranscribeService(
env.jobRepo,
env.fileRepo,
&okMetaViewer{},
&failingConverter{},
rec,
env.sender,
env.service.logger,
)
}
// Шаг распознавания отдаёт содержимое наружу, заводит запись о копии во внешнем
// хранилище и переставляет на неё ссылку задачи — только после того, как запись
// о копии существует.
func TestTranscribeJobHandsRecordOverAndMovesOn(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job := convertedJob(t, env)
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeJob(t.Context()))
assert.Equal(t, 1, rec.recognizeCalls, "содержимое отдано распознавателю")
assert.NotEmpty(t, rec.lastObjectKey, "ключ объекта назван")
after, err := readJob(env.app, job.Id)
outcome, err := env.service.recognizer.Parse(raw)
require.NoError(t, err)
assert.Equal(t, entity.StateTranscribe, after.State)
require.NotNil(t, after.RecognitionOpID)
assert.Equal(t, "operation-id", *after.RecognitionOpID)
require.NotNil(t, after.DelayTime, "задержка перед первой проверкой поставлена")
require.Len(t, outcome.Replicas, len(t0Replicas), "структура собрана")
assert.Equal(t, t0Replicas[0].Text, outcome.Replicas[0].Text)
assert.Equal(t, t0Replicas[0].StartMs, outcome.Replicas[0].StartMs, "время реплики сохранено")
// Ссылка задачи ведёт на существующую запись о копии, а не на несозданную.
require.NotNil(t, after.FileID)
copyRecord, err := env.fileRepo.GetByID(*after.FileID)
assert.Equal(t, fetchesBefore, rec.fetches.Load(), "к провайдеру за результатом не ходили")
assert.Equal(t, submitsBefore, rec.submits.Load(), "и новой операции не заводили")
// Структура записи собрана из того же ответа и лежит своей строкой.
require.NotNil(t, after.StructureID)
structure, err := env.repos.Structures.GetByID(*after.StructureID)
require.NoError(t, err)
assert.Equal(t, entity.LocationS3, copyRecord.Location)
assert.Equal(t, entity.StructureVersion, structure.Version)
require.Len(t, structure.Replicas, len(t0Replicas))
assert.Equal(t, t0Replicas[1].Text, structure.Replicas[1].Text)
}
// Отказ распознавателя не двигает задачу: она остаётся пригодной к повтору.
func TestTranscribeJobKeepsJobRetryableOnRecognizerFailure(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job := convertedJob(t, env)
// Шаг с внешней оплатой проверяет сделанное: объект нужного размера на месте —
// заливку не повторяем; операция заведена — не платим второй раз.
func TestPaidWorkIsNotRepeated(t *testing.T) {
rec := &countingRecognizer{}
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
rec := &scriptedRecognizer{recognizeErr: errors.New("распознаватель недоступен")}
svc := withRecognizer(env, rec)
record := newTelegramRecord(t, env)
require.Error(t, svc.FindAndRunTranscribeJob(t.Context()))
// Приведение.
require.NoError(t, env.service.RunStep(t.Context()))
require.Equal(t, entity.StateNormalized, readRecord(t, env, record.Id).State)
after, err := readJob(env.app, job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateConverted, after.State, "задача осталась на своём шаге")
assert.Nil(t, after.AcquisitionID, "захват снят: задача пригодна к повтору")
assert.NotNil(t, after.DelayTime, "пауза перед повтором поставлена")
assert.Empty(t, env.sender.messages, "отправителю про повторимый отказ не пишут")
// Отправка.
require.NoError(t, env.service.RunStep(t.Context()))
require.Equal(t, int64(1), rec.uploads.Load(), "залили один раз")
require.Equal(t, int64(1), rec.submits.Load(), "и заплатили один раз")
// Возвращаем запись на рубеж отправки — так это выглядит, когда шаг оборвался
// после оплаты, а захват протух.
after := readRecord(t, env, record.Id)
after.MoveToState(entity.StateNormalized)
require.NoError(t, env.recordRepo.Save(after, ""))
require.NoError(t, env.service.RunStep(t.Context()))
assert.Equal(t, int64(1), rec.uploads.Load(), "объект на месте — заливка не повторилась")
assert.Equal(t, int64(1), rec.submits.Load(), "операция заведена — второй раз не платим")
}
// transcribingJob доводит задачу до состояния ожидания операции.
func transcribingJob(t *testing.T, env *pipelineEnv, rec contract.AudioRecognizer) *entity.TranscribeJob {
t.Helper()
// Ожидание чужой операции откладывается своей задержкой, отказов не тратит и
// рубежа не двигает.
func TestPollingPostponesWithoutSpendingAttempts(t *testing.T) {
rec := &countingRecognizer{}
rec.inProgress.Store(3)
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
job := convertedJob(t, env)
require.NoError(t, withRecognizer(env, rec).FindAndRunTranscribeJob(t.Context()))
record := newTelegramRecord(t, env)
clearDelay(t, env, job.Id)
return job
}
require.NoError(t, env.service.RunStep(t.Context())) // приведение
require.NoError(t, env.service.RunStep(t.Context())) // отправка
require.Equal(t, entity.StateSubmitted, readRecord(t, env, record.Id).State)
// Ожидание чужой операции попытку не тратит и опрос не учащает: шаг отработал
// без отказа, и задержка у него своя, числом.
func TestCheckJobWaitsWithoutSpendingAttempts(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
entered := readRecord(t, env, record.Id).StateEnteredAt
svc := withRecognizer(env, rec)
var delays []int64
for range 3 {
clearDelay(t, env, record.Id)
require.NoError(t, env.service.RunStep(t.Context()))
for i := 0; i < 3; i++ {
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
after := readRecord(t, env, record.Id)
require.Equal(t, entity.StateSubmitted, after.State, "рубеж не сдвинут откладыванием")
assert.Equal(t, 0, after.Attempts, "ожидание чужой операции отказа не тратит")
assert.WithinDuration(t, entered, after.StateEnteredAt, 0, "и отсчёт застревания не двигает")
after, err := readJob(env.app, job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateTranscribe, after.State)
assert.Equal(t, 0, after.Attempts, "ожидание операции попытку не тратит")
require.NotNil(t, after.DelayTime)
assert.InDelta(t, nextCheckDelay.Seconds(), time.Until(*after.DelayTime).Seconds(), 2,
"задержка опроса не выродилась в наименьшую паузу повтора")
clearDelay(t, env, job.Id)
delays = append(delays, after.DelayTime.Unix())
}
}
// Отказ операции распознавания — приговор записи: задача уходит в `failed`, а
// отправитель узнаёт причину человеческим текстом.
func TestCheckJobFailsJobAndTellsSender(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
rec.result = entity.NewFailedResult("операция отклонена")
svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
after, err := readJob(env.app, job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateFailed, after.State)
require.Len(t, env.sender.messages, 1, "отправитель узнал об отказе")
assert.Contains(t, env.sender.messages[0], "сбой при распознавании файла")
assert.NotContains(t, env.sender.messages[0], "операция отклонена",
"машинная причина отправителю не идёт")
}
// Готовая операция завершает задачу и отдаёт текст отправителю ровно один раз.
func TestCheckJobCompletesAndAnswersOnce(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
rec.result = entity.NewCompletedResult()
rec.text = "расшифровка записи"
svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
after, err := readJob(env.app, job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDone, after.State)
require.NotNil(t, after.TranscriptionText)
assert.Equal(t, "расшифровка записи", *after.TranscriptionText)
require.Len(t, env.sender.messages, 1, "отправитель получил ровно один ответ")
assert.Equal(t, "расшифровка записи", env.sender.messages[0])
// И задача из выборки исчезла: второй ответ отправителю неоткуда взяться.
_, err = env.jobRepo.FindAndAcquire(entity.StateTranscribe, "next", time.Now().Add(-time.Hour))
var missing *contract.JobNotFoundError
assert.ErrorAs(t, err, &missing)
}
// Пустая расшифровка — не отказ: задача завершается, а отправителю уходит
// объяснение вместо пустого сообщения.
func TestCheckJobCompletesEmptyTextWithExplanation(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
rec.result = entity.NewCompletedResult()
rec.text = ""
svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
after, err := readJob(env.app, job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDone, after.State)
require.Len(t, env.sender.messages, 1)
assert.Contains(t, strings.ToLower(env.sender.messages[0]), "нет текста")
}
// Шаг, потерявший захват за время работы, результата не пишет и отправителю не
// отвечает: иначе два воркера пишут в одну задачу, а отправитель получает два
// ответа на одну запись.
func TestCheckJobWritesNothingWhenAcquisitionLost(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
rec.result = entity.NewCompletedResult()
rec.text = "расшифровка записи"
// Захват задачи достался другому, пока шаг работал.
acquired, err := env.jobRepo.FindAndAcquire(entity.StateTranscribe, "mine", time.Now().Add(-time.Hour))
require.NoError(t, err)
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("acquisition_id", "someone-else")
require.NoError(t, env.app.Save(record))
svc := withRecognizer(env, rec)
err = svc.checkTranscribeJob(t.Context(), acquired, "mine")
var lost *contract.LostAcquisitionError
require.ErrorAs(t, err, &lost)
after, err := readJob(env.app, job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateTranscribe, after.State, "результат не записан")
assert.Empty(t, env.sender.messages, "отправителю ничего не отправлено")
assert.Len(t, delays, 3, "все три прогона отложили работу")
}