Ctrl K
ComunidadGuías…

internal/app/transcription/service.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package transcription

import (
	"bytes"
	"context"
	"crypto/rand"
	"encoding/binary"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"math"
	"mime/multipart"
	"net/http"
	"strings"
	"sync"
	"time"

	"go.uber.org/zap"

	"telesrv/internal/domain"
	"telesrv/internal/store"
)

const (
	maxDocumentBytes = 25 << 20
	asrResponseLimit = 1 << 20
	dbWriteTimeout   = 10 * time.Second
)

var (
	// ErrUnavailable means the ASR backend is not configured (TELESRV_ASR_URL is empty); the caller maps it to TRANSCRIPTION_FAILED.
	ErrUnavailable = errors.New("transcription backend is not configured")
	// ErrDocument means the voice document is unreadable or over the size limit and cannot be sent to ASR.
	ErrDocument = errors.New("voice document is not available for transcription")
	// ErrTranscriptionFailed means a previous upload transcription failed; file
	// jobs are terminal and are not retried by a polling client.
	ErrTranscriptionFailed = errors.New("file transcription previously failed")
)

type FileSource interface {
	GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error)
}

type jobRequest struct {
	documentID      int64
	transcriptionID int64
	userID          int64
	peer            domain.Peer
	msgID           int
	// file marks an upload-based job (messages.transcribeAudioFile) whose
	// completion is delivered as updateTranscribedAudioFile, not a message one.
	file bool
}

type Service struct {
	store     store.TranscriptionStore
	files     FileSource
	url       string
	client    *http.Client
	log       *zap.Logger
	requestMu sync.Mutex
	mu        sync.Mutex
	inflight  map[int64][]jobRequest
	// completed pushes updateTranscribedAudio after the background task writes the DB; it is
	// injected by main after the router is created, to avoid the app layer depending on rpc
	// in the reverse direction.
	completed func(ctx context.Context, userID int64, peer domain.Peer, msgID int, transcriptionID int64, text string)
	// fileCompleted pushes updateTranscribedAudioFile for an upload job.
	fileCompleted func(ctx context.Context, userID int64, transcriptionID int64, text string)
}

func NewService(
	st store.TranscriptionStore,
	files FileSource,
	url string,
	timeout time.Duration,
	log *zap.Logger,
) *Service {
	return &Service{
		store:    st,
		files:    files,
		url:      strings.TrimRight(strings.TrimSpace(url), "/"),
		client:   &http.Client{Timeout: timeout},
		log:      log,
		inflight: map[int64][]jobRequest{},
	}
}

func (s *Service) SetCompletionHandler(handler func(ctx context.Context, userID int64, peer domain.Peer, msgID int, transcriptionID int64, text string)) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.completed = handler
}

func (s *Service) SetFileCompletionHandler(handler func(ctx context.Context, userID int64, transcriptionID int64, text string)) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.fileCompleted = handler
}

// Transcribe returns the currently known state: if done it returns the text directly, otherwise it ensures the background task is running and returns pending.
func (s *Service) Transcribe(
	ctx context.Context,
	userID int64,
	peer domain.Peer,
	msgID int,
	document domain.Document,
) (domain.TranscriptionResult, error) {
	return s.transcribe(ctx, userID, peer, msgID, document, false)
}

// TranscribeFile transcribes an uploaded file (messages.transcribeAudioFile).
// A previously failed file job is terminal (ErrTranscriptionFailed) and never
// starts another ASR job, so a polling client cannot consume the global ASR
// slots; on success the file completion handler pushes updateTranscribedAudioFile.
func (s *Service) TranscribeFile(
	ctx context.Context,
	userID int64,
	document domain.Document,
) (domain.TranscriptionResult, error) {
	return s.transcribe(ctx, userID, domain.Peer{}, 0, document, true)
}

func (s *Service) transcribe(
	ctx context.Context,
	userID int64,
	peer domain.Peer,
	msgID int,
	document domain.Document,
	file bool,
) (domain.TranscriptionResult, error) {
	if s == nil || s.url == "" {
		return domain.TranscriptionResult{}, ErrUnavailable
	}
	s.requestMu.Lock()
	defer s.requestMu.Unlock()
	if document.ID == 0 {
		return domain.TranscriptionResult{}, ErrDocument
	}
	row, found, err := s.store.GetTranscription(ctx, document.ID)
	if err != nil {
		return domain.TranscriptionResult{}, err
	}
	if found && row.Status == domain.TranscriptionStatusDone {
		return domain.TranscriptionResult{TranscriptionID: row.TranscriptionID, Text: row.Text}, nil
	}
	if found && row.Status == domain.TranscriptionStatusFailed && file {
		// Terminal for upload jobs: do not reset and re-run ASR on every poll.
		return domain.TranscriptionResult{}, ErrTranscriptionFailed
	}
	job := jobRequest{
		documentID:      document.ID,
		transcriptionID: row.TranscriptionID,
		userID:          userID,
		peer:            peer,
		msgID:           msgID,
		file:            file,
	}
	if !found {
		job.transcriptionID, err = newTranscriptionID()
		if err != nil {
			return domain.TranscriptionResult{}, err
		}
		_, inserted, err := s.store.CreateTranscriptionPending(ctx, domain.PendingTranscriptionRequest{
			DocumentID:      job.documentID,
			TranscriptionID: job.transcriptionID,
			RequestedBy:     userID,
			Peer:            peer,
			MsgID:           msgID,
		})
		if err != nil {
			return domain.TranscriptionResult{}, err
		}
		if !inserted {
			// A concurrent first call already inserted the row: re-read, and if it completed in the meantime return the text directly.
			row, found, err = s.store.GetTranscription(ctx, document.ID)
			if err != nil {
				return domain.TranscriptionResult{}, err
			}
			if !found {
				return domain.TranscriptionResult{}, ErrDocument
			}
			if row.Status == domain.TranscriptionStatusDone {
				return domain.TranscriptionResult{TranscriptionID: row.TranscriptionID, Text: row.Text}, nil
			}
			job.transcriptionID = row.TranscriptionID
		}
	} else if row.Status == domain.TranscriptionStatusFailed {
		job.transcriptionID, err = newTranscriptionID()
		if err != nil {
			return domain.TranscriptionResult{}, err
		}
		if _, _, err := s.store.ResetTranscriptionPending(ctx, domain.PendingTranscriptionRequest{
			DocumentID:      job.documentID,
			TranscriptionID: job.transcriptionID,
			RequestedBy:     userID,
			Peer:            peer,
			MsgID:           msgID,
		}); err != nil {
			return domain.TranscriptionResult{}, err
		}
	}
	if !s.startJob(document, job) {
		return domain.TranscriptionResult{}, ErrUnavailable
	}
	return domain.TranscriptionResult{TranscriptionID: job.transcriptionID, Pending: true}, nil
}

const maxConcurrentTranscriptions = 4
const maxTranscriptionSubscribers = 128

func (s *Service) startJob(document domain.Document, job jobRequest) bool {
	s.mu.Lock()
	if subscribers, busy := s.inflight[job.documentID]; busy {
		for _, subscriber := range subscribers {
			if subscriber == job {
				s.mu.Unlock()
				return true
			}
		}
		if len(subscribers) >= maxTranscriptionSubscribers {
			s.mu.Unlock()
			return false
		}
		s.inflight[job.documentID] = append(subscribers, job)
		s.mu.Unlock()
		return true
	}
	if len(s.inflight) >= maxConcurrentTranscriptions {
		s.mu.Unlock()
		return false
	}
	s.inflight[job.documentID] = []jobRequest{job}
	s.mu.Unlock()
	go s.run(context.Background(), document, job)
	return true
}

func (s *Service) run(parent context.Context, document domain.Document, job jobRequest) {
	ctx, cancel := context.WithTimeout(parent, s.client.Timeout)
	defer cancel()
	text, err := s.transcribeDocument(ctx, document.ID)
	s.requestMu.Lock()
	defer s.requestMu.Unlock()
	defer func() { s.mu.Lock(); delete(s.inflight, job.documentID); s.mu.Unlock() }()
	dbCtx, cancelDB := context.WithTimeout(context.Background(), dbWriteTimeout)
	defer cancelDB()
	if err != nil {
		s.log.Warn("transcription failed",
			zap.Int64("document_id", job.documentID),
			zap.Int64("user_id", job.userID),
			zap.Error(err))
		if markErr := s.store.MarkTranscriptionFailed(dbCtx, job.documentID, err.Error()); markErr != nil {
			s.log.Warn("mark transcription failed", zap.Error(markErr))
		}
		return
	}
	if err := s.store.MarkTranscriptionDone(dbCtx, job.documentID, text); err != nil {
		s.log.Warn("mark transcription done", zap.Error(err))
		return
	}
	s.mu.Lock()
	completed := s.completed
	fileCompleted := s.fileCompleted
	subscribers := append([]jobRequest(nil), s.inflight[job.documentID]...)
	s.mu.Unlock()
	for _, subscriber := range subscribers {
		if subscriber.userID == 0 {
			continue
		}
		if subscriber.file {
			if fileCompleted != nil {
				fileCompleted(dbCtx, subscriber.userID, job.transcriptionID, text)
			}
			continue
		}
		if subscriber.msgID > 0 && completed != nil {
			completed(dbCtx, subscriber.userID, subscriber.peer, subscriber.msgID, job.transcriptionID, text)
		}
	}

}

func (s *Service) transcribeDocument(ctx context.Context, documentID int64) (string, error) {
	chunk, found, err := s.files.GetFile(ctx, domain.FileDownloadRequest{
		LocationKey: fmt.Sprintf("doc:%d", documentID),
		Limit:       maxDocumentBytes + 1,
	})
	if err != nil {
		return "", err
	}
	if !found || chunk.Total <= 0 || chunk.Total > maxDocumentBytes ||
		int64(len(chunk.Bytes)) != chunk.Total {
		return "", ErrDocument
	}
	var body bytes.Buffer
	writer := multipart.NewWriter(&body)
	part, err := writer.CreateFormFile("file", "voice.ogg")
	if err != nil {
		return "", err
	}
	if _, err := part.Write(chunk.Bytes); err != nil {
		return "", err
	}
	if err := writer.Close(); err != nil {
		return "", err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.url+"/inference", &body)
	if err != nil {
		return "", err
	}
	req.Header.Set("Content-Type", writer.FormDataContentType())
	resp, err := s.client.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	payload, err := io.ReadAll(io.LimitReader(resp.Body, asrResponseLimit))
	if err != nil {
		return "", err
	}
	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("asr responded %d", resp.StatusCode)
	}
	var parsed struct {
		Text string `json:"text"`
	}
	if err := json.Unmarshal(payload, &parsed); err != nil {
		return "", fmt.Errorf("decode asr response: %w", err)
	}
	return strings.TrimSpace(parsed.Text), nil
}

func newTranscriptionID() (int64, error) {
	var buf [8]byte
	if _, err := rand.Read(buf[:]); err != nil {
		return 0, err
	}
	id := int64(binary.LittleEndian.Uint64(buf[:]) & math.MaxInt64)
	if id == 0 {
		id = 1
	}
	return id, nil
}