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
@@ -37,6 +37,15 @@ async function cargarDocumentos() {
|
||||
documentos.value = r.items || []
|
||||
}
|
||||
|
||||
// Tres formas de cargar conocimiento, no una: la web, un archivo (lista de
|
||||
// precios, condiciones) y lo que el dueño escribe a mano, que es lo más
|
||||
// valioso y lo único que no está en ningún documento.
|
||||
const fuenteNueva = ref('texto')
|
||||
const autoActualizar = ref(true)
|
||||
const notaTitulo = ref('')
|
||||
const notaTexto = ref('')
|
||||
const archivoRef = ref(null)
|
||||
|
||||
async function agregarFuente() {
|
||||
if (!nuevaUrl.value.trim()) return
|
||||
ingestando.value = true
|
||||
@@ -46,6 +55,7 @@ async function agregarFuente() {
|
||||
agente_id: agenteIdNum.value,
|
||||
url: nuevaUrl.value.trim(),
|
||||
max_paginas: Number(maxPaginas.value) || 30,
|
||||
auto_actualizar: autoActualizar.value,
|
||||
})
|
||||
nuevaUrl.value = ''
|
||||
await cargarDocumentos()
|
||||
@@ -56,6 +66,84 @@ async function agregarFuente() {
|
||||
}
|
||||
}
|
||||
|
||||
async function agregarNota() {
|
||||
if (!notaTexto.value.trim()) return
|
||||
ingestando.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await api.post(apiUmind('/umind/documentos/texto'), {
|
||||
agente_id: agenteIdNum.value,
|
||||
titulo: notaTitulo.value.trim(),
|
||||
contenido: notaTexto.value,
|
||||
})
|
||||
notaTitulo.value = ''
|
||||
notaTexto.value = ''
|
||||
await cargarDocumentos()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
ingestando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function subirArchivo() {
|
||||
const f = archivoRef.value?.files?.[0]
|
||||
if (!f) return
|
||||
ingestando.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('agente_id', String(agenteIdNum.value))
|
||||
fd.append('archivo', f)
|
||||
// Sin Content-Type a mano: el navegador tiene que poner el boundary.
|
||||
const res = await fetch(apiUmind('/umind/documentos/archivo'), { method: 'POST', body: fd })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'No se pudo subir')
|
||||
archivoRef.value.value = ''
|
||||
await cargarDocumentos()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
ingestando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reprocesar(d) {
|
||||
error.value = ''
|
||||
try {
|
||||
await api.post(apiUmind(`/umind/documentos/${d.ID}/reprocesar`), {})
|
||||
await cargarDocumentos()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function alternarAuto(d) {
|
||||
try {
|
||||
await api.put(apiUmind(`/umind/documentos/${d.ID}`), { auto_actualizar: !d.auto_actualizar })
|
||||
await cargarDocumentos()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
// "hace 3 días" en vez de una fecha: lo que importa no es cuándo se procesó
|
||||
// sino qué tan viejo es lo que el agente está contestando.
|
||||
function antiguedad(fecha) {
|
||||
if (!fecha) return 'sin procesar'
|
||||
const dias = Math.floor((Date.now() - new Date(fecha)) / 86400000)
|
||||
if (dias <= 0) return 'hoy'
|
||||
if (dias === 1) return 'ayer'
|
||||
if (dias < 30) return `hace ${dias} días`
|
||||
const meses = Math.floor(dias / 30)
|
||||
return meses === 1 ? 'hace un mes' : `hace ${meses} meses`
|
||||
}
|
||||
|
||||
function estaVieja(d) {
|
||||
if (!d.procesado_at) return false
|
||||
return Date.now() - new Date(d.procesado_at) > 60 * 86400000
|
||||
}
|
||||
|
||||
async function eliminarDocumento(id) {
|
||||
if (!confirm('¿Eliminar esta fuente y sus fragmentos indexados?')) return
|
||||
await api.del(apiUmind(`/umind/documentos/${id}`))
|
||||
@@ -410,13 +498,61 @@ watch(
|
||||
|
||||
<!-- Base de conocimiento -->
|
||||
<div v-if="tab === 'conocimiento'">
|
||||
<form class="flex gap-2 mb-4" @submit.prevent="agregarFuente">
|
||||
<input v-model="nuevaUrl" type="url" placeholder="https://ejemplo.com" required class="flex-1 border border-borde bg-white dark:bg-gray-900 text-texto rounded-lg px-3 py-2 text-sm" />
|
||||
<input v-model="maxPaginas" type="number" min="1" max="200" class="w-24 border border-borde bg-white dark:bg-gray-900 text-texto rounded-lg px-3 py-2 text-sm" title="Máximo de páginas a crawlear" />
|
||||
<button type="submit" :disabled="ingestando" class="btn-primary disabled:opacity-50 transition-colors">
|
||||
{{ ingestando ? 'Agregando...' : 'Crawlear sitio' }}
|
||||
</button>
|
||||
</form>
|
||||
<div class="card p-4 mb-4">
|
||||
<div class="flex gap-1 mb-3">
|
||||
<button
|
||||
v-for="[k, l] in [['texto', '✍️ Escribir'], ['archivo', '📄 Subir archivo'], ['url', '🌐 Sitio web']]"
|
||||
:key="k"
|
||||
class="px-3 py-1.5 rounded-lg text-sm"
|
||||
:class="fuenteNueva === k ? 'bg-brand text-white font-medium' : 'text-tenue hover:text-texto'"
|
||||
@click="fuenteNueva = k"
|
||||
>{{ l }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Lo que el dueño sabe y no está escrito en ningún lado. -->
|
||||
<form v-if="fuenteNueva === 'texto'" class="space-y-2" @submit.prevent="agregarNota">
|
||||
<input v-model="notaTitulo" placeholder="Título (ej: Horarios y zonas de entrega)" class="input" />
|
||||
<textarea
|
||||
v-model="notaTexto"
|
||||
rows="5"
|
||||
required
|
||||
placeholder="Atendemos de lunes a viernes de 9 a 18. No hacemos envíos fuera de la ciudad. La garantía es de 6 meses y cubre…"
|
||||
class="input font-normal"
|
||||
></textarea>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs text-tenue">Lo que te preguntan todos los días y no está en tu web.</p>
|
||||
<button type="submit" :disabled="ingestando" class="btn-primary disabled:opacity-50">
|
||||
{{ ingestando ? 'Guardando…' : 'Guardar' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form v-else-if="fuenteNueva === 'archivo'" class="space-y-2" @submit.prevent="subirArchivo">
|
||||
<input ref="archivoRef" type="file" accept=".pdf,.docx,.txt,.md,.csv,.html,image/*" class="text-sm text-texto" />
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs text-tenue">PDF, Word, texto o una foto. Ej: tu lista de precios.</p>
|
||||
<button type="submit" :disabled="ingestando" class="btn-primary disabled:opacity-50">
|
||||
{{ ingestando ? 'Leyendo…' : 'Subir' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form v-else class="space-y-2" @submit.prevent="agregarFuente">
|
||||
<div class="flex gap-2">
|
||||
<input v-model="nuevaUrl" type="url" placeholder="https://ejemplo.com" required class="input flex-1" />
|
||||
<input v-model="maxPaginas" type="number" min="1" max="200" class="input w-24" title="Máximo de páginas a leer" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center gap-2 text-xs text-tenue cursor-pointer">
|
||||
<input v-model="autoActualizar" type="checkbox" class="rounded border-borde text-brand focus:ring-brand" />
|
||||
Releer el sitio cada semana
|
||||
</label>
|
||||
<button type="submit" :disabled="ingestando" class="btn-primary disabled:opacity-50">
|
||||
{{ ingestando ? 'Leyendo…' : 'Leer sitio' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card divide-y divide-borde">
|
||||
<div v-if="documentos.length === 0" class="p-6 text-sm text-tenue">Sin fuentes todavía.</div>
|
||||
<div v-for="d in documentos" :key="d.ID" class="p-4 flex items-center justify-between">
|
||||
@@ -424,11 +560,31 @@ watch(
|
||||
<div class="text-sm text-texto">{{ d.origen }}</div>
|
||||
<div class="text-xs text-tenue mt-0.5">
|
||||
<span class="px-1.5 py-0.5 rounded" :class="estadoColor(d.estado)">{{ d.estado }}</span>
|
||||
<span v-if="d.tipo"> · {{ { url: 'sitio', archivo: 'archivo', texto: 'nota' }[d.tipo] || d.tipo }}</span>
|
||||
<span v-if="d.total_chunks"> · {{ d.total_chunks }} fragmentos</span>
|
||||
<!-- La antigüedad, en rojo cuando pasó de dos meses: es lo único
|
||||
que delata que el agente contesta con información vieja. -->
|
||||
<span :class="estaVieja(d) ? 'text-amber-600 dark:text-amber-400 font-medium' : ''">
|
||||
· leído {{ antiguedad(d.procesado_at) }}
|
||||
</span>
|
||||
<span v-if="d.auto_actualizar"> · se actualiza sola</span>
|
||||
<span v-if="d.error" class="text-red-600 dark:text-red-400"> · {{ d.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="text-red-500 hover:text-red-700 text-sm" @click="eliminarDocumento(d.ID)">Eliminar</button>
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
v-if="d.tipo === 'url'"
|
||||
class="text-xs text-tenue hover:text-texto"
|
||||
:title="d.auto_actualizar ? 'Dejar de releer sola' : 'Releer el sitio cada semana'"
|
||||
@click="alternarAuto(d)"
|
||||
>{{ d.auto_actualizar ? '🔁 auto' : '↻ manual' }}</button>
|
||||
<button
|
||||
class="text-sm text-brand hover:underline disabled:opacity-40"
|
||||
:disabled="d.estado === 'procesando'"
|
||||
@click="reprocesar(d)"
|
||||
>Actualizar</button>
|
||||
<button class="text-red-500 hover:text-red-700 text-sm" @click="eliminarDocumento(d.ID)">Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+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])
|
||||
}
|
||||
}
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>uMind Studio</title>
|
||||
<script type="module" crossorigin src="/orchestrator/assets/index-Dn2ZJSQE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-BaBLTLOm.css">
|
||||
<script type="module" crossorigin src="/orchestrator/assets/index-D4CbBy2Y.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-C_8yw0Pk.css">
|
||||
</head>
|
||||
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
|
||||
que son los que cambian con el tema. Una utilidad acá le ganaba a la
|
||||
|
||||
@@ -3,6 +3,7 @@ package controllers
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -297,9 +298,10 @@ func GetUmindDocumentosHandler(c *fiber.Ctx) error {
|
||||
// segundos/minutos según cuántas páginas tenga el sitio.
|
||||
func CreateUmindDocumentoHandler(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
AgenteID uint `json:"agente_id"`
|
||||
URL string `json:"url"`
|
||||
MaxPaginas int `json:"max_paginas"`
|
||||
AgenteID uint `json:"agente_id"`
|
||||
URL string `json:"url"`
|
||||
MaxPaginas int `json:"max_paginas"`
|
||||
AutoActualizar bool `json:"auto_actualizar"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||
@@ -318,10 +320,12 @@ func CreateUmindDocumentoHandler(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
doc := &models.UmindDocumento{
|
||||
AgenteID: req.AgenteID,
|
||||
Tipo: "url",
|
||||
Origen: strings.TrimSpace(req.URL),
|
||||
Estado: "procesando",
|
||||
AgenteID: req.AgenteID,
|
||||
Tipo: "url",
|
||||
Origen: strings.TrimSpace(req.URL),
|
||||
Estado: "procesando",
|
||||
MaxPaginas: req.MaxPaginas,
|
||||
AutoActualizar: req.AutoActualizar,
|
||||
}
|
||||
if err := models.CreateUmindDocumento(doc); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
@@ -859,3 +863,162 @@ func slugSimple(s string) string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ─── Fuentes de conocimiento que no son una URL ─────────────────────────────
|
||||
|
||||
// CreateUmindTextoHandler guarda como conocimiento un texto escrito a mano.
|
||||
// Es la fuente más valiosa y la que no está en ningún lado: horarios, qué no
|
||||
// hacen, la respuesta a la pregunta que les hacen todos los días.
|
||||
// POST /umind/documentos/texto
|
||||
func CreateUmindTextoHandler(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
AgenteID uint `json:"agente_id"`
|
||||
Titulo string `json:"titulo"`
|
||||
Contenido string `json:"contenido"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||
}
|
||||
if _, err := accesoAgente(c, req.AgenteID); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(req.Contenido) == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "escribí algo para que el agente lo aprenda"})
|
||||
}
|
||||
titulo := strings.TrimSpace(req.Titulo)
|
||||
if titulo == "" {
|
||||
titulo = "Nota"
|
||||
}
|
||||
|
||||
doc := &models.UmindDocumento{
|
||||
AgenteID: req.AgenteID,
|
||||
Tipo: "texto",
|
||||
Origen: titulo,
|
||||
Contenido: req.Contenido,
|
||||
Estado: "procesando",
|
||||
}
|
||||
if err := models.CreateUmindDocumento(doc); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
go services.IngestarTexto(req.AgenteID, doc.ID, req.Contenido)
|
||||
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"ok": true, "id": doc.ID, "estado": "procesando"})
|
||||
}
|
||||
|
||||
// CreateUmindArchivoHandler carga un archivo como conocimiento. Usa el mismo
|
||||
// extractor que los adjuntos de los canales: PDF, Word, texto e imágenes.
|
||||
// POST /umind/documentos/archivo (multipart: agente_id, archivo)
|
||||
func CreateUmindArchivoHandler(c *fiber.Ctx) error {
|
||||
agenteID, _ := strconv.ParseUint(c.FormValue("agente_id"), 10, 64)
|
||||
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
||||
return err
|
||||
}
|
||||
archivo, err := c.FormFile("archivo")
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subí un archivo"})
|
||||
}
|
||||
if archivo.Size > 20<<20 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el archivo supera los 20 MB"})
|
||||
}
|
||||
f, err := archivo.Open()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "no se pudo leer el archivo"})
|
||||
}
|
||||
defer f.Close()
|
||||
datos, err := io.ReadAll(io.LimitReader(f, 20<<20))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "no se pudo leer el archivo"})
|
||||
}
|
||||
|
||||
// El texto se extrae acá y no en segundo plano: si el archivo no se puede
|
||||
// leer, conviene decirlo mientras la persona está mirando la pantalla.
|
||||
texto, err := services.ExtraerTextoDeArchivo(uint(agenteID), archivo.Filename, datos)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
doc := &models.UmindDocumento{
|
||||
AgenteID: uint(agenteID),
|
||||
Tipo: "archivo",
|
||||
Origen: archivo.Filename,
|
||||
Contenido: texto,
|
||||
Estado: "procesando",
|
||||
}
|
||||
if err := models.CreateUmindDocumento(doc); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
go services.IngestarTexto(uint(agenteID), doc.ID, texto)
|
||||
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"ok": true, "id": doc.ID, "estado": "procesando"})
|
||||
}
|
||||
|
||||
// ReprocesarUmindDocumentoHandler vuelve a leer una fuente. Para una URL la
|
||||
// crawlea de nuevo —es la única forma de que el agente deje de contestar los
|
||||
// precios del año pasado— y para el resto rearma los fragmentos del texto
|
||||
// guardado.
|
||||
// POST /umind/documentos/:id/reprocesar
|
||||
func ReprocesarUmindDocumentoHandler(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
||||
}
|
||||
if err := accesoDocumento(c, uint(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
doc, err := models.GetUmindDocumentoByID(uint(id))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "fuente no encontrada"})
|
||||
}
|
||||
if doc.Estado == "procesando" {
|
||||
return c.Status(409).JSON(fiber.Map{"error": "esta fuente ya se está procesando"})
|
||||
}
|
||||
services.ReprocesarDocumento(*doc)
|
||||
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"ok": true, "estado": "procesando"})
|
||||
}
|
||||
|
||||
// ActualizarUmindDocumentoHandler edita el texto de una fuente escrita a mano
|
||||
// y la vuelve a procesar.
|
||||
// PUT /umind/documentos/:id
|
||||
func ActualizarUmindDocumentoHandler(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
||||
}
|
||||
if err := accesoDocumento(c, uint(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
doc, err := models.GetUmindDocumentoByID(uint(id))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "fuente no encontrada"})
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Titulo string `json:"titulo"`
|
||||
Contenido string `json:"contenido"`
|
||||
AutoActualizar *bool `json:"auto_actualizar"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
if req.AutoActualizar != nil {
|
||||
updates["auto_actualizar"] = *req.AutoActualizar
|
||||
}
|
||||
reprocesar := false
|
||||
if doc.Tipo == "texto" && strings.TrimSpace(req.Contenido) != "" {
|
||||
updates["contenido"] = req.Contenido
|
||||
if t := strings.TrimSpace(req.Titulo); t != "" {
|
||||
updates["origen"] = t
|
||||
}
|
||||
doc.Contenido = req.Contenido
|
||||
reprocesar = true
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := models.UpdateUmindDocumento(uint(id), updates); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
if reprocesar {
|
||||
services.ReprocesarDocumento(*doc)
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ func RegistrarRutasUmind(g fiber.Router, scope fiber.Handler, escritura fiber.Ha
|
||||
|
||||
g.Get("/umind/documentos", r(controllers.GetUmindDocumentosHandler)...)
|
||||
g.Post("/umind/documentos", w(controllers.CreateUmindDocumentoHandler)...)
|
||||
g.Post("/umind/documentos/texto", w(controllers.CreateUmindTextoHandler)...)
|
||||
g.Post("/umind/documentos/archivo", w(controllers.CreateUmindArchivoHandler)...)
|
||||
g.Put("/umind/documentos/:id", w(controllers.ActualizarUmindDocumentoHandler)...)
|
||||
g.Post("/umind/documentos/:id/reprocesar", w(controllers.ReprocesarUmindDocumentoHandler)...)
|
||||
g.Delete("/umind/documentos/:id", w(controllers.DeleteUmindDocumentoHandler)...)
|
||||
|
||||
g.Get("/umind/sesiones", r(controllers.GetUmindSesionesHandler)...)
|
||||
|
||||
Reference in New Issue
Block a user