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

- 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
+85 -26
View File
@@ -2,7 +2,6 @@ package contract
import (
"io"
"time"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
@@ -23,6 +22,14 @@ type WorkFile interface {
Close() error
}
// FileMeta — что известно о копии сверх её содержимого.
type FileMeta struct {
// Format — расширение без точки, в нижнем регистре.
Format string
// DurationMs — длительность, если её удалось прочитать.
DurationMs int64
}
type FileRepository interface {
// Stage принимает содержимое потоком в рабочую копию с заданным
// расширением: по нему внешняя программа выбирает разбор. В память запись
@@ -33,44 +40,96 @@ type FileRepository interface {
StageEmpty(ext string) (WorkFile, error)
// Localize выдаёт рабочую копию хранимого файла.
Localize(fileID string) (WorkFile, error)
// CreateLocal кладёт рабочую копию в хранилище под именем name и заводит
// запись о файле. Имя задаёт сервис: умолчание хранилища, строящее его из
// имени отправителя, не применяется.
// Create кладёт рабочую копию в хранилище под именем name и заводит запись о
// файле. Имя задаёт сервис: умолчание хранилища, строящее его из имени
// отправителя, не применяется.
//
// ownerID — владелец записи, которой файл принадлежит; пустой значит «файл
// без владельца», и таков всякий файл записи, принятой ботом. Владелец
// лежит своей колонкой, а не выводится через задачу: ссылку на файл в
// задаче переставляет каждый шаг конвейера, и исходная копия после
// конвертации не связана с задачей ничем.
CreateLocal(name string, work WorkFile, ownerID string) (*entity.File, error)
// CreateRemote заводит запись о копии, лежащей во внешнем хранилище.
CreateRemote(objectKey string, size int64, ownerID string) (*entity.File, error)
// лежит своей колонкой, а не выводится через запись: файл переживает свою
// запись — шаг заводит его до сохранения, и потерянный захват оставляет файл
// с владельцем и без ссылки.
Create(name string, work WorkFile, meta FileMeta, ownerID string) (*entity.File, error)
GetByID(id string) (*entity.File, error)
// Open отдаёт содержимое хранимого файла потоком.
Open(fileID string) (io.ReadCloser, error)
}
type TranscriptJobRepository interface {
Create(job *entity.TranscribeJob) error
// Save сохраняет задачу, захват которой держит holder. Захват, доставшийся
// AcquiredRecord — то, что отдаёт захват: идентификатор записи и признак
// **этого** захвата.
//
// Перечня колонок здесь нет намеренно. Захват, возвращавший колонки поимённо,
// требовал править их в четырёх местах сразу, и забытая колонка приезжала
// нулевой, а первое же сохранение писало этот ноль поверх значения. Колонки шаг
// читает обычным чтением.
type AcquiredRecord struct {
ID string
// Holder — значение, уникальное для каждого захвата. Запись результата
// условна по нему, а не по занятости записи: захват, перевыданный другому по
// протуханию срока или после снятия остановки человеком, обязан обратить
// запись первого в отказ.
Holder string
}
type AudioRecordRepository interface {
Create(record *entity.AudioRecord) error
// Save сохраняет запись, захват которой держит holder. Захват, доставшийся
// за время работы другому, даёт LostAcquisitionError и запись не проводит.
// Пустой holder снимает эту условность и в конвейере не употребляется: все
// его шаги получают признак захвата от FindAndAcquire.
Save(job *entity.TranscribeJob, holder string) error
// GetByID отдаёт задачу, только если её владелец — ownerID. Чужая задача,
// ничья задача и несуществующая дают одну и ту же ошибку: по разнице
// ответов иначе перебирается список заведённых задач.
Save(record *entity.AudioRecord, holder string) error
// GetByID отдаёт запись, только если её владелец — ownerID. Чужая запись,
// ничья и несуществующая дают одну и ту же ошибку: по разнице ответов иначе
// перебирается список заведённых записей.
//
// Владелец здесь обязателен, и пустой ownerID не совпадает ни с чем —
// включая задачи без владельца. Правило записано со стороны спрашивающего:
// включая записи без владельца. Правило записано со стороны спрашивающего:
// обязательность, которую держит одна лишь подпись метода, пустую строку
// пропускает, и вызывающий без учётной записи получил бы ровно множество
// записей бота.
// пропускает.
GetByID(id, ownerID string) (*entity.AudioRecord, error)
// Get отдаёт запись без сужения владельцем: им пользуется конвейер, чья
// выборка владельцем не сужается.
Get(id string) (*entity.AudioRecord, error)
// FindAndAcquire забирает пригодную к работе запись одним неделимым шагом и
// увеличивает число её отказов. Отбор идёт по рабочим рубежам, паузе, сроку
// протухания захвата и отсутствию признака остановки; срок протухания
// приезжает с рубежом и пишется в саму запись.
//
// Второго читающего метода нет намеренно: он выбирался бы по
// внимательности вызывающего.
GetByID(id, ownerID string) (*entity.TranscribeJob, error)
// FindAndAcquire забирает задачу одним неделимым шагом и увеличивает число
// её попыток. Работы в состоянии нет — JobNotFoundError.
FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error)
// Работы нет — JobNotFoundError.
FindAndAcquire(stages []entity.Stage) (*AcquiredRecord, error)
}
// TextRepository — тексты записи. Пара «запись и вид» уникальна: повтор
// прерванного шага не заводит второй строки.
type TextRepository interface {
Put(recordID, kind, contents string) (*entity.Text, error)
GetByID(id string) (*entity.Text, error)
}
// StructureRepository — структура реплик записи. Пара «запись и версия разбора»
// уникальна по той же причине.
type StructureRepository interface {
Put(recordID string, version int, replicas []entity.Replica) (*entity.Structure, error)
GetByID(id string) (*entity.Structure, error)
}
// RecognitionRepository — попытка распознавания у внешнего провайдера.
type RecognitionRepository interface {
// Create заводит строку попытки **до** обращения к провайдеру: окно между
// его ответом и записью идентификатора — то место, где теряется оплаченное.
Create(recognition *entity.Recognition) error
// Submitted сохраняет адрес аудио и идентификатор заведённой операции. По
// последнему повторный шаг узнаёт, что за эту запись уже заплачено.
Submitted(id, sourceURI, externalID string) error
// Finish отмечает завершение операции и кладёт сырой ответ вложением.
Finish(id string, raw []byte) error
GetByID(id string) (*entity.Recognition, error)
// ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда
// ответ нужен: шаг опроса читает строку попытки без него.
ReadRaw(id string) ([]byte, error)
}
// RecordEventRepository — журнал событий записи.
type RecordEventRepository interface {
Append(event *entity.RecordEvent) error
}