Files
transcriber/internal/adapter/recognizer/yandex/speechkit.go
T
av 1576d06735 внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
2026-08-14 20:20:33 +03:00

288 lines
11 KiB
Go

package yandex
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/proto"
stt "github.com/yandex-cloud/go-genproto/yandex/cloud/ai/stt/v3"
"github.com/yandex-cloud/go-genproto/yandex/cloud/operation"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
const (
SpeechKitEndpoint = "stt.api.cloud.yandex.net:443"
OperationEndpoint = "operation.api.cloud.yandex.net:443"
RecognitionModel = "deferred-general"
)
type speechKitConfig struct {
ApiKey string
FolderID string
}
type speechKitService struct {
sttConn *grpc.ClientConn
opConn *grpc.ClientConn
sttClient stt.AsyncRecognizerClient
opClient operation.OperationServiceClient
apiKey string
folderID string
}
func newSpeechKitService(cfg speechKitConfig) (*speechKitService, error) {
apiKey := cfg.ApiKey
folderID := cfg.FolderID
if apiKey == "" || folderID == "" {
return nil, fmt.Errorf("missing required Yandex Cloud environment variables")
}
// Создаем защищенное соединение для SpeechKit
creds := credentials.NewTLS(nil)
sttConn, err := grpc.NewClient(SpeechKitEndpoint, grpc.WithTransportCredentials(creds))
if err != nil {
return nil, fmt.Errorf("failed to connect to SpeechKit: %w", err)
}
// Создаем защищенное соединение для Operations API
opConn, err := grpc.NewClient(OperationEndpoint, grpc.WithTransportCredentials(creds))
if err != nil {
// Отказы независимы, и второй не теряется. На сегодняшнем клиенте он
// почти наверняка не наступит: grpc.NewClient ленив, соединение к этому
// моменту не открыто, и Close вернёт отказ только при повторном
// закрытии — то есть сообщит о нашей ошибке, а не о Yandex. Сборка
// оставлена как защита от смены реализации клиента; nil от закрытия
// errors.Join отбрасывает, и форма ошибки в обычном случае не меняется.
return nil, errors.Join(
fmt.Errorf("failed to connect to Operations API: %w", err),
sttConn.Close(),
)
}
sttClient := stt.NewAsyncRecognizerClient(sttConn)
opClient := operation.NewOperationServiceClient(opConn)
return &speechKitService{
sttConn: sttConn,
opConn: opConn,
sttClient: sttClient,
opClient: opClient,
apiKey: apiKey,
folderID: folderID,
}, nil
}
func (s *speechKitService) Close() error {
var err1, err2 error
if s.sttConn != nil {
err1 = s.sttConn.Close()
}
if s.opConn != nil {
err2 = s.opConn.Close()
}
// Отказы двух соединений независимы, и вернуть только первый — значит
// потерять половину причины: журнал пишется при остановке процесса, и
// восстановить утраченное будет уже негде.
return errors.Join(err1, err2)
}
// recognizeFileFromS3 запускает асинхронное распознавание файла из S3
func (s *speechKitService) recognizeFileFromS3(ctx context.Context, s3URI string) (string, error) {
// Добавляем авторизацию и folder_id в контекст
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
// Создаем запрос на распознавание
req := &stt.RecognizeFileRequest{
AudioSource: &stt.RecognizeFileRequest_Uri{
Uri: s3URI,
},
RecognitionModel: &stt.RecognitionModelOptions{
Model: RecognitionModel,
AudioFormat: &stt.AudioFormatOptions{
AudioFormat: &stt.AudioFormatOptions_ContainerAudio{
ContainerAudio: &stt.ContainerAudio{
ContainerAudioType: stt.ContainerAudio_OGG_OPUS,
},
},
},
TextNormalization: &stt.TextNormalizationOptions{
TextNormalization: stt.TextNormalizationOptions_TEXT_NORMALIZATION_ENABLED,
ProfanityFilter: false,
LiteratureText: true,
},
AudioProcessingType: stt.RecognitionModelOptions_FULL_DATA,
},
SpeakerLabeling: &stt.SpeakerLabelingOptions{
SpeakerLabeling: stt.SpeakerLabelingOptions_SPEAKER_LABELING_ENABLED,
},
}
// Отправляем запрос
op, err := s.sttClient.RecognizeFile(ctx, req)
if err != nil {
return "", fmt.Errorf("failed to start recognition: %w", err)
}
return op.Id, nil
}
// fetchRecognition забирает результат операции целиком и отдаёт его доменным,
// вместе с сырым ответом на хранение.
//
// Ответ сохраняется потому, что **результат операции у провайдера не
// переспрашивается**: связь реплики с говорящим сервис строить пока не умеет, и
// когда научится, архив пересчитается из сохранённого без единого рубля.
func (s *speechKitService) fetchRecognition(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) {
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
req := &stt.GetRecognitionRequest{
OperationId: operationID,
}
stream, err := s.sttClient.GetRecognition(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to get recognition stream: %w", err)
}
var responses []*stt.StreamingResponse
for {
resp, err := stream.Recv()
if err != nil {
// Конец потока библиотека отдаёт ровно `io.EOF`. Прежде он узнавался
// сравнением текста сообщения: так же выглядел бы и настоящий отказ
// с текстом «EOF», и распознавание молча вернуло бы половину текста.
if errors.Is(err, io.EOF) {
break
}
return nil, fmt.Errorf("failed to receive recognition response: %w", err)
}
responses = append(responses, resp)
}
raw, err := encodeResponses(responses)
if err != nil {
return nil, err
}
outcome := outcomeFromResponses(responses)
outcome.Raw = raw
return outcome, nil
}
// checkOperationStatus проверяет статус операции распознавания
func (s *speechKitService) checkOperationStatus(ctx context.Context, operationID string) (*operation.Operation, error) {
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
op, err := s.opClient.Get(ctx, &operation.GetOperationRequest{
OperationId: operationID,
})
if err != nil {
return nil, fmt.Errorf("failed to get operation status: %w", err)
}
return op, nil
}
// encodeResponses укладывает ответ провайдера целиком, в том виде, в каком он
// пришёл: сообщения потока подряд, каждое со своей длиной впереди.
//
// Форма **двоичная**, а не текстовая, и это несущее решение. Текстовое
// представление собирается по нашей скомпилированной схеме и молча выбрасывает
// поля, которых в ней нет, — а провайдер добавляет их без предупреждения.
// Двоичная форма неизвестные поля переносит: они переживают запись и чтение и
// станут читаемыми, когда мы обновим схему. Ради этого архив и заводился —
// результат операции у провайдера не переспрашивается, и повторное
// распознавание стоит денег.
//
// Цена названа прямо: сохранённое не читается глазами и не разбирается ничем,
// кроме нашего же кода.
func encodeResponses(responses []*stt.StreamingResponse) ([]byte, error) {
var raw []byte
for _, resp := range responses {
encoded, err := proto.Marshal(resp)
if err != nil {
// Текст расшифровки наружу не выходит даже отказом: сообщение
// провайдера несёт её целиком.
return nil, errors.New("failed to encode provider response")
}
raw = binary.AppendUvarint(raw, uint64(len(encoded)))
raw = append(raw, encoded...)
}
return raw, nil
}
// decodeResponses читает сохранённый ответ провайдера обратно.
func decodeResponses(raw []byte) ([]*stt.StreamingResponse, error) {
var responses []*stt.StreamingResponse
for len(raw) > 0 {
size, read := binary.Uvarint(raw)
if read <= 0 || uint64(len(raw)-read) < size {
return nil, errors.New("stored provider payload is truncated")
}
raw = raw[read:]
var resp stt.StreamingResponse
if err := proto.Unmarshal(raw[:size], &resp); err != nil {
return nil, errors.New("failed to decode provider response")
}
responses = append(responses, &resp)
raw = raw[size:]
}
return responses, nil
}
// outcomeFromResponses строит доменный результат: реплики со временем и плоский
// текст. Формата провайдера отсюда наружу не выходит ничего.
//
// Говорящие не размечаются: связь реплики с разбором говорящего у провайдера не
// выяснена. Структура при этом строится из сохранённого ответа, поэтому разметка
// станет возможной без повторной оплаты.
func outcomeFromResponses(responses []*stt.StreamingResponse) *entity.RecognitionOutcome {
outcome := &entity.RecognitionOutcome{}
var plain []byte
for _, resp := range responses {
refinement := resp.GetFinalRefinement()
if refinement == nil {
continue
}
text := refinement.GetNormalizedText()
if text == nil {
continue
}
for _, alt := range text.GetAlternatives() {
if alt.GetText() == "" {
continue
}
outcome.Replicas = append(outcome.Replicas, entity.Replica{
StartMs: alt.GetStartTimeMs(),
EndMs: alt.GetEndTimeMs(),
Text: alt.GetText(),
})
if len(plain) > 0 {
plain = append(plain, ' ')
}
plain = append(plain, alt.GetText()...)
}
}
outcome.PlainText = string(plain)
return outcome
}