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.
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user