Files
transcriber/internal/adapter/recognizer/yandex/recognizer.go
T
av f494dcb83e Отмена доходит до внешнего собеседника, а токен не покидает единой точки
- контекст проложен от воркера и обоих входов до внешних вызовов: ffmpeg и
  ffprobe заводятся через exec.CommandContext, SpeechKit и Object Storage
  принимают ctx вместо context.Background, скачивание записи идёт запросом с
  контекстом. Прежде остановка сервиса не доходила до чужой работы вовсе
- прерванный шаг приговора не выносит: убитый по контексту ffmpeg отдаёт
  «signal: killed», от настоящего отказа неотличимо ни типом, ни errors.Is, и
  различает их только ctx.Err(). Задача остаётся на повтор, попытку не тратит и
  отправителю о несуществующем сбое не сообщает; воркер не считает остановку
  отказом, а задача не забирается вовсе, если нас уже остановили
- клиента Bot API заводит единая точка internal/adapter/telegram: токен стоит в
  пути каждого обращения, а http.Client кладёт адрес в *url.Error целиком.
  Чистка на месте употребления закрывала один вызов из пяти — теперь свой Do
  чистит отказ, подменённый логгер вычищает токен из строк самой библиотеки, а
  транспорт бота токена не получает вовсе
- принятие операции распознавания защищено от отмены своим пределом: SpeechKit
  мог её принять и начать считать деньги, а потерянный идентификатор заставил
  бы повтор оплатить ту же запись второй раз
- приём по HTTP доводит запись до задачи независимо от отправителя: на
  контексте запроса один обрыв соединения терял полностью загруженную запись
- ответ Telegram с не-2xx кодом больше не становится записью: прежде тело
  отказа доезжало до хранилища и умирало на ffprobe, уводя диагностику
2026-08-13 10:27:54 +03:00

119 lines
3.9 KiB
Go

package yandex
import (
"context"
"fmt"
"io"
"time"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
type YandexAudioRecognizerConfig struct {
// s3
Region string
AccessKey string
SecretKey string
BucketName string
Endpoint string
// speech kit
ApiKey string
FolderID string
}
type YandexAudioRecognizerService struct {
s3Sevice *yandexS3Service
sttService *speechKitService
}
func NewYandexAudioRecognizerService(cfg YandexAudioRecognizerConfig) (*YandexAudioRecognizerService, error) {
s3, err := newYandexS3Service(s3Config{
Region: cfg.Region,
AccessKey: cfg.AccessKey,
SecretKey: cfg.SecretKey,
BucketName: cfg.BucketName,
Endpoint: cfg.Endpoint,
})
if err != nil {
return nil, err
}
stt, err := newSpeechKitService(speechKitConfig{
ApiKey: cfg.ApiKey,
FolderID: cfg.FolderID,
})
if err != nil {
return nil, err
}
return &YandexAudioRecognizerService{
s3Sevice: s3,
sttService: stt,
}, nil
}
func (s *YandexAudioRecognizerService) Close() error {
return s.sttService.Close()
}
// startRecognitionTimeout — сколько ждём принятия операции, когда нас уже
// остановили. Число меньше жёсткого предела остановки: иначе процесс убьют
// прежде, чем ответ дойдёт, и защита ничего не даст.
const startRecognitionTimeout = 10 * time.Second
func (s *YandexAudioRecognizerService) Recognize(ctx context.Context, file io.Reader, fileName string) (string, error) {
// Заливка отменяется штатно: она дорога по времени, а повтор её бесплатен —
// объект ложится под тем же ключом.
err := s.s3Sevice.uploadFile(ctx, file, fileName)
if err != nil {
return "", err
}
uri := s.s3Sevice.fileUrl(fileName)
// А вот принятие операции от отмены защищено. Окно короткое и дорогое:
// SpeechKit может операцию принять и начать считать деньги, а ответ до нас
// не доедет — идентификатор потеряется навсегда, и повтор оплатит ту же
// запись второй раз. Свой предел вызову оставлен, чтобы остановка не ждала
// вечно.
startCtx, cancel := protectFromCancel(ctx, startRecognitionTimeout)
defer cancel()
opId, err := s.sttService.recognizeFileFromS3(startCtx, uri)
if err != nil {
return "", err
}
return opId, nil
}
// protectFromCancel отвязывает вызов от отмены родителя, оставляя ему значения
// родителя и собственный предел по времени. Употребляется там, где обрыв стоит
// дороже ожидания: у платной операции, чей результат нельзя переспросить.
func protectFromCancel(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(ctx), timeout)
}
func (s *YandexAudioRecognizerService) GetRecognitionText(ctx context.Context, operationID string) (string, error) {
return s.sttService.getRecognitionText(ctx, operationID)
}
func (s *YandexAudioRecognizerService) CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
operation, err := s.sttService.checkOperationStatus(ctx, operationID)
if err != nil {
return nil, err
}
if !operation.Done {
return entity.NewInProgressResult(), nil
}
if opErr := operation.GetError(); opErr != nil {
errorText := fmt.Sprintf("operation failed: code %d, message: %s", opErr.Code, opErr.Message)
return entity.NewFailedResult(errorText), nil
}
return entity.NewCompletedResult(), nil
}