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.
268 lines
7.6 KiB
Go
268 lines
7.6 KiB
Go
package services
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
|
"golang.org/x/net/html"
|
|
)
|
|
|
|
var umindCrawlerHTTPClient = &http.Client{Timeout: 20 * time.Second}
|
|
|
|
const (
|
|
umindChunkTamano = 900 // caracteres por chunk, aprox
|
|
umindChunkSolape = 150 // caracteres de solape entre chunks consecutivos
|
|
)
|
|
|
|
// paginaCrawleada es el resultado de bajar y parsear una URL.
|
|
type paginaCrawleada struct {
|
|
URL string
|
|
Titulo string
|
|
Texto string
|
|
Links []string
|
|
}
|
|
|
|
// crawlearPagina descarga una URL y extrae su texto visible, título y los
|
|
// enlaces internos que encuentre (para poder seguir crawleando).
|
|
func crawlearPagina(pageURL string) (*paginaCrawleada, error) {
|
|
req, err := http.NewRequest("GET", pageURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", "uMind-Crawler/1.0 (+https://u-site.app)")
|
|
|
|
resp, err := umindCrawlerHTTPClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("no se pudo descargar %s: %w", pageURL, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("%s respondió %d", pageURL, resp.StatusCode)
|
|
}
|
|
ct := resp.Header.Get("Content-Type")
|
|
if ct != "" && !strings.Contains(ct, "text/html") {
|
|
return nil, fmt.Errorf("%s no es HTML (%s)", pageURL, ct)
|
|
}
|
|
|
|
base, err := url.Parse(pageURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
doc, err := html.Parse(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("no se pudo parsear el HTML de %s: %w", pageURL, err)
|
|
}
|
|
|
|
pagina := &paginaCrawleada{URL: pageURL}
|
|
var textoBuf strings.Builder
|
|
var caminar func(n *html.Node)
|
|
caminar = func(n *html.Node) {
|
|
if n.Type == html.ElementNode {
|
|
switch strings.ToLower(n.Data) {
|
|
case "script", "style", "noscript", "svg", "nav", "footer":
|
|
return // no bajar a estos subárboles
|
|
case "title":
|
|
if n.FirstChild != nil && n.FirstChild.Type == html.TextNode {
|
|
pagina.Titulo = strings.TrimSpace(n.FirstChild.Data)
|
|
}
|
|
return
|
|
case "a":
|
|
for _, attr := range n.Attr {
|
|
if attr.Key == "href" && attr.Val != "" {
|
|
if abs, err := base.Parse(attr.Val); err == nil {
|
|
abs.Fragment = ""
|
|
pagina.Links = append(pagina.Links, abs.String())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if n.Type == html.TextNode {
|
|
t := strings.TrimSpace(n.Data)
|
|
if t != "" {
|
|
textoBuf.WriteString(t)
|
|
textoBuf.WriteString(" ")
|
|
}
|
|
}
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
caminar(c)
|
|
}
|
|
}
|
|
caminar(doc)
|
|
pagina.Texto = normalizarEspacios(textoBuf.String())
|
|
return pagina, nil
|
|
}
|
|
|
|
func normalizarEspacios(s string) string {
|
|
campos := strings.Fields(s)
|
|
return strings.Join(campos, " ")
|
|
}
|
|
|
|
// CrawlearSitio recorre un sitio en BFS a partir de urlInicial, sin salir del
|
|
// mismo host, hasta maxPaginas páginas. Es deliberadamente simple para un
|
|
// piloto (no respeta robots.txt ni sitemap.xml todavía).
|
|
func CrawlearSitio(urlInicial string, maxPaginas int) ([]paginaCrawleada, error) {
|
|
inicio, err := url.Parse(urlInicial)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("URL inicial inválida: %w", err)
|
|
}
|
|
host := inicio.Hostname()
|
|
|
|
visitadas := map[string]bool{}
|
|
cola := []string{urlInicial}
|
|
var resultado []paginaCrawleada
|
|
|
|
for len(cola) > 0 && len(resultado) < maxPaginas {
|
|
actual := cola[0]
|
|
cola = cola[1:]
|
|
if visitadas[actual] {
|
|
continue
|
|
}
|
|
visitadas[actual] = true
|
|
|
|
pagina, err := crawlearPagina(actual)
|
|
if err != nil {
|
|
log.Printf("[UMIND] Crawler: error en %s: %v", actual, err)
|
|
continue
|
|
}
|
|
if pagina.Texto != "" {
|
|
resultado = append(resultado, *pagina)
|
|
}
|
|
for _, link := range pagina.Links {
|
|
u, err := url.Parse(link)
|
|
if err != nil || u.Hostname() != host {
|
|
continue
|
|
}
|
|
if !visitadas[link] {
|
|
cola = append(cola, link)
|
|
}
|
|
}
|
|
}
|
|
return resultado, nil
|
|
}
|
|
|
|
// trocearTexto parte un texto largo en fragmentos de ~umindChunkTamano
|
|
// caracteres con solape, intentando cortar en un espacio para no partir
|
|
// palabras a la mitad.
|
|
func trocearTexto(texto string) []string {
|
|
texto = strings.TrimSpace(texto)
|
|
if texto == "" {
|
|
return nil
|
|
}
|
|
if len(texto) <= umindChunkTamano {
|
|
return []string{texto}
|
|
}
|
|
|
|
var chunks []string
|
|
inicio := 0
|
|
for inicio < len(texto) {
|
|
fin := inicio + umindChunkTamano
|
|
if fin >= len(texto) {
|
|
chunks = append(chunks, strings.TrimSpace(texto[inicio:]))
|
|
break
|
|
}
|
|
// buscar el último espacio antes de "fin" para no cortar palabras
|
|
corte := strings.LastIndex(texto[inicio:fin], " ")
|
|
if corte <= 0 {
|
|
corte = fin - inicio
|
|
}
|
|
chunks = append(chunks, strings.TrimSpace(texto[inicio:inicio+corte]))
|
|
siguiente := inicio + corte - umindChunkSolape
|
|
if siguiente <= inicio {
|
|
siguiente = inicio + corte
|
|
}
|
|
inicio = siguiente
|
|
}
|
|
return chunks
|
|
}
|
|
|
|
// IngestarTenant crawlea el sitio del tenant, trocea el contenido, genera los
|
|
// embeddings y los guarda como UmindChunk. Se ejecuta en segundo plano desde
|
|
// el panel admin porque puede tardar (varias páginas + llamadas al API de
|
|
// embeddings). El UmindDocumento va reflejando el progreso/estado.
|
|
func IngestarTenant(tenantID uint, documentoID uint, urlInicial string, maxPaginas int) {
|
|
if _, err := models.GetUmindTenantByID(tenantID); err != nil {
|
|
models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("tenant no encontrado: %v", err), 0)
|
|
return
|
|
}
|
|
// Los embeddings usan una config global (módulo "umind_embeddings"), no la
|
|
// del tenant: todos los chunks de todos los tenants deben salir del mismo
|
|
// modelo de embeddings para que la similitud coseno entre vectores tenga
|
|
// sentido. La config del tenant (AiConfigID) es solo para el chat.
|
|
ai, err := models.GetUmindEmbeddingsConfig()
|
|
if err != nil {
|
|
models.UpdateUmindDocumentoEstado(documentoID, "error", err.Error(), 0)
|
|
return
|
|
}
|
|
|
|
if maxPaginas <= 0 {
|
|
maxPaginas = 30
|
|
}
|
|
paginas, err := CrawlearSitio(urlInicial, maxPaginas)
|
|
if err != nil {
|
|
models.UpdateUmindDocumentoEstado(documentoID, "error", err.Error(), 0)
|
|
return
|
|
}
|
|
if len(paginas) == 0 {
|
|
models.UpdateUmindDocumentoEstado(documentoID, "error", "no se pudo extraer texto de ninguna página", 0)
|
|
return
|
|
}
|
|
|
|
// Trocear todo el contenido crawleado en chunks de texto plano.
|
|
var textos []string
|
|
for _, p := range paginas {
|
|
for _, c := range trocearTexto(p.Texto) {
|
|
if len(strings.TrimSpace(c)) < 40 {
|
|
continue // fragmentos demasiado cortos no aportan al RAG
|
|
}
|
|
textos = append(textos, c)
|
|
}
|
|
}
|
|
if len(textos) == 0 {
|
|
models.UpdateUmindDocumentoEstado(documentoID, "error", "no se generó ningún fragmento de texto aprovechable", 0)
|
|
return
|
|
}
|
|
|
|
// Generar embeddings en tandas para no mandar un solo request gigante.
|
|
const tandaTam = 50
|
|
var chunks []models.UmindChunk
|
|
for i := 0; i < len(textos); i += tandaTam {
|
|
fin := i + tandaTam
|
|
if fin > len(textos) {
|
|
fin = len(textos)
|
|
}
|
|
tanda := textos[i:fin]
|
|
vectores, err := GenerarEmbeddings(ai, tanda)
|
|
if err != nil {
|
|
models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("error generando embeddings: %v", err), len(chunks))
|
|
return
|
|
}
|
|
for j, texto := range tanda {
|
|
embJSON, err := models.EmbeddingToJSON(vectores[j])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
chunks = append(chunks, models.UmindChunk{
|
|
TenantID: tenantID,
|
|
DocumentoID: documentoID,
|
|
Contenido: texto,
|
|
EmbeddingJSON: embJSON,
|
|
})
|
|
}
|
|
}
|
|
|
|
if err := models.CreateUmindChunks(chunks); err != nil {
|
|
models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("error guardando fragmentos: %v", err), 0)
|
|
return
|
|
}
|
|
|
|
models.UpdateUmindDocumentoEstado(documentoID, "listo", "", len(chunks))
|
|
log.Printf("[UMIND] Ingesta de tenant %d completada: %d páginas, %d chunks", tenantID, len(paginas), len(chunks))
|
|
}
|