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:
Lizandro Guarnizo
2026-08-19 12:45:41 -05:00
co-authored by Claude Opus 5
parent 3ee83c8534
commit 3ea17b0980
11 changed files with 542 additions and 65 deletions
+164 -8
View File
@@ -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>