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

- 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
+59 -26
View File
@@ -18,16 +18,22 @@ import (
)
type TranscribeHandler struct {
jobRepo contract.TranscriptJobRepository
recordRepo contract.AudioRecordRepository
textRepo contract.TextRepository
trsService *service.TranscribeService
logger *slog.Logger
}
func NewTranscribeHandler(jobRepo contract.TranscriptJobRepository, trsService *service.TranscribeService, logger *slog.Logger) *TranscribeHandler {
func NewTranscribeHandler(
recordRepo contract.AudioRecordRepository,
textRepo contract.TextRepository,
trsService *service.TranscribeService,
logger *slog.Logger,
) *TranscribeHandler {
if logger == nil {
logger = slog.Default()
}
return &TranscribeHandler{jobRepo: jobRepo, trsService: trsService, logger: logger}
return &TranscribeHandler{recordRepo: recordRepo, textRepo: textRepo, trsService: trsService, logger: logger}
}
type CreateTranscribeJobResponse struct {
@@ -35,16 +41,27 @@ type CreateTranscribeJobResponse struct {
State string `json:"status"`
}
// GetTranscribeJobResponse — ответ об одной записи.
//
// Имена полей нормативны и остались прежними: контракт HTTP API объявлен
// проектом необратимым, и переименование поля ломает внешнюю программу молча.
// Изменились **значения** поля состояния — рубеж теперь называет достигнутое, — и
// это объявленная ломка.
//
// Поле `halted` новое: отказ перестал быть состоянием, и без него остановленная
// запись выглядела бы как обычная, стоящая на своём рубеже. Машинный текст
// отказа в ответ не идёт: он принадлежит журналу владельца сервиса.
type GetTranscribeJobResponse struct {
JobID string `json:"job_id"`
State string `json:"status"`
Halted bool `json:"halted"`
CreatedAt time.Time `json:"created_at"`
TranscriptionText *string `json:"transcription_text,omitempty"`
}
// Register вешает маршруты сервиса на роутер хранилища. Порт у сервиса и у
// панели один, поэтому и роутер один; имена полей ответа и коды при переезде
// сохранены — публичный контракт API объявлен необратимым.
// сохранены — публичный контракт HTTP API объявлен необратимым.
func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) {
api := r.Group("/api")
@@ -80,18 +97,18 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
}
}()
// Запись доехала целиком, поэтому задача заводится независимо от того,
// дождётся ли отправитель ответа: на контексте запроса приём терял бы
// полностью загруженную запись от одного обрыва соединения, а забрать
// результат он может и позже — по `GET /status/{id}`. Значения контекста
// (журнал запроса, сессия) при этом сохраняются, теряется только отмена.
// Запись доехала целиком, поэтому она заводится независимо от того, дождётся
// ли отправитель ответа: на контексте запроса приём терял бы полностью
// загруженную запись от одного обрыва соединения, а забрать результат он
// может и позже — по `GET /status/{id}`. Значения контекста (журнал запроса,
// сессия) при этом сохраняются, теряется только отмена.
ctx := context.WithoutCancel(e.Request.Context())
// Владелец берётся из предъявленной сессии и ниоткуда больше: владелец,
// пришедший полем запроса, дал бы всякому вошедшему право завести запись на
// чужое имя. Проверка предъявителя стоит слоем выше, поэтому здесь `e.Auth`
// уже есть и принадлежит коллекции пользователей.
job, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id)
record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id)
if err != nil {
// Второй раз отказ не логируем: приём назван конвенцией логирующей
// границей и уже написал о нём. Транспорт переводит ошибку в ответ.
@@ -100,37 +117,53 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
// Возвращаем успешный ответ
return e.JSON(http.StatusCreated, CreateTranscribeJobResponse{
JobID: job.Id,
State: job.State,
JobID: record.Id,
State: record.State,
})
}
func (h *TranscribeHandler) GetTranscribeJobStatus(e *core.RequestEvent) error {
jobID := e.Request.PathValue("id")
recordID := e.Request.PathValue("id")
// Чужая задача, задача без владельца и несуществующая отвечают одним и тем
// Чужая запись, запись без владельца и несуществующая отвечают одним и тем
// же: хранилище отдаёт на все три ту же ошибку, а транспорт — тот же код и
// то же тело. Различать их наружу нельзя — по разнице ответов перебирается
// список заведённых задач.
job, err := h.jobRepo.GetByID(jobID, e.Auth.Id)
// список заведённых записей.
record, err := h.recordRepo.GetByID(recordID, e.Auth.Id)
if err != nil {
// Наружу ответ один на все исходы, а в журнал они идут по-разному.
// «Задачи нет» и «задача чужая» — штатная работа разграничения, о ней
// «Записи нет» и «запись чужая» — штатная работа разграничения, о ней
// писать нечего; всё прочее — отказ хранилища, и без этой строки он
// приходит отправителю как «вашей записи нет», а владелец сервиса об
// аварии не узнаёт ниоткуда. Журнал читает владелец, а не тот, кто
// перебирает, поэтому различать их здесь можно.
// аварии не узнаёт ниоткуда.
var notFound *contract.JobNotFoundError
if !errors.As(err, &notFound) {
h.logger.Error("Failed to read transcribe job", "error", err, "job_id", jobID)
h.logger.Error("Failed to read audio record", "error", err, "record_id", recordID)
}
return e.JSON(http.StatusNotFound, map[string]string{"error": "Job not found"})
}
return e.JSON(http.StatusOK, GetTranscribeJobResponse{
JobID: job.Id,
State: job.State,
CreatedAt: job.CreatedAt,
TranscriptionText: job.TranscriptionText,
})
response := GetTranscribeJobResponse{
JobID: record.Id,
State: record.State,
Halted: record.IsHalted(),
CreatedAt: record.CreatedAt,
}
// Вид текста называется **явно**: видов у записи больше одного, и отдача
// «последнего записанного» сделала бы ответ функцией порядка записи, а не
// состояния записи. В это поле уходит сырая расшифровка, и только она.
if record.TranscriptTextID != nil {
text, err := h.textRepo.GetByID(*record.TranscriptTextID)
if err != nil {
h.logger.Error("Failed to read transcript", "error", err, "record_id", recordID)
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to read transcription"})
}
if text.Contents != "" {
contents := text.Contents
response.TranscriptionText = &contents
}
}
return e.JSON(http.StatusOK, response)
}