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>
101 lines
2.8 KiB
Go
101 lines
2.8 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
|
)
|
|
|
|
var umindEmbeddingsHTTPClient = &http.Client{Timeout: 30 * time.Second}
|
|
|
|
// GenerarEmbeddings pide los vectores de una tanda de textos en una sola
|
|
// llamada (más barato y rápido que uno por uno durante la ingesta). Usa el
|
|
// endpoint de embeddings compatible con OpenAI — Anthropic no ofrece
|
|
// embeddings, por eso esta config debe ser una con provider "openai" (u otro
|
|
// compatible con ese formato de respuesta).
|
|
func GenerarEmbeddings(ai *models.AiConfig, textos []string) ([][]float32, error) {
|
|
if ai == nil {
|
|
return nil, fmt.Errorf("no hay una configuración de IA para generar embeddings")
|
|
}
|
|
if len(textos) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
baseURL := ai.BaseURL
|
|
if baseURL == "" {
|
|
baseURL = ProviderDefaultURL(ai.Provider)
|
|
}
|
|
baseURL = strings.TrimRight(baseURL, "/")
|
|
|
|
model := ai.ModelName
|
|
if model == "" {
|
|
model = "text-embedding-3-small"
|
|
}
|
|
|
|
reqBody, _ := json.Marshal(map[string]interface{}{
|
|
"model": model,
|
|
"input": textos,
|
|
})
|
|
req, err := http.NewRequest("POST", baseURL+"/embeddings", bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+ai.ClaveEnClaro())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := umindEmbeddingsHTTPClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("no se pudo conectar con el servicio de embeddings: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024))
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
detalle := strings.TrimSpace(string(raw))
|
|
if len(detalle) > 300 {
|
|
detalle = detalle[:300]
|
|
}
|
|
return nil, fmt.Errorf("el servicio de embeddings respondió %d: %s", resp.StatusCode, detalle)
|
|
}
|
|
|
|
var out struct {
|
|
Data []struct {
|
|
Embedding []float32 `json:"embedding"`
|
|
Index int `json:"index"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return nil, fmt.Errorf("respuesta inesperada del servicio de embeddings")
|
|
}
|
|
if len(out.Data) != len(textos) {
|
|
return nil, fmt.Errorf("el servicio de embeddings devolvió %d vectores para %d textos", len(out.Data), len(textos))
|
|
}
|
|
|
|
vectores := make([][]float32, len(textos))
|
|
for _, d := range out.Data {
|
|
if d.Index < 0 || d.Index >= len(vectores) {
|
|
continue
|
|
}
|
|
vectores[d.Index] = d.Embedding
|
|
}
|
|
return vectores, nil
|
|
}
|
|
|
|
// GenerarEmbedding es el atajo para un solo texto (ej: la consulta del usuario en RAG).
|
|
func GenerarEmbedding(ai *models.AiConfig, texto string) ([]float32, error) {
|
|
vectores, err := GenerarEmbeddings(ai, []string{texto})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(vectores) == 0 {
|
|
return nil, fmt.Errorf("no se generó ningún embedding")
|
|
}
|
|
return vectores[0], nil
|
|
}
|