Files
soft_usite/pkg/services/umind_ingest_service.go
T
Lizandro GuarnizoandClaude Sonnet 5 f3f2f421d6 feat: uMind pasa a multi-agente por tenant
Un tenant (negocio/sitio, dueño de los dominios permitidos) puede tener
varios UmindAgente independientes (ej. "Ventas", "Soporte"), cada uno con
su propia config de IA, tono, base de conocimiento, tools, canales y
conexión de correo. El site_key también pasa a ser por agente, así cada
uno tiene su propio <script> de widget embebible y su propio color.

Backend:
- Nuevo modelo UmindAgente (pkg/models/umind_agente.go), con SiteKey,
  AiConfigID, Tono, MensajeBienvenida y Color — campos que antes vivían en
  UmindTenant y se sacan de ahí (las columnas viejas quedan huérfanas sin
  usar, no se hace DROP COLUMN).
- UmindDocumento, UmindChunk, UmindHerramienta, UmindCanal, UmindConexion y
  UmindMensaje pasan de TenantID a AgenteID. El campo se agrega sin
  "not null" para no romper el ALTER TABLE en Postgres sobre tablas que ya
  tienen filas (ej. emetropolitana).
- migrations.MigrarUmindAgentes(): idempotente, crea un agente "Principal"
  por cada tenant existente heredando lo que ya tenía configurado, y mueve
  sus datos de tenant_id a agente_id. Corre en cada arranque normal, mismo
  criterio que los Seed* — nada se rompe para los tenants ya en producción.
- Motor del agente, widget, canales (Telegram/WhatsApp) y OAuth de correo
  ahora operan sobre UmindAgente; el tenant solo se consulta para el
  chequeo de dominio permitido y el nombre del negocio que ve el visitante.

Frontend: nueva jerarquía de navegación tenant → lista de agentes
(TenantAgentes.vue) → detalle de un agente (AgenteDetail.vue, antes
TenantDetail.vue) con las mismas 6 tabs de siempre, ahora por agente. El
modal de tenant en el sidebar se achica a nombre/dominios/activo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:30:06 -05:00

289 lines
8.5 KiB
Go

package services
import (
"fmt"
"io"
"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")
esHTML := ct == "" || strings.Contains(ct, "text/html")
// Además de HTML, se acepta texto plano/Markdown servido tal cual — es un
// patrón cada vez más común (ej. este mismo caso: un .md pensado para que
// lo consuma una IA, sin ruido de HTML que limpiar).
esTexto := strings.Contains(ct, "text/plain") || strings.Contains(ct, "text/markdown") ||
strings.HasSuffix(strings.ToLower(strings.Split(pageURL, "?")[0]), ".md")
if !esHTML && !esTexto {
return nil, fmt.Errorf("%s no es HTML ni texto plano (%s)", pageURL, ct)
}
if !esHTML {
body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
if err != nil {
return nil, fmt.Errorf("no se pudo leer %s: %w", pageURL, err)
}
pagina := &paginaCrawleada{URL: pageURL, Texto: normalizarEspacios(string(body))}
primeraLinea := strings.TrimSpace(strings.SplitN(string(body), "\n", 2)[0])
if strings.HasPrefix(primeraLinea, "#") {
pagina.Titulo = strings.TrimSpace(strings.TrimLeft(primeraLinea, "# "))
}
return pagina, nil
}
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
}
// IngestarAgente crawlea el sitio indicado, trocea el contenido, genera los
// embeddings y los guarda como UmindChunk para ese agente. 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 IngestarAgente(agenteID uint, documentoID uint, urlInicial string, maxPaginas int) {
if _, err := models.GetUmindAgenteByID(agenteID); err != nil {
models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("agente no encontrado: %v", err), 0)
return
}
// Los embeddings usan una config global (módulo "umind_embeddings"), no la
// del agente: todos los chunks de todos los agentes deben salir del mismo
// modelo de embeddings para que la similitud coseno entre vectores tenga
// sentido. La config del agente (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{
AgenteID: agenteID,
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 agente %d completada: %d páginas, %d chunks", agenteID, len(paginas), len(chunks))
}