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

- 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
@@ -0,0 +1,347 @@
package migrations
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// up202608140002 перестраивает модель вокруг аудиозаписи.
//
// Прежняя коллекция задач уходит целиком: сервис на сервере остановлен, а
// прежние данные удалены решением владельца 2026-08-14 — переноса эта работа не
// делает, и оставленная пустая коллекция висела бы в панели вторым домом для
// того же понятия.
//
// Порядок заведения задан связями, а не вкусом: приложения ссылаются на запись,
// а запись — на них, поэтому запись заводится первой без обратных ссылок, потом
// приложения, и только потом ссылки дописываются.
//
// Правила доступа у новых коллекций остаются **незаданными**, то есть «только
// владелец панели». Содержимое записи отдаёт собственный адрес сервиса, а не
// поверхность хранилища; непустое правило открыло бы перечисление коллекции
// впрок, а норма проекта велит держать эту поверхность закрытой.
func up202608140002(app core.App) error {
users, err := app.FindCollectionByNameOrId(UsersCollection)
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
files, err := app.FindCollectionByNameOrId(FilesCollection)
if err != nil {
return fmt.Errorf("failed to find files collection: %w", err)
}
// Формат и длительность у копии: по ним видно, чем запись была, не открывая
// её. Расширение наружу выходит только приведённым к перечню известных.
files.Fields.Add(
&core.TextField{Name: "format"},
&core.NumberField{Name: "duration_ms", OnlyInt: true},
)
if err := app.Save(files); err != nil {
return fmt.Errorf("failed to extend files: %w", err)
}
topics, err := createTopics(app, users.Id)
if err != nil {
return err
}
records, err := createAudioRecords(app, users.Id, files.Id, topics.Id)
if err != nil {
return err
}
texts, err := createTexts(app, records.Id)
if err != nil {
return err
}
structures, err := createStructures(app, records.Id)
if err != nil {
return err
}
recognitions, err := createRecognitions(app, records.Id)
if err != nil {
return err
}
if err := createRecordEvents(app, records.Id); err != nil {
return err
}
// Обратные ссылки дописываются последними: раньше коллекций-целей ещё нет.
records.Fields.Add(
&core.RelationField{Name: "transcript_text", CollectionId: texts.Id, MaxSelect: 1},
&core.RelationField{Name: "literary_text", CollectionId: texts.Id, MaxSelect: 1},
&core.RelationField{Name: "structure", CollectionId: structures.Id, MaxSelect: 1},
&core.RelationField{Name: "recognition", CollectionId: recognitions.Id, MaxSelect: 1},
)
if err := app.Save(records); err != nil {
return fmt.Errorf("failed to link audio records to their appendices: %w", err)
}
jobs, err := app.FindCollectionByNameOrId(JobsCollection)
if err != nil {
return fmt.Errorf("failed to find jobs collection: %w", err)
}
if err := app.Delete(jobs); err != nil {
return fmt.Errorf("failed to drop the former jobs collection: %w", err)
}
return nil
}
// createAudioRecords заводит центральную сущность.
//
// Ссылки на файлы две и порознь: шаг конвейера больше не переставляет одну на
// свой результат, и исходник остаётся доступным после того, как запись прошла
// конвейер.
func createAudioRecords(app core.App, usersID, filesID, topicsID string) (*core.Collection, error) {
records := core.NewBaseCollection(RecordsCollection)
records.Fields.Add(
ownerField(usersID),
&core.SelectField{
Name: "source",
Values: []string{entity.SourceUnknown, entity.SourceApi, entity.SourceTelegram},
MaxSelect: 1,
Required: true,
},
// Заголовок и краткое описание читаются вместе со списком, сотней штук
// разом, и потому лежат колонками записи, а не строками текстов.
&core.TextField{Name: "title"},
&core.TextField{Name: "brief"},
// Перечень рубежей закрыт схемой: запись, заведённая в панели руками, не
// должна попасть в выборку с рубежом, которого конвейер не знает.
&core.SelectField{
Name: "state",
Values: entity.AllStates(),
MaxSelect: 1,
Required: true,
},
// Время входа в рубеж — сторож застревания. Ставится только сменой рубежа
// и возвратом записи в работу; откладывание опроса его не двигает.
&core.DateField{Name: "state_entered_at"},
// Остановка — признак, а не рубеж: `state` при ней не стирается, и снятие
// признака продолжает работу с места остановки.
&core.DateField{Name: "halted_at"},
&core.SelectField{
Name: "halt_reason",
Values: entity.AllHaltReasons(),
MaxSelect: 1,
},
&core.TextField{Name: "error_text"},
// Признак **этого** захвата: значение уникально для каждого захвата, и
// запись результата условна по нему, а не по занятости записи.
&core.TextField{Name: "acquisition_id"},
// Срок протухания захвата приезжает с рубежом и пишется числом при самом
// захвате: воркер не привязан к шагу и вывести срок из себя не может.
&core.DateField{Name: "acquire_expires_at"},
&core.DateField{Name: "delay_time"},
// Число отказов ограничивает повторы внутри шага. Время в рубеже мерит
// отдельный сторож: одно число не справлялось ни с одной из обязанностей.
&core.NumberField{Name: "attempts", OnlyInt: true, Min: ptr(0.0)},
&core.RelationField{Name: "original_file", CollectionId: filesID, MaxSelect: 1},
&core.RelationField{Name: "normalized_file", CollectionId: filesID, MaxSelect: 1},
&core.RelationField{
Name: "topics",
CollectionId: topicsID,
MaxSelect: entity.MaxTopicsPerRecord,
},
&core.NumberField{Name: "tg_chat_id", OnlyInt: true},
&core.NumberField{Name: "tg_reply_message_id", OnlyInt: true},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
// Отбор захвата идёт по рубежу, признаку остановки, паузе и сроку протухания
// захвата — индекс снимает полный перебор.
records.AddIndex("idx_audio_records_state", false, "state, halted_at", "")
if err := app.Save(records); err != nil {
return nil, fmt.Errorf("failed to create audio records: %w", err)
}
return records, nil
}
// createTexts заводит тексты записи. Пара «запись и вид» уникальна: повтор
// прерванного шага иначе завёл бы второй комплект строк, и вопрос «какой текст
// отдавать человеку» стал бы вопросом порядка записи, а не состояния.
func createTexts(app core.App, recordsID string) (*core.Collection, error) {
texts := core.NewBaseCollection(TextsCollection)
texts.Fields.Add(
&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
&core.SelectField{
Name: "kind",
Values: entity.AllTextKinds(),
MaxSelect: 1,
Required: true,
},
// Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме
// зовут формат файла, и третий смысл у одного слова развёл бы по разным
// вещам вид текста и формат копии.
&core.EditorField{Name: "contents"},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
texts.AddIndex("idx_texts_record_kind", true, "record, kind", "")
if err := app.Save(texts); err != nil {
return nil, fmt.Errorf("failed to create texts: %w", err)
}
return texts, nil
}
// createStructures заводит структуру реплик. Номер версии нужен потому, что
// разбор сохранённого ответа изменится раньше, чем архив пересчитают.
func createStructures(app core.App, recordsID string) (*core.Collection, error) {
structures := core.NewBaseCollection(StructuresCollection)
structures.Fields.Add(
&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
&core.NumberField{Name: "version", OnlyInt: true, Required: true},
&core.JSONField{Name: "contents", MaxSize: structureContentsMaxSize},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
structures.AddIndex("idx_structures_record_version", true, "record, version", "")
if err := app.Save(structures); err != nil {
return nil, fmt.Errorf("failed to create structures: %w", err)
}
return structures, nil
}
// createRecognitions заводит попытку распознавания у внешнего провайдера.
//
// Сырой ответ лежит **вложением**, а не колонкой: шаг опроса читает эту строку
// раз в несколько секунд, а хранилище читает запись целиком — ответ на
// многочасовую запись ехал бы в память при каждом опросе.
//
// Поле вложения помечено защищённым: сырой ответ это полный текст речи, и
// умолчание библиотеки отдавало бы его по ссылке любому, кто её знает.
func createRecognitions(app core.App, recordsID string) (*core.Collection, error) {
recognitions := core.NewBaseCollection(RecognitionsCollection)
payload := &core.FileField{Name: "payload", MaxSelect: 1, MaxSize: recognitionPayloadMaxSize}
payload.Protected = true
recognitions.Fields.Add(
&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
&core.TextField{Name: "provider", Required: true},
&core.TextField{Name: "model"},
// Идентификатор операции у провайдера — самое провайдерское, что есть в
// модели, и живёт он здесь, а не колонкой записи.
&core.TextField{Name: "external_id"},
// Адрес, по которому провайдер читает аудио. Копия во внешнем хранилище
// файлом записи не считается: другой провайдер её не потребует.
&core.TextField{Name: "source_uri"},
payload,
&core.DateField{Name: "started_at"},
&core.DateField{Name: "finished_at"},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
if err := app.Save(recognitions); err != nil {
return nil, fmt.Errorf("failed to create recognitions: %w", err)
}
return recognitions, nil
}
// createRecordEvents заводит журнал событий записи.
//
// Колонка текста отказа зовётся `outcome_text`, а не `error_text`: последнее имя
// названо поимённо инвариантом проекта о секрете, и две колонки с этим именем
// сделали бы инвариант двусмысленным.
func createRecordEvents(app core.App, recordsID string) error {
events := core.NewBaseCollection(RecordEventsCollection)
events.Fields.Add(
&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
&core.SelectField{
Name: "origin",
Values: entity.AllEventOrigins(),
MaxSelect: 1,
Required: true,
},
&core.TextField{Name: "step"},
&core.SelectField{
Name: "outcome",
Values: entity.AllEventOutcomes(),
MaxSelect: 1,
Required: true,
},
&core.TextField{Name: "outcome_text"},
&core.NumberField{Name: "duration_ms", OnlyInt: true},
&core.AutodateField{Name: "created", OnCreate: true},
)
events.AddIndex("idx_record_events_record", false, "record", "")
if err := app.Save(events); err != nil {
return fmt.Errorf("failed to create record events: %w", err)
}
return nil
}
// createTopics заводит словарь тем. Тема уникальна в паре «владелец и название»:
// словарь свой у каждого человека, и общий показал бы одному темы другого.
func createTopics(app core.App, usersID string) (*core.Collection, error) {
topics := core.NewBaseCollection(TopicsCollection)
topics.Fields.Add(
&core.RelationField{
Name: "owner",
CollectionId: usersID,
MaxSelect: 1,
Required: true,
},
&core.TextField{Name: "name", Required: true},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
topics.AddIndex("idx_topics_owner_name", true, "owner, name", "")
if err := app.Save(topics); err != nil {
return nil, fmt.Errorf("failed to create topics: %w", err)
}
return topics, nil
}
const (
// structureContentsMaxSize — потолок разбитой на реплики расшифровки. Число
// с запасом: шестичасовой разговор даёт порядка мегабайта текста с временем.
structureContentsMaxSize = 16 << 20
// recognitionPayloadMaxSize — потолок сохранённого ответа провайдера. Он
// многословнее самой расшифровки: несёт альтернативы, время каждого слова и
// разбор говорящих.
recognitionPayloadMaxSize = 256 << 20
)
// Поля объявляются россыпью, а не помощником, который принимал бы имя доводом:
// сверка перечня колонок со схемой читает литерал `Name:` в шагах, и имя,
// спрятанное за вызовом, она не видит — колонка выпала бы из-под правила молча.
// down202608140002 снимает новые коллекции. Прежнюю коллекцию задач он не
// восстанавливает: данных под ней не было, а пустая копия прежней схемы была бы
// вторым домом для понятия, которого больше нет.
func down202608140002(app core.App) error {
// Порядок обратный порядку заведения: приложения ссылаются на запись.
order := []string{
RecordEventsCollection,
RecognitionsCollection,
StructuresCollection,
TextsCollection,
RecordsCollection,
TopicsCollection,
}
for _, name := range order {
collection, err := app.FindCollectionByNameOrId(name)
if err != nil {
continue
}
if err := app.Delete(collection); err != nil {
return fmt.Errorf("failed to drop %s: %w", name, err)
}
}
return nil
}
@@ -23,7 +23,19 @@ import (
// меняются только новым шагом схемы.
const (
FilesCollection = "files"
JobsCollection = "transcribe_jobs"
// JobsCollection — прежняя коллекция задач. Шаг 202608140002 её удаляет;
// имя остаётся здесь, потому что на него ссылаются прежние шаги схемы, а
// применённый шаг не переписывается.
JobsCollection = "transcribe_jobs"
// RecordsCollection — аудиозапись, центральная сущность сервиса. Имя в
// snake_case, как у соседей по схеме: одно исключение разошлось бы молча по
// константе имён, запросу захвата, правилам панели и запрету удаления.
RecordsCollection = "audio_records"
TextsCollection = "texts"
StructuresCollection = "structures"
RecognitionsCollection = "recognitions"
RecordEventsCollection = "record_events"
TopicsCollection = "topics"
// UsersCollection заводит не наш шаг, а системный шаг библиотеки. Имя стоит
// здесь потому, что на него ссылаются и шаги схемы, и проверка предъявителя
// на приёме: строковый литерал в двух местах разошёлся бы молча.
@@ -36,6 +48,7 @@ func init() {
pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.go")
pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go")
pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go")
pbmigrations.Register(up202608140002, down202608140002, "202608140002_record_centric_model.go")
}
func ptr[T any](v T) *T { return &v }