Files
soft_usite/pkg/services/umind_ingest_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

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))
}