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 } // El solape caía en cualquier lado, así que el fragmento siguiente // podía arrancar a mitad de una palabra ("alabra…"). Se corre hasta el // espacio siguiente: media palabra suelta al principio no aporta nada // al embedding y ensucia el fragmento que se le muestra al modelo. if esp := strings.IndexByte(texto[siguiente:], ' '); esp > 0 && siguiente+esp < inicio+corte { siguiente += esp + 1 } 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 } 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 } var textos []string for _, p := range paginas { textos = append(textos, p.Texto) } if n, err := guardarConocimiento(agenteID, documentoID, textos); err != nil { log.Printf("[UMIND] Ingesta de agente %d fallida: %v", agenteID, err) } else { log.Printf("[UMIND] Ingesta de agente %d completada: %d páginas, %d chunks", agenteID, len(paginas), n) } } // IngestarTexto guarda como conocimiento un texto que se cargó a mano o que se // extrajo de un archivo. Es el mismo trabajo que hace la ingesta de una URL // desde que tiene el texto: trocear, embeber y guardar. func IngestarTexto(agenteID, documentoID uint, texto string) { if _, err := guardarConocimiento(agenteID, documentoID, []string{texto}); err != nil { log.Printf("[UMIND] Ingesta de texto del agente %d fallida: %v", agenteID, err) } } // guardarConocimiento trocea, genera los embeddings y reemplaza los fragmentos // del documento. Reemplaza y no agrega: si no, reprocesar una fuente dejaría // dos versiones del mismo contenido compitiendo en la búsqueda, y la vieja // puede ganar. func guardarConocimiento(agenteID, documentoID uint, fuentes []string) (int, error) { // 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 para que la similitud coseno entre vectores tenga sentido. ai, err := models.GetUmindEmbeddingsConfig() if err != nil { models.UpdateUmindDocumentoEstado(documentoID, "error", err.Error(), 0) return 0, err } var textos []string for _, fuente := range fuentes { for _, c := range trocearTexto(fuente) { if len(strings.TrimSpace(c)) < 40 { continue // fragmentos demasiado cortos no aportan al RAG } textos = append(textos, c) } } if len(textos) == 0 { msg := "no se generó ningún fragmento de texto aprovechable" models.UpdateUmindDocumentoEstado(documentoID, "error", msg, 0) return 0, fmt.Errorf("%s", msg) } // 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 0, err } 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.BorrarChunksDeDocumento(documentoID); err != nil { models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("no se pudieron limpiar los fragmentos anteriores: %v", err), 0) return 0, err } if err := models.CreateUmindChunks(chunks); err != nil { models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("error guardando fragmentos: %v", err), 0) return 0, err } models.UpdateUmindDocumentoEstado(documentoID, "listo", "", len(chunks)) return len(chunks), nil } // ReprocesarDocumento vuelve a procesar una fuente, en segundo plano. // Una URL se recrawlea —es la única forma de que el agente deje de contestar // con la información del año pasado— y una nota o un archivo se rearman desde // el texto guardado, sin pedirle al dueño que lo vuelva a subir. func ReprocesarDocumento(doc models.UmindDocumento) { _ = models.UpdateUmindDocumentoEstado(doc.ID, "procesando", "", doc.TotalChunks) if doc.Tipo == "url" { go IngestarAgente(doc.AgenteID, doc.ID, doc.Origen, doc.MaxPaginas) return } if strings.TrimSpace(doc.Contenido) == "" { _ = models.UpdateUmindDocumentoEstado(doc.ID, "error", "esta fuente se cargó antes de que se guardara su texto: volvé a subirla", 0) return } go IngestarTexto(doc.AgenteID, doc.ID, doc.Contenido) } // RefrescarConocimientoUmind recrawlea las fuentes marcadas para actualizarse // solas. Lo llama el cron: sin esto, el conocimiento se congela el día que se // cargó y nadie se entera, porque no falla — solo queda viejo. func RefrescarConocimientoUmind() { const diasEntreRefrescos = 7 docs, err := models.GetDocumentosParaRefrescar(diasEntreRefrescos) if err != nil { log.Printf("[UMIND] No se pudieron buscar fuentes para refrescar: %v", err) return } for _, d := range docs { log.Printf("[UMIND] Refrescando fuente %d del agente %d (%s)", d.ID, d.AgenteID, d.Origen) ReprocesarDocumento(d) } }