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.
84 lines
2.2 KiB
Go
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))
|
|
}
|