Files
soft_usite/pkg/services/umind_rag_service.go
T
Lizandro GD 5ee5880dc2 Agrega uMind: chat con IA embebible por tenant (F1 — widget web + RAG)
Multi-tenant dentro de soft_usite, reutilizando la infraestructura ya
existente (AiConfig, motor de function-calling del agente de Telegram)
en vez de un servicio nuevo aparte:

- UmindTenant: sitio/cliente con dominios permitidos, config de IA para
  el chat y personalidad/tono.
- Ingesta: crawler simple (mismo dominio, N páginas) + chunking +
  embeddings (config global con módulo "umind_embeddings", pensada
  para OpenAI ya que Claude no ofrece embeddings) guardados como JSON,
  con búsqueda por similitud coseno en memoria (sin pgvector todavía).
- Agente acotado: única herramienta buscar_conocimiento, sin acceso a
  nada interno — si no encuentra la respuesta, lo dice en vez de
  inventar.
- Widget público (/widget/umind.js + /widget/:site_key/*), autenticado
  por site_key + validación de dominio (Origin/Referer), no por
  secreto, ya que la key viaja en el HTML público del sitio instalado.
- Panel /app/umind: tenants, estado de ingesta, historial de
  conversaciones por sesión.
2026-08-07 16:18:16 +00:00

84 lines
2.2 KiB
Go

package services
import (
"fmt"
"math"
"sort"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// resultadoRAG empareja un chunk con su similitud a la consulta, para poder ordenar.
type resultadoRAG struct {
chunk models.UmindChunk
similitud float64
}
// BuscarConocimiento embebe la consulta del usuario y devuelve los topK
// fragmentos más parecidos de la base de conocimiento del tenant, por
// similitud coseno calculada en memoria. Sin pgvector por ahora: para el
// volumen de un piloto (un tenant, unos cientos de chunks) esto es
// suficientemente rápido; si el volumen crece, se reemplaza por una consulta
// pgvector sin cambiar la firma de esta función.
func BuscarConocimiento(tenantID uint, consulta string, topK int) ([]models.UmindChunk, error) {
if topK <= 0 {
topK = 4
}
ai, err := models.GetUmindEmbeddingsConfig()
if err != nil {
return nil, err
}
consultaVec, err := GenerarEmbedding(ai, consulta)
if err != nil {
return nil, fmt.Errorf("no se pudo generar el embedding de la consulta: %w", err)
}
chunks, err := models.GetUmindChunksByTenant(tenantID)
if err != nil {
return nil, err
}
if len(chunks) == 0 {
return nil, nil
}
resultados := make([]resultadoRAG, 0, len(chunks))
for _, c := range chunks {
vec, err := models.EmbeddingFromJSON(c.EmbeddingJSON)
if err != nil || len(vec) == 0 {
continue
}
resultados = append(resultados, resultadoRAG{chunk: c, similitud: similitudCoseno(consultaVec, vec)})
}
sort.Slice(resultados, func(i, j int) bool { return resultados[i].similitud > resultados[j].similitud })
if topK > len(resultados) {
topK = len(resultados)
}
out := make([]models.UmindChunk, topK)
for i := 0; i < topK; i++ {
out[i] = resultados[i].chunk
}
return out, nil
}
// similitudCoseno calcula la similitud coseno entre dos vectores del mismo
// tamaño. Vectores de tamaño distinto (embeddings de modelos diferentes) dan 0.
func similitudCoseno(a, b []float32) float64 {
if len(a) != len(b) || len(a) == 0 {
return 0
}
var punto, normaA, normaB float64
for i := range a {
ai, bi := float64(a[i]), float64(b[i])
punto += ai * bi
normaA += ai * ai
normaB += bi * bi
}
if normaA == 0 || normaB == 0 {
return 0
}
return punto / (math.Sqrt(normaA) * math.Sqrt(normaB))
}