Files
soft_usite/pkg/services/umind_ingest_service.go
T
Lizandro GuarnizoandClaude Opus 5 3ea17b0980 feat(umind): cargar conocimiento de tres formas y que deje de quedar viejo en silencio
Hasta ahora la única forma de darle información a un agente era crawlear una
URL, una vez, para siempre. Tres cosas cambian:

Escribir a mano. Es la fuente más valiosa y la única que no está en ningún
documento: horarios, qué no hacen, la respuesta que dan quince veces por día.
También es la única que el dueño puede corregir en el momento en que ve al
agente contestar mal.

Subir un archivo. La lista de precios suele estar en un PDF, no en la web. Usa
el mismo extractor que los adjuntos de los canales, así que PDF, Word, texto e
imágenes entran sin código nuevo. El texto se extrae con la persona mirando la
pantalla: si el archivo no se puede leer, se dice ahí y no en un log.

Y lo importante: el conocimiento se congelaba el día que se cargaba. Si el
cliente cambiaba los precios en su sitio, el agente seguía dando los viejos con
total seguridad — sin error, sin aviso, nada. Ahora cada fuente muestra de
cuándo es ("leído hace 3 meses", en ámbar pasados dos meses), tiene botón de
actualizar, y las URLs pueden marcarse para releerse solas cada semana (cron a
las 4 AM). Reprocesar reemplaza los fragmentos en vez de sumarlos: si no,
quedaban las dos versiones compitiendo en la búsqueda y podía ganar la vieja.

De paso, el troceado dejaba fragmentos que arrancaban a mitad de palabra
("alabra…") porque el solape no se alineaba a un espacio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:45:41 -05:00

358 lines
11 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
}
// 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)
}
}