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