Files
transcriber/internal/entity/audio_record.go
T
av 8f7c3a057a удалён вход Telegram, владелец записи стал обязателен в схеме
- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка
  входа при старте, секция настроек и зависимость go-telegram-bot-api; из
  конвейера ушла доставка ответа отправителю — исход виден опросом готовности.
  Колонки адресата и значение источника остались в схеме: применённые шаги не
  переписываются
- шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла;
  существующие строки он не проверяет, и это принято сознательно — искать их
  надо запросом до выкладки
- ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ
  распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала
  быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image
  до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
2026-08-15 07:24:35 +03:00

198 lines
9.5 KiB
Go

package entity
import (
"time"
"git.vakhrushev.me/av/transcriber/internal/clock"
)
// Рубежи конвейера. Рубеж называет **достигнутое**, а не предстоящее: по нему
// видно, что с записью уже сделано, и потому остановленная запись продолжает с
// места остановки, а не с начала.
//
// Конечный рубеж зовётся `done`: доставка ответа отправителю в конвейер не
// входит, и слово описывает пройденный конвейер, а не полученный человеком
// текст.
const (
StateUploaded = "uploaded"
StateNormalized = "normalized"
StateSubmitted = "submitted"
StateTranscribed = "transcribed"
StateDone = "done"
)
// Причины остановки. Прежние состояния `failed` и `dead` схлопнуты сюда: обе
// восстанавливаются одинаково — снятием признака, — и различие между ними
// перестало быть структурным.
const (
// HaltReasonStepFailed — шаг рассудил об этой записи окончательно.
HaltReasonStepFailed = "step_failed"
// HaltReasonAttempts — мы повторяли и перестали.
HaltReasonAttempts = "attempts_exhausted"
// HaltReasonStuck — запись простояла в рубеже дольше предела.
HaltReasonStuck = "stuck"
)
const (
SourceUnknown = "unknown"
SourceApi = "api"
// SourceTelegram — историческое значение. Вход Telegram убран, новых записей
// с этим источником не появляется, а константа остаётся: на неё ссылается
// применённый шаг схемы `202608140002`, а применённый шаг не переписывается.
SourceTelegram = "telegram"
)
// AudioRecord — аудиозапись, центральная сущность сервиса.
//
// Приложения к ней — файлы, тексты, структура реплик, темы, журнал событий и
// попытка распознавания — живут своими строками и адресуются ссылками. Поля
// очереди соседствуют с доменом, но не с содержимым: расшифровка лежит строкой
// `texts`, и чтение очереди её не тянет.
type AudioRecord struct {
Id string
// OwnerID — учётная запись, от имени которой запись принята. Обязателен:
// колонка владельца пустого значения не принимает, и ничьей записи в
// хранилище не бывает. Назначается один раз, при приёме, и конвейером не
// меняется.
OwnerID string
Source string
// Title и Brief читаются вместе со списком, сотней штук разом, и потому
// лежат колонками записи, а не строками `texts`.
Title *string
Brief *string
State string
// StateEnteredAt ставится только сменой рубежа и возвратом записи в работу.
// Откладывание опроса его не двигает — иначе застревание в чужой операции
// не наступало бы никогда.
StateEnteredAt time.Time
// Остановка — признак, а не рубеж: `State` при ней не стирается.
HaltedAt *time.Time
HaltReason *string
ErrorText *string
// AcquisitionID — признак **этого** захвата, значение уникальное для каждого.
// Запись результата условна по нему, а не по занятости записи: захват,
// перевыданный другому — по протуханию срока или после снятия остановки
// человеком, — обязан обратить запись первого в отказ.
AcquisitionID *string
AcquireExpiresAt *time.Time
DelayTime *time.Time
// Attempts считает **отказы** и ограничивает повторы внутри шага. Время в
// рубеже мерит StateEnteredAt: одно число не справлялось ни с одной из двух
// обязанностей.
Attempts int
// Ссылки на файлы живут порознь и не переставляются: исходник остаётся
// доступным после того, как запись прошла конвейер.
OriginalFileID *string
NormalizedFileID *string
StructureID *string
TranscriptTextID *string
LiteraryTextID *string
RecognitionID *string
CreatedAt time.Time
UpdatedAt time.Time
}
// AllStates — закрытый перечень рубежей для схемы хранилища.
func AllStates() []string {
out := make([]string, 0, len(stages))
for _, s := range stages {
out = append(out, s.Name)
}
return out
}
// AllHaltReasons — закрытый перечень причин остановки для схемы хранилища.
func AllHaltReasons() []string {
return []string{HaltReasonStepFailed, HaltReasonAttempts, HaltReasonStuck}
}
// MoveToState двигает запись на новый рубеж и чистит служебные поля прошлого.
//
// Время входа в рубеж ставится заново: с этой минуты идёт отсчёт застревания.
// Число отказов обнуляется — шаг, дошедший до перехода, завершился без отказа, а
// отказы считают именно отказавшие: иначе запись, прошедшая конвейер целиком,
// накопила бы их поштучно и остановилась бы здоровой.
func (r *AudioRecord) MoveToState(state string) {
now := clock.Now()
r.State = state
r.StateEnteredAt = now
r.DelayTime = nil
r.AcquisitionID = nil
r.AcquireExpiresAt = nil
r.Attempts = 0
r.UpdatedAt = now
}
// Postpone откладывает работу над записью: ставит паузу и снимает захват.
//
// Переходом это не является и потому не трогает ни рубеж, ни время входа в
// него. Число отказов обнуляется по прежнему доводу — ожидание чужой операции
// отказом не является.
//
// Прежде шаг опроса звал переход с **тем же** состоянием, и мнимость этого
// перехода обнуляла сторожа. Без разделения время входа в рубеж сбрасывалось бы
// на каждом опросе и повторило бы ровно тот промах, ради которого заводится.
func (r *AudioRecord) Postpone(until time.Time) {
r.DelayTime = &until
r.AcquisitionID = nil
r.AcquireExpiresAt = nil
r.Attempts = 0
r.UpdatedAt = clock.Now()
}
// RetryAfter освобождает отказавшую запись для повтора: захват снимается, пауза
// ставится, а число отказов сохраняется — по нему растёт пауза и наступает
// предел.
func (r *AudioRecord) RetryAfter(delay time.Time) {
r.AcquisitionID = nil
r.AcquireExpiresAt = nil
r.DelayTime = &delay
r.UpdatedAt = clock.Now()
}
// Halt останавливает запись признаком, сохраняя достигнутый рубеж.
//
// Число отказов сохраняется: по нему видно, сколько раз пробовали. Захват
// снимается — остановленная запись всё равно не выдаётся, а оставленный признак
// захвата помешал бы первому же захвату после снятия остановки.
func (r *AudioRecord) Halt(reason, errText string) {
now := clock.Now()
r.HaltedAt = &now
r.HaltReason = &reason
r.ErrorText = &errText
r.AcquisitionID = nil
r.AcquireExpiresAt = nil
r.DelayTime = nil
r.UpdatedAt = now
}
// Resume возвращает остановленную запись в работу с сохранённого рубежа.
//
// Сбрасываются все три сторожа. Время входа в рубеж — тоже, и это не
// избыточность: запись, простоявшая остановленной дольше предела, иначе
// останавливалась бы снова первым же захватом, и перезапуск не работал бы вовсе.
func (r *AudioRecord) Resume() {
now := clock.Now()
r.HaltedAt = nil
r.HaltReason = nil
r.ErrorText = nil
r.Attempts = 0
r.DelayTime = nil
r.AcquisitionID = nil
r.AcquireExpiresAt = nil
r.StateEnteredAt = now
r.UpdatedAt = now
}
// IsHalted — стоит ли на записи признак остановки.
func (r *AudioRecord) IsHalted() bool {
return r.HaltedAt != nil
}