внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures, recognitions, record_events, topics) живут своими коллекциями, ссылки на исходник и на приведённую копию перестали переставляться - рубеж называет достигнутое, отказ стал признаком остановки с причиной, а сторожей стало двое: число отказов и время в рубеже - воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
This commit is contained in:
@@ -2,17 +2,20 @@ package yandex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"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 (
|
||||
@@ -134,9 +137,13 @@ func (s *speechKitService) recognizeFileFromS3(ctx context.Context, s3URI string
|
||||
return op.Id, nil
|
||||
}
|
||||
|
||||
// GetRecognitionResult получает результат распознавания по ID операции
|
||||
func (s *speechKitService) getRecognitionText(ctx context.Context, operationID string) (string, error) {
|
||||
// Добавляем авторизацию и folder_id в контекст
|
||||
// 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)
|
||||
|
||||
@@ -146,11 +153,10 @@ func (s *speechKitService) getRecognitionText(ctx context.Context, operationID s
|
||||
|
||||
stream, err := s.sttClient.GetRecognition(ctx, req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get recognition stream: %w", err)
|
||||
return nil, fmt.Errorf("failed to get recognition stream: %w", err)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
var responses []*stt.StreamingResponse
|
||||
for {
|
||||
resp, err := stream.Recv()
|
||||
if err != nil {
|
||||
@@ -160,19 +166,19 @@ func (s *speechKitService) getRecognitionText(ctx context.Context, operationID s
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return "", fmt.Errorf("failed to receive recognition response: %w", err)
|
||||
}
|
||||
if refinement := resp.GetFinalRefinement(); refinement != nil {
|
||||
if text := refinement.GetNormalizedText(); text != nil {
|
||||
for _, alt := range text.Alternatives {
|
||||
sb.WriteString(alt.Text)
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("failed to receive recognition response: %w", err)
|
||||
}
|
||||
responses = append(responses, resp)
|
||||
}
|
||||
|
||||
return sb.String(), nil
|
||||
raw, err := encodeResponses(responses)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
outcome := outcomeFromResponses(responses)
|
||||
outcome.Raw = raw
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
// checkOperationStatus проверяет статус операции распознавания
|
||||
@@ -190,3 +196,92 @@ func (s *speechKitService) checkOperationStatus(ctx context.Context, operationID
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user