package entity import ( "strings" "time" "unicode" "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 // OriginalFilename — имя файла, данное отправителем. Лежит **отдельно от // заголовка**: заголовок несёт название, которое дал человек либо посчитала // языковая модель, а имя файла — то, по чему человек узнаёт свою запись, пока // заголовка нет. Одной колонкой на оба смысла посчитанное название затирало бы // имя, и вернуть затёртое было бы неоткуда. // // Значение приходит извне: приём режет его по MaxOriginalFilenameLen и убирает // управляющие знаки. В имя файла хранилища и в журнал оно не идёт — инвариант // приватности. OriginalFilename *string // DurationMs и SizeBytes — величины **принятого**, снимок с момента приёма. // Со строкой файла они намеренно не сверяются: там лежат величины той копии, // которой файл является сейчас, и уточнение длительности меняет их, не трогая // эти. Нужны колонками записи, потому что показываются в списке. // // Указатели здесь не выражают «неизвестно»: числовая колонка хранилища // пустого значения не держит, и пустое кладётся нулём. Обе величины ставит // приём и ставит всегда — запись с непрочитанными метаданными отвергается // отказом и не заводится вовсе. Решение владельца 2026-08-15. DurationMs *int64 SizeBytes *int64 // TopicIDs — темы записи. Ни приём, ни конвейер их не пишут: место заведено // вперёд, заполняет его задача, считающая темы языковой моделью. TopicIDs []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 } // MaxOriginalFilenameLen — потолок длины имени файла, данного отправителем. // // Имя приходит извне и содержимым своим приёму не подконтрольно, поэтому длина // назначается сервисом. Число выведено из предела длины имени в распространённых // файловых системах: имя длиннее 255 знаков не приходит от системного диалога // выбора файла вовсе, и всё, что длиннее, — либо самодельный запрос, либо // попытка раздуть строку записи. const MaxOriginalFilenameLen = 255 // SanitizeOriginalFilename приводит имя, данное отправителем, к пригодному для // хранения виду: убирает управляющие знаки и режет по потолку длины. // // Живёт в домене, а не в транспорте: имя доходит до колонки записи одним путём, // и правило чистки обязано быть одно. Управляющие знаки убираются потому, что // иначе доезжают до экрана и до панели владельца; резка идёт **после** уборки, // иначе потолок съедали бы знаки, которых в сохранённом имени всё равно не будет. // // Режется по знакам, а не по байтам: имя русское чаще, чем латинское, и обрезка // по байтам разрубила бы знак пополам. func SanitizeOriginalFilename(name string) string { cleaned := strings.Map(func(r rune) rune { if unicode.IsControl(r) { return -1 } return r }, name) runes := []rune(cleaned) if len(runes) > MaxOriginalFilenameLen { runes = runes[:MaxOriginalFilenameLen] } return string(runes) } // 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 }