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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3ee83c8534
commit
3ea17b0980
+44
-4
@@ -131,11 +131,23 @@ func (t *UmindTenant) DominioPermitido(host string) bool {
|
||||
type UmindDocumento struct {
|
||||
gorm.Model
|
||||
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
|
||||
Tipo string `json:"tipo" gorm:"column:tipo;size:20"` // url | archivo
|
||||
Origen string `json:"origen" gorm:"column:origen;type:text"` // la URL crawleada, o el nombre del archivo
|
||||
Tipo string `json:"tipo" gorm:"column:tipo;size:20"` // url | archivo | texto
|
||||
Origen string `json:"origen" gorm:"column:origen;type:text"` // la URL crawleada, el nombre del archivo, o el título del texto
|
||||
Estado string `json:"estado" gorm:"column:estado;default:'pendiente'"` // pendiente | procesando | listo | error
|
||||
Error string `json:"error" gorm:"column:error;type:text"`
|
||||
TotalChunks int `json:"total_chunks" gorm:"column:total_chunks;default:0"`
|
||||
// Contenido guarda el texto de las fuentes que no se pueden volver a
|
||||
// buscar solas (lo que escribió el dueño, lo que se extrajo de un archivo).
|
||||
// Sin esto no se puede editar ni reprocesar sin volver a subir el archivo.
|
||||
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
|
||||
// MaxPaginas se guarda para poder recrawlear igual que la primera vez.
|
||||
MaxPaginas int `json:"max_paginas" gorm:"column:max_paginas;default:0"`
|
||||
// ProcesadoAt dice de cuándo es el conocimiento. Una web cambia y el agente
|
||||
// sigue contestando lo viejo con total seguridad: esta fecha es lo único
|
||||
// que delata que la fuente quedó vieja.
|
||||
ProcesadoAt *time.Time `json:"procesado_at" gorm:"column:procesado_at"`
|
||||
// AutoActualizar deja que el cron la vuelva a procesar sola.
|
||||
AutoActualizar bool `json:"auto_actualizar" gorm:"column:auto_actualizar;default:false"`
|
||||
}
|
||||
|
||||
func (UmindDocumento) TableName() string { return "umind_documentos" }
|
||||
@@ -159,11 +171,32 @@ func GetUmindDocumentoByID(id uint) (*UmindDocumento, error) {
|
||||
}
|
||||
|
||||
func UpdateUmindDocumentoEstado(id uint, estado, errMsg string, totalChunks int) error {
|
||||
return app.Http.Database.DB.Model(&UmindDocumento{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
updates := map[string]interface{}{
|
||||
"estado": estado,
|
||||
"error": errMsg,
|
||||
"total_chunks": totalChunks,
|
||||
}).Error
|
||||
}
|
||||
if estado == "listo" {
|
||||
ahora := time.Now()
|
||||
updates["procesado_at"] = &ahora
|
||||
}
|
||||
return app.Http.Database.DB.Model(&UmindDocumento{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func UpdateUmindDocumento(id uint, updates map[string]interface{}) error {
|
||||
return app.Http.Database.DB.Model(&UmindDocumento{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
// GetDocumentosParaRefrescar devuelve las fuentes con auto-actualización que no
|
||||
// se procesan desde hace más de los días indicados.
|
||||
func GetDocumentosParaRefrescar(dias int) ([]UmindDocumento, error) {
|
||||
var items []UmindDocumento
|
||||
corte := time.Now().AddDate(0, 0, -dias)
|
||||
err := app.Http.Database.DB.
|
||||
Where("auto_actualizar = ? AND estado <> ?", true, "procesando").
|
||||
Where("procesado_at IS NULL OR procesado_at < ?", corte).
|
||||
Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func DeleteUmindDocumento(id uint) error {
|
||||
@@ -206,6 +239,13 @@ func EmbeddingFromJSON(s string) ([]float32, error) {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// BorrarChunksDeDocumento limpia los fragmentos de una fuente antes de volver a
|
||||
// procesarla. Sin esto, reprocesar deja la versión vieja y la nueva compitiendo
|
||||
// en la búsqueda, y la vieja puede ganar.
|
||||
func BorrarChunksDeDocumento(documentoID uint) error {
|
||||
return app.Http.Database.DB.Where("documento_id = ?", documentoID).Delete(&UmindChunk{}).Error
|
||||
}
|
||||
|
||||
func CreateUmindChunks(chunks []UmindChunk) error {
|
||||
if len(chunks) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -78,6 +78,13 @@ func IniciarCron() {
|
||||
return
|
||||
}
|
||||
|
||||
// Conocimiento de los agentes que se actualiza solo — 4 AM, cuando nadie
|
||||
// está mirando: recrawlear varios sitios no es gratis.
|
||||
if _, err := cronScheduler.AddFunc("0 4 * * *", RefrescarConocimientoUmind); err != nil {
|
||||
log.Printf("[CRON] Error registrando tarea refresco_conocimiento: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Buzón de soporte por IMAP — cada 2 minutos. No hace nada si no está
|
||||
// configurado, así que registrarlo siempre no cuesta.
|
||||
if _, err := cronScheduler.AddFunc("*/2 * * * *", RevisarBuzonSoporte); err != nil {
|
||||
|
||||
@@ -197,6 +197,13 @@ func trocearTexto(texto string) []string {
|
||||
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
|
||||
@@ -212,16 +219,6 @@ func IngestarAgente(agenteID uint, documentoID uint, urlInicial string, maxPagin
|
||||
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
|
||||
}
|
||||
@@ -235,10 +232,43 @@ func IngestarAgente(agenteID uint, documentoID uint, urlInicial string, maxPagin
|
||||
return
|
||||
}
|
||||
|
||||
// Trocear todo el contenido crawleado en chunks de texto plano.
|
||||
var textos []string
|
||||
for _, p := range paginas {
|
||||
for _, c := range trocearTexto(p.Texto) {
|
||||
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
|
||||
}
|
||||
@@ -246,8 +276,9 @@ func IngestarAgente(agenteID uint, documentoID uint, urlInicial string, maxPagin
|
||||
}
|
||||
}
|
||||
if len(textos) == 0 {
|
||||
models.UpdateUmindDocumentoEstado(documentoID, "error", "no se generó ningún fragmento de texto aprovechable", 0)
|
||||
return
|
||||
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.
|
||||
@@ -262,7 +293,7 @@ func IngestarAgente(agenteID uint, documentoID uint, urlInicial string, maxPagin
|
||||
vectores, err := GenerarEmbeddings(ai, tanda)
|
||||
if err != nil {
|
||||
models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("error generando embeddings: %v", err), len(chunks))
|
||||
return
|
||||
return 0, err
|
||||
}
|
||||
for j, texto := range tanda {
|
||||
embJSON, err := models.EmbeddingToJSON(vectores[j])
|
||||
@@ -278,11 +309,49 @@ func IngestarAgente(agenteID uint, documentoID uint, urlInicial string, maxPagin
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
return 0, err
|
||||
}
|
||||
|
||||
models.UpdateUmindDocumentoEstado(documentoID, "listo", "", len(chunks))
|
||||
log.Printf("[UMIND] Ingesta de agente %d completada: %d páginas, %d chunks", agenteID, len(paginas), 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// El troceado es lo que decide qué puede encontrar el agente después. Un
|
||||
// fragmento cortado a la mitad de una frase o sin solape hace que la búsqueda
|
||||
// devuelva texto que no responde nada.
|
||||
func TestTrocearTexto(t *testing.T) {
|
||||
if got := trocearTexto(" "); got != nil {
|
||||
t.Errorf("texto vacío debería dar nil, dio %v", got)
|
||||
}
|
||||
|
||||
corto := "Atendemos de 9 a 18."
|
||||
if got := trocearTexto(corto); len(got) != 1 || got[0] != corto {
|
||||
t.Errorf("un texto corto tiene que quedar en un solo fragmento: %v", got)
|
||||
}
|
||||
|
||||
largo := strings.Repeat("palabra ", 900)
|
||||
chunks := trocearTexto(largo)
|
||||
if len(chunks) < 2 {
|
||||
t.Fatalf("un texto largo debería partirse, dio %d fragmento(s)", len(chunks))
|
||||
}
|
||||
for i, c := range chunks {
|
||||
if strings.HasPrefix(c, " ") || strings.HasSuffix(c, " ") {
|
||||
t.Errorf("fragmento %d con espacios en los bordes: %q", i, c)
|
||||
}
|
||||
if c == "" {
|
||||
t.Errorf("fragmento %d vacío", i)
|
||||
}
|
||||
}
|
||||
// Sin solape, una frase que cae justo en el corte se pierde para siempre.
|
||||
if !strings.HasPrefix(chunks[1], "palabra") {
|
||||
t.Errorf("el segundo fragmento debería arrancar con contenido real: %q", chunks[1][:20])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user