Prepara el terreno para que el cliente cargue su propia cuenta de IA sin que su clave quede en texto plano ni que el selector le muestre las de los demás. - ClaveEnClaro() descifra con fallback a texto plano: las filas viejas se leen igual y quedan cifradas al primer guardado, sin script ni downtime. utils.Decrypt hace panic (no devuelve error) con entrada que no es un ciphertext válido, así que el fallback va sobre recover — eso mismo es el mecanismo de detección de "todavía está en claro". - Migrados TODOS los lectores: uMind (chat y embeddings), bot de Telegram, Whisper, Query Runner, streaming de IA y Landing Generator. Un lector sin migrar mandaría el ciphertext como API key. - AiConfig gana TenantID (null = global del staff) y GetAiConfigSelectPorTenants para acotar el selector. - Test de los 4 casos del fallback, incluido hex válido que no descifra. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
99 lines
2.8 KiB
Go
99 lines
2.8 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
|
)
|
|
|
|
var whisperHTTPClient = &http.Client{Timeout: 90 * time.Second}
|
|
|
|
// TranscribirAudio envía un archivo de audio a un endpoint de transcripción
|
|
// compatible con la API de OpenAI (POST /audio/transcriptions, multipart) y
|
|
// devuelve el texto. Funciona tanto con OpenAI (modelo whisper-1) como con Groq
|
|
// (modelo whisper-large-v3), reutilizando la config que ya se administra en
|
|
// /app/ai-config con el módulo "whisper".
|
|
func TranscribirAudio(ai *models.AiConfig, audioPath string) (string, error) {
|
|
if ai == nil {
|
|
return "", fmt.Errorf("no hay una configuración de Whisper activa: ve a /app/ai-config, crea o edita una y márcale el módulo 'Transcripción de audio (Whisper)'")
|
|
}
|
|
if ai.ClaveEnClaro() == "" {
|
|
return "", fmt.Errorf("la configuración de Whisper '%s' no tiene API key", ai.Nombre)
|
|
}
|
|
|
|
baseURL := ai.BaseURL
|
|
if baseURL == "" {
|
|
baseURL = ProviderDefaultURL(ai.Provider)
|
|
}
|
|
baseURL = strings.TrimRight(baseURL, "/")
|
|
|
|
model := ai.ModelName
|
|
if model == "" {
|
|
model = "whisper-1"
|
|
}
|
|
|
|
f, err := os.Open(audioPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("no se pudo abrir el audio descargado: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
part, err := writer.CreateFormFile("file", filepath.Base(audioPath))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := io.Copy(part, f); err != nil {
|
|
return "", fmt.Errorf("error leyendo el audio: %w", err)
|
|
}
|
|
_ = writer.WriteField("model", model)
|
|
_ = writer.WriteField("response_format", "json")
|
|
if err := writer.Close(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", baseURL+"/audio/transcriptions", &body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+ai.ClaveEnClaro())
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
|
|
resp, err := whisperHTTPClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("no se pudo conectar con el servicio de transcripción: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
detalle := strings.TrimSpace(string(raw))
|
|
if len(detalle) > 300 {
|
|
detalle = detalle[:300]
|
|
}
|
|
return "", fmt.Errorf("el servicio de transcripción respondió %d: %s", resp.StatusCode, detalle)
|
|
}
|
|
|
|
var out struct {
|
|
Text string `json:"text"`
|
|
}
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return "", fmt.Errorf("respuesta inesperada del servicio de transcripción")
|
|
}
|
|
texto := strings.TrimSpace(out.Text)
|
|
if texto == "" {
|
|
return "", fmt.Errorf("no se detectó texto en el audio")
|
|
}
|
|
return texto, nil
|
|
}
|