Los tickets del portal ya avisaban por Telegram; los que entran por correo solo
mandaban un mail al admin, que es justo el canal que nadie mira a tiempo. Ahora
pasan por el mismo despachador de notificaciones, así que se prenden y apagan
desde /app/notif-config como cualquier otro evento — nuevo ticket y respuesta
del cliente por separado.
El cuerpo del aviso es un resumen cuando el correo pasa los 700 caracteres
(hilos reenviados, texto pegado); abajo de eso va tal cual, porque resumir tres
renglones es gastar una llamada de IA para decir lo mismo. Si la IA falla, se
manda recortado. El ticket guarda siempre el texto completo.
DispatchTicketNuevo ahora acepta portalUser nil: un correo no tiene usuario de
portal detrás y no por eso hay que dejar de avisar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Escribí una segunda implementación de "llamar al proveedor" en vez de usar la
que ya existe (callAI, la del bot de Telegram). Esa copia no hablaba Anthropic
—se lo mandaba todo al endpoint de OpenAI— y, si la config tenía la URL base
vacía (lo normal: la vista dice que es opcional), armaba una URL relativa que
ni siquiera es una petición válida. Cualquiera de las dos cosas terminaba en
el 502.
Ahora usa callAI, que despacha por proveedor y completa la URL base. El error
del proveedor queda en el log del servidor y el mensaje llega entero a la vista;
si la respuesta no trae mensaje, la vista muestra el status para distinguir un
fallo de la app de uno del proxy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Faltaba la tercera pata: audio pasaba por Whisper, imagen por OCR, y un PDF o
un Word adjunto se ignoraba en silencio. Ahora hay un interruptor por canal
—"Leer archivos adjuntos"— y los documentos que llegan por Telegram o WhatsApp
se convierten a texto antes de pasar al agente.
PDF se lee con stdlib cuando el documento es digital (facturas, cotizaciones,
lo exportado por cualquier programa) y cae al OCR si es un escaneo. Word .docx
es un zip con XML adentro; texto plano, CSV y JSON van directo.
El agente recibe el contenido con contexto ("el cliente adjuntó X, y escribió
Y"), no el chorizo pelado: sin eso el modelo contesta como si el cliente
hubiera tipeado una factura.
De paso la importación de plantillas gana PDF, que usa el mismo extractor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hasta ahora crear una plantilla era escribir HTML con variables de Go a mano.
Ahora se sube el documento que ya existe (.docx, .html, .txt o una foto del
papel), se lee —docx con stdlib, imágenes por OCR— y la IA lo devuelve armado
como plantilla con las variables del generador puestas donde iban los datos.
No se guarda solo: el HTML cae en el editor para revisarlo, y si la IA devolvió
sintaxis de plantilla inválida se avisa antes de guardar.
PDF queda afuera por ahora; el mensaje de error dice qué hacer en su lugar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Los clientes escriben a soporte@ desde su correo de siempre. Hasta ahora eso
solo llegaba si un proveedor (SendGrid/Mailgun) nos hacía POST; si nadie lo
configuraba, los correos quedaban sin leer en el buzón.
Ahora el cron entra al buzón cada 2 minutos, baja los no leídos, abre ticket
(o los engancha al hilo si son respuesta) y los marca como leídos. La lógica
de ingesta se movió a services para que webhook e IMAP se comporten igual.
La contraseña del buzón se guarda cifrada (AES-GCM con APP_KEY) y no vuelve
al navegador.
De paso, dos bugs que impedían guardar la configuración: el formulario mandaba
id=0 (gorm.Model serializa "ID"), así que cada guardado creaba una fila nueva
en vez de editar la que se usa; y Updates con struct ignoraba los booleanos en
false, así que desactivar algo no tenía efecto.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dos de las cuatro mejoras de la auditoría del agente.
1. Las herramientas aceptan cliente_nombre además de cliente_id.
De 27 parámetros que eran IDs, 10 eran de cliente. Para cada acción el
modelo tenía que encadenar: listar_clientes → leer la respuesta →
encontrar la fila → extraer el número → recién ahí llamar a la
herramienta. Cuatro pasos, y basta que falle uno para que no se guarde
nada — es la explicación más probable de la factura que nunca se creó,
porque ese cliente podía no venir en la primera página del listado.
Ahora el modelo pasa el nombre y el servidor lo resuelve, con búsqueda
completa y no paginada. Si hay varios candidatos devuelve la lista para
que el modelo pregunte cuál, en vez de elegir uno: cargarle una factura
a la empresa equivocada es peor que preguntar. Una coincidencia exacta
gana sobre las parciales, así "Metropolitana" no queda ambiguo solo
porque existe "Metropolitana Norte".
2. Contadores de uso y fallo por herramienta, expuestos como
estado_herramientas.
Cada problema se venía diagnosticando de a un caso, reconstruyendo qué
pasó después de que el usuario avisara. Ahora se puede preguntar
directamente qué viene fallando y ver si es una herramienta puntual o el
modelo eligiendo mal. En memoria a propósito: alcanza para responder eso
y no agrega una tabla.
Quedan las otras dos: adelgazar el prompt de 5,4 KB moviendo los manuales
por dominio a las descripciones de las herramientas, y comparar modelos con
un mismo caso. La primera conviene hacerla con el sistema estable, porque
toca cómo decide el modelo en todos los flujos a la vez.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmado que la factura nunca se creó: la lista de /app/facturas no
filtra nada que pudiera esconderla. La herramienta falló y el modelo
informó "FACTURA GUARDADA EXITOSAMENTE" igual.
El commit anterior le prohíbe eso por prompt, pero un prompt es una
sugerencia. Esto es la garantía: el código junta los errores que
devolvieron las herramientas durante la conversación y los agrega a la
respuesta. Si algo no se completó, se ve, diga lo que diga el modelo.
No reemplaza al prompt — el modelo sigue debiendo explicar el fallo con
sus palabras. Es la red por debajo, para que un "guardado exitosamente"
sobre algo que no se guardó no pueda pasar desapercibido otra vez.
Sigue sin saberse por qué falló aquella vez, porque hasta hoy el resultado
de las herramientas no se registraba. Con el log y este aviso, el próximo
intento lo va a decir en el momento.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Auditoría de qué expone /api/v2 contra qué puede hacer el bot. El bot no
llama a la API: usa su propio registro de herramientas, así que la API
puede crecer y el bot queda atrás sin que nada lo señale.
De 46 herramientas contra ~300 endpoints de v2, el hueco más caro estaba
en el flujo que se estuvo usando hoy:
- Se podían crear cuentas por cobrar y por pagar, pero no marcarlas
pagadas — y marcar pagado es justamente lo que genera la transacción en
contabilidad. O sea que el bot podía registrar deuda pero nunca cerrarla.
- No había forma de corregir una factura mal cargada, que es exactamente lo
que hacía falta después del bug del monto en cero.
Se agregan actualizar_factura, marcar_cuenta_cobro_pagada y
marcar_cuenta_pagar_pagada. actualizar_factura solo pisa los campos que
vinieron, así que corregir el monto no borra el número ni el concepto.
Quedan sin herramientas áreas enteras que v2 sí cubre (hostinger,
cloudflare, websms, portal-usuarios, uMind, api-keys, saas, plantillas).
No se agregan a ciegas: cada herramienta ocupa lugar en el prompt y
compite por la atención del modelo, así que conviene sumarlas cuando haya
un uso real detrás.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Por qué no aparecía la factura: la única herramienta de venta del bot era
adjuntar_factura, y exige un documento pendiente en el chat. Al dictarle
los datos por texto el modelo no tenía con qué guardar — y como el prompt
tampoco se lo prohibía, informó "guardada exitosamente" sobre algo que
nunca se creó.
/api/v2/facturas ya permitía crearlas, pero el bot no llama a la API REST:
usa su propio registro de herramientas, así que solo puede hacer lo que ese
registro expone. El hueco estaba ahí, no en la API.
crear_factura toma cliente_id y monto (obligatorios), número, concepto y
fechas. Devuelve el monto realmente guardado y el id, que es lo que el
prompt le exige informar en vez de repetir lo que dijo el usuario.
Las descripciones de las dos herramientas se remiten entre sí para que el
modelo no elija la equivocada: con documento adjunto va adjuntar_factura,
con datos dictados va crear_factura. El test verifica esa distinción,
porque elegir mal reproduce exactamente el fallo original.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
El bot informó "FACTURA GUARDADA EXITOSAMENTE" con $290.000 y el registro
no aparecía. Revisando el camino salieron tres cosas, y las tres explican
por qué el mensaje del bot no se podía tomar como prueba de nada.
1. Los montos se leían con getInt, que solo acepta números JSON. Si el
modelo manda el monto como texto ("290000", "$290.000") devuelve el
valor por defecto: 0. Y con decimales los truncaba. La factura quedaba
creada en cero mientras el bot informaba el monto correcto, porque el
bot repite lo que dijo el usuario, no lo que se guardó. Ahora hay un
getFloat que acepta número o texto y limpia $ , y espacios.
2. Se registraba la llamada a la herramienta pero no su resultado, así que
no había forma de saber si guardó o devolvió error. Ahora se registra
también el resultado: es la única fuente confiable de qué pasó, porque
el mensaje del bot lo escribe el modelo.
3. El prompt no prohibía informar éxito sin haberlo verificado. Ahora se
le exige no decir que guardó algo si la herramienta devolvió error, y
confirmar con los valores que devolvió la herramienta y no con los que
dijo el usuario.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Error en producción al crear un tenant:
null value in column "site_key" of relation "umind_tenants"
violates not-null constraint (SQLSTATE 23502)
Es un bug que introduje yo. Al pasar uMind a multi-agente saqué SiteKey de
UmindTenant y renombré TenantID→AgenteID en seis tablas. GORM agrega
columnas pero nunca las borra ni les cambia las restricciones, así que las
viejas quedaron en la base CON su NOT NULL original — y el INSERT nuevo ya
no las incluye.
Al mirarlo, el alcance era mayor que el error reportado: no es solo
site_key. Las seis tablas renombradas tienen su tenant_id huérfano también
NOT NULL, así que fallaba insertar documentos, chunks, mensajes, tools,
canales y conexiones. En la práctica uMind quedaba inutilizable después de
desplegar el refactor: ni crear un tenant, ni ingestar conocimiento, ni
guardar un mensaje del chat.
La migración corre en cada arranque, antes de MigrarUmindAgentes, y
consulta information_schema para no intentar el ALTER a ciegas en una
instalación nueva donde la columna no existe.
No se hace DROP COLUMN a propósito: los datos viejos quedan por si hay que
reconciliar algo. Solo se libera la restricción.
El test compara los nombres de tabla contra el TableName() real de cada
modelo. Un typo ahí haría que la migración no encuentre la columna y siga
de largo: el bug seguiría vivo y el arranque se vería sano.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cuatro cosas que ya estaban a medio construir y no se usaban como canal.
1. El badge del widget ahora es un enlace con UTM. Cada cliente ya te
estaba dando exposición en su sitio y no se capitalizaba. El host sale
de la URL del propio script, así funciona igual en cualquier entorno.
2. Reporte del mes por agente en Excel: conversaciones atendidas, con qué
las abrió el visitante, y el consumo desglosado. Es lo que el cliente
necesita para justificar el gasto puertas adentro — un panel al que hay
que entrar no sirve para eso, un archivo que se reenvía sí. Reusa el
generador de xlsx del cronograma.
3. Aviso automático de agentes sin base de conocimiento (cron diario). Un
agente sin fuentes responde de memoria e inventa datos, el cliente
concluye que el producto no sirve y se va. Es el punto donde más gente
se cae y se detecta solo. Se avisa UNA vez por agente, usando el log de
auditoría como registro de envío para no necesitar tabla nueva ni
convertir el recordatorio en spam.
4. Tarjeta de uMind en el dashboard del portal: atajo si el cliente ya lo
tiene, oferta si no. Es el punto de contacto más barato que hay — ya
entró, ya confía, y el cobro se suma a la factura que ya recibe. El
mensaje lidera con notas de voz y fotos, que es lo más difícil de
copiar de lo que tenemos.
La consulta de conversaciones agrupa en SQL en vez de traerse el historial
entero para agrupar en Go.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
El cliente ya veía el roadmap en pantalla pero no tenía cómo pasárselo a
alguien que no entra al portal.
El .xlsx se genera con archive/zip + encoding/xml de la stdlib en vez de
sumar una dependencia de Excel para una sola pantalla. Un CSV renombrado
no servía: en Excel en español el separador y los acentos se rompen, y
esto es un archivo que el cliente le entrega a un tercero.
Detalles que hacen que Excel lo acepte y se lea bien: inline strings (sin
tabla sharedStrings, así ningún índice puede apuntar a la cadena
equivocada), encabezado en negrita y congelado, anchos de columna según
el contenido, y nombre de hoja saneado (Excel rechaza el archivo si pasa
de 31 caracteres o trae : \ / ? * [ ]).
El endpoint repite el chequeo de acceso de PortalProyecto: sin eso,
cualquiera con sesión de portal se bajaba el cronograma de otro cliente
adivinando el slug.
Verificado abriendo el archivo generado con openpyxl: 6 partes, CRC OK,
XML bien formado en todas, acentos y CJK intactos, negrita y panel
congelado donde corresponde.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
El servicio devolvía la transcripción correcta y la reportábamos como
error: "respuesta inesperada del servicio de transcripción: <la
transcripción completa>". La causa es que mandábamos response_format=json,
que es la convención de la API de OpenAI — whisper-asr-webservice usa el
query param `output`, así que ignoró el campo y respondió en su formato
por defecto (txt), y el parser exigía JSON.
- textoDeRespuestaWhisper acepta las dos formas (JSON {"text":...} o texto
pelado) en vez de adivinar qué variante corre del otro lado. Con test:
8 casos, incluidos JSON sin campo text y un texto que empieza con "{".
- Whisper y OCR: la caja de resultado ahora muestra el conteo de
caracteres, scrollea si es largo, y tiene Copiar y Descargar .txt (Blob
+ <a download>, el texto ya está en el navegador).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
El agente estaba dando https://escuelametropolitana.com/login.php, una
ruta que no existe: el prompt decía "no inventes datos" pero no decía
nada de URLs, teléfonos ni correos, que es justo lo que un modelo
confabula con más confianza porque "sabe" que un login suele estar en
/login.php. Un enlace inventado deja a la persona sin poder completar el
trámite, peor que no responder.
- Regla explícita: URLs, teléfonos, WhatsApp y correos solo si aparecen
LITERALMENTE en los resultados de buscar_conocimiento, copiados carácter
por carácter, sin completar rutas ni cambiar el dominio.
- Widget: los enlaces se vuelven clickeables. El contenido sale de páginas
crawleadas, así que NO se inserta como HTML: se parte por URL y cada
trozo va con createTextNode o un <a> con rel=noopener noreferrer
nofollow. Verificado que un <img onerror> o un <script> en la respuesta
quedan escapados como texto inerte.
- WhatsApp: preview_url=true, así el primer enlace se ve como tarjeta
clickeable en vez de texto pelado.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
El cobro recurrente (link de pago, webhook con firma, renovación
automática de vencimiento) ya funcionaba de punta a punta; lo único que
le faltaba para cobrar por uso era monto variable.
- MontoACobrar = PrecioAcordado + consumo pendiente del cliente. Si el
cliente no usa uMind el consumo es 0 y el monto queda idéntico al de
antes, así que los contratos existentes no cambian.
- El consumo se agrega por Cliente, no por tenant: un contrato es de un
cliente y un cliente puede tener varios tenants.
- El desglose (cuántos tokens, imágenes y transcripciones, y a cuánto)
va como líneas de servicio, así las plantillas de correo existentes lo
muestran sin tocarlas. Cobrar un monto variable sin decir de dónde sale
es pedir una disputa.
- Al confirmarse el pago se cierra el consumo (facturado_at). Solo si el
UPDATE del contrato afectó filas: si el webhook llega dos veces, la
segunda no vuelve a cerrar nada.
- Test de que el detalle del correo suma exactamente lo que se cobra.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Es la base del cobro por uso: hasta ahora no había ninguna medición de
consumo en todo el repo.
- UmindUso registra cada evento facturable con el costo YA calculado al
precio vigente del plan. Congelarlo evita que subir un precio revalúe
consumo pasado, que haría indefendible una factura ante un reclamo.
- callAI devuelve los tokens que reportó el proveedor (campo usage, igual
en todos los OpenAI-compatibles; input+output en Anthropic). Se mide
cada ronda de tool-calling, no solo la última: todas gastan tokens.
- ExtraerTextoOCR y TranscribirAudioSelfHosted reciben agenteID; 0 = no
medir, que es lo que pasan los botones "Probar" del panel de staff.
- Aviso al superar el tope del plan, una vez por mes y sin cortar el
servicio. El flag de "ya avisé" es en memoria a propósito.
- GET /app/umind/uso con filtros de fecha: resumen por tipo + detalle.
- Test del cálculo de costo por tipo, incluida fracción de 1k tokens y
tenant sin plan.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Prepara el terreno para que el cliente cargue su propia cuenta de IA sin
que su clave quede en texto plano ni que el selector le muestre las de
los demás.
- ClaveEnClaro() descifra con fallback a texto plano: las filas viejas se
leen igual y quedan cifradas al primer guardado, sin script ni downtime.
utils.Decrypt hace panic (no devuelve error) con entrada que no es un
ciphertext válido, así que el fallback va sobre recover — eso mismo es
el mecanismo de detección de "todavía está en claro".
- Migrados TODOS los lectores: uMind (chat y embeddings), bot de Telegram,
Whisper, Query Runner, streaming de IA y Landing Generator. Un lector
sin migrar mandaría el ciphertext como API key.
- AiConfig gana TenantID (null = global del staff) y
GetAiConfigSelectPorTenants para acotar el selector.
- Test de los 4 casos del fallback, incluido hex válido que no descifra.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cada canal (Telegram/WhatsApp) de un agente ahora puede activar, de forma
independiente, que los audios entrantes se transcriban con el Whisper ASR
propio y que a las imágenes entrantes se les extraiga texto con el
servicio OCR propio, antes de pasarle el mensaje al agente. Antes esos
mensajes se ignoraban en silencio.
- UmindCanal gana usar_whisper_audio/usar_ocr_imagenes (default off).
- WhatsApp: se descarga el media vía Graph API (resolución de URL + fetch
con el mismo access_token del canal) y se enruta a Whisper/OCR según type.
- Telegram: se descarga el archivo vía getFile + CDN de archivos del bot,
mismo enrutamiento para voice/audio/photo.
- Panel: checkboxes en el alta de canal y toggles inline por canal ya
creado, en la tab Canales del agente.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Nuevos submódulos en Integraciones para conectar el servicio propio de
OCR (extracción de texto de imágenes) y el de transcripción de audio
self-hosted (whisper-asr-webservice, Basic Auth), con panel de
configuración y prueba en vivo, siguiendo el patrón ya usado por
WebSMS/Hostinger.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Nueva tabla umind_eventos_log + tab "Auditoría" en el panel: errores y
eventos que hasta ahora solo se podían ver pidiendo los logs del servidor
(fallos al llamar al AI, respuestas sin texto usable, tools/webhooks que
fallan, tokens de correo que no se pueden refrescar, firmas de WhatsApp
inválidas, errores procesando mensajes de Telegram/WhatsApp) quedan
registrados por agente, visibles directo en el panel con el JSON crudo
expandible para diagnosticar sin acceso al servidor.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Una sesión de widget que pegó contra un bug ya arreglado (ej. el
thought_signature de Gemini) podía quedar con un turno "assistant" vacío
guardado en umind_mensajes — como el widget mantiene la misma session_id en
sessionStorage mientras no se cierra la pestaña, cada mensaje nuevo
reenviaba ese historial roto y el modelo volvía a responder vacío en
cascada. El chat de prueba del panel no lo sufría porque arranca session_id
nueva en cada carga de página.
Dos cambios: no se guarda un mensaje "assistant" con contenido vacío de acá
en más, y al armar el prompt se descartan los que ya hayan quedado guardados
así — una sesión vieja rota se autorepara sola en el próximo mensaje, sin
que el visitante tenga que abrir una pestaña nueva.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cuando el AI respondía sin error pero con un content que no era string
(o vacío), ProcessWidgetMessage caía en silencio al mensaje genérico de
"dame más detalle" sin dejar ningún rastro en el log — no había forma de
saber qué pasó. Ahora loguea el JSON crudo (agentMessage.Raw) en ese caso,
y también cuando se agotan las 3 rondas de tool-calling sin llegar a una
respuesta final.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Un tenant (negocio/sitio, dueño de los dominios permitidos) puede tener
varios UmindAgente independientes (ej. "Ventas", "Soporte"), cada uno con
su propia config de IA, tono, base de conocimiento, tools, canales y
conexión de correo. El site_key también pasa a ser por agente, así cada
uno tiene su propio <script> de widget embebible y su propio color.
Backend:
- Nuevo modelo UmindAgente (pkg/models/umind_agente.go), con SiteKey,
AiConfigID, Tono, MensajeBienvenida y Color — campos que antes vivían en
UmindTenant y se sacan de ahí (las columnas viejas quedan huérfanas sin
usar, no se hace DROP COLUMN).
- UmindDocumento, UmindChunk, UmindHerramienta, UmindCanal, UmindConexion y
UmindMensaje pasan de TenantID a AgenteID. El campo se agrega sin
"not null" para no romper el ALTER TABLE en Postgres sobre tablas que ya
tienen filas (ej. emetropolitana).
- migrations.MigrarUmindAgentes(): idempotente, crea un agente "Principal"
por cada tenant existente heredando lo que ya tenía configurado, y mueve
sus datos de tenant_id a agente_id. Corre en cada arranque normal, mismo
criterio que los Seed* — nada se rompe para los tenants ya en producción.
- Motor del agente, widget, canales (Telegram/WhatsApp) y OAuth de correo
ahora operan sobre UmindAgente; el tenant solo se consulta para el
chequeo de dominio permitido y el nombre del negocio que ve el visitante.
Frontend: nueva jerarquía de navegación tenant → lista de agentes
(TenantAgentes.vue) → detalle de un agente (AgenteDetail.vue, antes
TenantDetail.vue) con las mismas 6 tabs de siempre, ahora por agente. El
modal de tenant en el sidebar se achica a nombre/dominios/activo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Gemini (modelos "thinking" como 2.5) exige que el tool_call se reenvíe con
su thought_signature intacto en la ronda siguiente, o rechaza con 400
"Function call is missing a thought_signature". Nuestro parseo a
agentMessage/agentToolCall no conocía ese campo y lo descartaba en el
unmarshal, así que cualquier tool call con Gemini fallaba apenas el modelo
pedía usar una herramienta (buscar_conocimiento incluida).
agentMessage ahora guarda los bytes JSON originales cuando se parsea de una
respuesta (UnmarshalJSON) y los reenvía tal cual al volver a serializar
(MarshalJSON) — preserva thought_signature u otro campo propietario que
llegue, sin que el código necesite conocer su nombre/forma exacta. Los
mensajes que armamos nosotros (user/tool/system) no llevan Raw y siguen
serializando normal. Afecta tanto a uMind como al agente de Telegram, que
comparten este mismo motor.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ese historial se reenvía completo en cada turno junto con el system prompt
y las tools — con Gemini como proveedor principal, sin acceso fácil a
caching de contexto (su API de caching no pasa por la capa de
compatibilidad OpenAI que usamos), la forma más directa de bajar el costo
por mensaje en charlas largas es mandar menos historial repetido.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
El fix anterior de Gemini (22efb9c) solo tocó providerDefaultURL, que usan
el chat/embeddings/whisper reales. TestAiConfigHandler (el botón "Probar
conexión" del panel de AI Config) tenía una tercera copia independiente del
mismo mapeo, sin caso para Gemini ni Deepseek — por eso el chat ya
funcionaba con Gemini pero la prueba de conexión seguía fallando.
Se exporta providerDefaultURL a services.ProviderDefaultURL y el controller
la reusa, en vez de mantener una copia más que se desactualiza cada vez que
se agrega un proveedor.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- providerDefaultURL no tenía caso para "gemini": sin Base URL manual caía
al default (la URL de OpenAI) y fallaba siempre con la API key de Google.
Se agrega la capa de compatibilidad OpenAI de Gemini
(generativelanguage.googleapis.com/v1beta/openai).
- El widget muestra el texto del agente tal cual (textContent, no innerHTML
— deliberado, evita XSS si algo del contenido crawleado se cuela en una
respuesta). El modelo respondía con **negrita** y listas en Markdown que
se veían como asteriscos/guiones sueltos en vez de renderizarse. Se
instruye al system prompt a no usar Markdown, en vez de sumar un parser
al widget. Aplica a los tres canales (widget, Telegram, WhatsApp) y al
chat de prueba del panel, porque todos comparten el mismo prompt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CrawlearSitio rechazaba cualquier Content-Type que no contuviera
"text/html", así que una URL como base-conocimiento.md (text/plain, pensada
explícitamente para que la consuma una IA) fallaba con "no se pudo extraer
texto de ninguna página" pese a tener contenido perfectamente válido. Ahora
acepta text/plain, text/markdown, o cualquier URL terminada en .md, usando
el body tal cual sin pasarlo por el parser de HTML.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Backend:
- UmindConexion: cuenta de correo conectada por tenant vía OAuth2
(golang.org/x/oauth2, promovida de indirecta a directa), tokens cifrados
en reposo con el mismo AES-GCM+APP_KEY que ya usan tools/canales.
- Flujo completo: /app/umind/conexiones/conectar redirige a Google/Microsoft,
/callback/:proveedor intercambia el code (state autoverificable por HMAC,
sin tabla de estados pendientes), refresh on-demand antes de cada uso.
- Dos tools nuevas para el agente (enviar_correo/leer_bandeja) que aparecen
solo si el tenant tiene una conexión activa, vía Gmail API / Microsoft
Graph directo (sin el SDK pesado de Google).
- Requiere que el dueño del proyecto cree las apps OAuth en Google Cloud
Console / Azure y cargue GOOGLE_OAUTH_CLIENT_ID/SECRET y
MS_OAUTH_CLIENT_ID/SECRET — sin eso los botones de conectar fallan con un
mensaje claro, no en silencio.
Frontend: rediseño del orquestador — layout de sidebar fijo (reemplaza el
navbar + lista de página completa), modo oscuro vía prefers-color-scheme,
tabs en pill, y la nueva tab "Conexiones".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SPA nueva en /orchestrator (Vue 3 + Vite, servida por el mismo binario Go
bajo /orchestrator para que la cookie de sesión funcione sin tocar CORS),
reemplaza al panel Alpine.js como punto de entrada del menú.
Backend, todo aditivo sobre el motor de uMind ya existente:
- UmindHerramienta: tools custom por tenant que llaman un webhook HTTP,
integradas al loop de function-calling existente. Cliente HTTP con
guardas SSRF (bloqueo de IPs privadas/loopback/link-local resuelto en el
momento de conectar, no antes, para cerrar la ventana de DNS rebinding)
que no existían en el proyecto.
- UmindCanal: Telegram y WhatsApp Business Cloud API como canales
adicionales del mismo agente que ya atiende el widget web, ambos
reusando ProcessWidgetMessage. WhatsApp valida X-Hub-Signature-256.
Credenciales cifradas en reposo con el mismo AES-GCM+APP_KEY que ya usa
el proyecto para la contraseña SMTP (primer uso para secretos de uMind).
- Se conecta middlewares.Limit() (rate limiter que existía pero no se
usaba en ningún lado) al widget público y a los webhooks nuevos.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- GetSchemaRelations: trae todas las llaves foráneas de la base con
consulta específica por motor (Postgres/MySQL/MSSQL/SQLite). No
aplica a Redis/Mongo (schemaless).
- Endpoint GET /query-runner/relations, con el mismo chequeo de
autorización por conexión que ya usa RunQuery — de paso se lo
agrego también a GetDatabases/GetTables, que no lo tenían.
- UI: botón "Ver relaciones" (diagrama completo) y un ícono por tabla
en el árbol lateral (relaciones solo de esa tabla), renderizado con
Mermaid.js servido localmente (public/js/mermaid.min.js, sin CDN).
- El bot solo tenía listar_tareas (resumen: título/estado/prioridad),
sin forma de pedir la descripción completa ni el historial de
comentarios de una tarea puntual. Nuevo tool detalle_tarea trae la
tarea completa + comentarios (autor, contenido, adjuntos, fecha).
- UMIND_CASOS_DE_USO.html: documento comercial con casos de uso por
tipo de negocio, argumentos de venta y límites actuales del
producto, para ofrecer uMind a clientes.
Multi-tenant dentro de soft_usite, reutilizando la infraestructura ya
existente (AiConfig, motor de function-calling del agente de Telegram)
en vez de un servicio nuevo aparte:
- UmindTenant: sitio/cliente con dominios permitidos, config de IA para
el chat y personalidad/tono.
- Ingesta: crawler simple (mismo dominio, N páginas) + chunking +
embeddings (config global con módulo "umind_embeddings", pensada
para OpenAI ya que Claude no ofrece embeddings) guardados como JSON,
con búsqueda por similitud coseno en memoria (sin pgvector todavía).
- Agente acotado: única herramienta buscar_conocimiento, sin acceso a
nada interno — si no encuentra la respuesta, lo dice en vez de
inventar.
- Widget público (/widget/umind.js + /widget/:site_key/*), autenticado
por site_key + validación de dominio (Origin/Referer), no por
secreto, ya que la key viaja en el HTML público del sitio instalado.
- Panel /app/umind: tenants, estado de ingesta, historial de
conversaciones por sesión.
- AutoMigrate migraba ~50 modelos en un solo llamado que se detiene en
el primer error, dejando todo lo posterior (plantillas_documento,
tarifas, documentos_generados, arquitecturas, telegram_staff_tokens)
sin tabla, en silencio. Ahora se migra modelo por modelo: uno roto
ya no bloquea al resto.
- /app/contabilidad/entidades pedía datos a la misma URL de la página
(devuelve HTML) en vez de /list (JSON) — el listado nunca traía nada.
- Mensaje de bienvenida del asistente: estaba generado por la IA cada
vez con calidad variable; ahora es determinístico para /ayuda y
/start, con redacción revisada, y el system prompt pide el mismo
formato cuando el usuario solo saluda.
Un archivo adjunto que no fuera PDF o imagen se mandaba igual como
bloque "image" a la API de Claude, que la rechaza (400) y tumbaba toda
la respuesta del agente. Ahora solo se intenta previsualizar tipos que
Claude soporta (PDF/jpeg/png/webp/gif); cualquier otro formato se
guarda igual con adjuntar_documento_tarea/proyecto/factura, solo que
la IA no puede leer su contenido, únicamente el nombre.
Permite emitir credenciales (token hasheado + IP/CIDR opcional) desde
/app/pagos-externos para que aplicaciones de terceros pidan cobros a
través de Bold/dLocal/PayPal sin acceso a nada más del sistema:
- POST /api/v1/pagos-externos/solicitar genera el link de cobro real
usando solo las pasarelas habilitadas para ese servicio.
- Los webhooks existentes de Bold/dLocal/PayPal (firma obligatoria,
idempotentes) ahora también resuelven referencias "extpay-…" sin
tocar el flujo de contratos ("contrato-{id}").
- Al confirmarse el pago se notifica por webhook firmado (HMAC) y/o
Telegram, configurable por servicio.
- CRUD de servicios protegido con SoloAdmin; token y callback_secret
solo se muestran una vez, en DB se guardan hasheados.
Antes solo facturas y documentos de proyecto consumían el adjunto
pendiente de Telegram; crear_tarea/asignar_tarea lo ignoraban. Agrega
adjuntar_documento_tarea: si el usuario manda un archivo junto con una
instrucción de tarea ("asigna esto a Natalia"), el agente crea/resuelve
la tarea y guarda el archivo como comentario adjunto, visible igual que
un adjunto subido desde /app/tareas.
- Nueva config "whisper" reutilizando /app/ai-config (credenciales/endpoint
validables) y servicio de transcripción compatible con OpenAI/Groq.
- El webhook de Telegram ahora detecta notas de voz/audio, las transcribe
y procesa el texto resultante como si el usuario lo hubiera escrito.
- Fix: subir un archivo adjunto en un comentario de tarea sin escribir
texto era rechazado por el backend ("contenido requerido") aunque el
frontend sí lo permitía.
- El agente creaba tareas con estado='pendiente' (default), pero el tablero
Kanban del dashboard solo reconoce por_hacer/en_progreso/revision/hecho. La
tarea se guardaba bien (por eso llegaba el correo de notificación) pero no
aparecía en ninguna columna. Se corrigen los enums y el default de las tools
crear_tarea/actualizar_estado_tarea, y se agrega una reparación única al
arranque que corrige las tareas ya creadas con el estado inválido.
- adjuntar_factura siempre asumía factura de VENTA (ligada a un cliente). Se
agrega adjuntar_factura_compra: cuando un proveedor le factura a U-SITE (no
al revés), busca/crea la Entidad proveedor y registra una cuenta por pagar
con el documento adjunto (se agregan campos archivo/original_name/tipo_mime
a CuentaPagar, que no los tenía). El prompt del sistema instruye a Claude a
decidir la dirección leyendo quién emite y quién recibe el documento.
De paso: endpoint de descarga del soporte de la factura de compra, expuesto
también en el dashboard (/app/contabilidad/cuentas-pagar).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seguridad (crítico):
- Los webhooks de Bold y dLocal solo validaban la firma si el atacante la
enviaba: sin cabecera se aceptaba cualquier payload. Ahora es obligatoria.
- GET /pago-exitoso marcaba contratos como pagados leyendo un query param del
navegador. Ahora solo muestra estado; la confirmación la hace la verificación
contra la API de la pasarela o el webhook firmado.
- /uploads se servía como estático público: se descargaban RUTs, facturas y
entregables sabiendo la ruta. Ahora exige sesión.
- Los secretos JWT no se podían sobreescribir por entorno (faltaba el tag env:)
y su valor estaba en el repo, permitiendo firmarse una sesión de admin. Ahora
son configurables y el arranque se detiene si siguen con el valor publicado.
- .env y session.db salen del control de versiones.
- Query Runner, gestión de usuarios/roles/módulos y seeds quedan restringidos a
administradores; antes bastaba con tener sesión.
Pasarelas de pago:
- dLocal generaba enlaces que nunca se reconciliaban: mandaba el ID numérico en
vez de "contrato-N", la URL de retorno apuntaba a la API de dLocal y nunca se
enviaba notification_url, así que su webhook jamás se disparaba.
- PayPal solo tenía pantalla de configuración. Se implementa el servicio
completo (OAuth, orden, captura, verificación de webhook) y queda
seleccionable como pasarela.
- La moneda estaba fija en COP: un contrato en USD generaba un cobro por esa
cifra en pesos.
Contratos:
- pago_confirmado nunca volvía a false, así que el segundo ciclo de renovación
no se cobraba aunque el cliente pagara. Se reinicia al generar enlace nuevo.
- Los contratos vencidos nunca cambiaban de estado y recibían correo a diario
de forma indefinida; ahora se cierran tras 30 días de gracia.
Otros:
- Coolify: coolifyCall ignoraba el status HTTP y reportaba errores como éxito.
El agente pasa de 10 a cobertura completa (servicios, bases de datos,
variables de entorno, proyectos, equipos y recursos de servidor).
- SeedBalanceData ya no corre en cada arranque (recreaba transacciones
borradas); ahora se invoca con SEED_BALANCE=1.
- Los seeds dejan de devolver permisos revocados en cada despliegue.
- Timeouts en las llamadas HTTP a Telegram y dLocal que podían colgarse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Antes la whitelist de telegram_agent_auth solo se podía tocar con curl/Postman
contra /api/v2/agent/auth. Se agrega /app/agente/chats-autorizados con un
CRUD simple, más un botón "buscar mensajes recientes" (UpdatesRecientesDelBot,
vía getUpdates) para descubrir el chat_id de alguien que le acaba de escribir
al bot sin tener que pedírselo por otro medio. Entrada nueva en el menú lateral
del módulo Automatización IA.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
El bot manda mensajes con parse_mode=HTML, pero la IA responde en Markdown
(##, **negrita**, listas con "-", separadores ---), que Telegram no
interpreta — salía todo literal con los símbolos. Ahora FormatearParaTelegram
convierte el Markdown a las etiquetas que Telegram sí soporta (b, code, pre,
a) justo antes de enviar, escapando cualquier < > & literal para que
sendMessage tampoco falle por HTML inválido. Se ajustó el único lugar que ya
armaba HTML a mano (/instancias) para que use el mismo formato Markdown y
pase por el mismo conversor. Incluye tests con el caso real reportado.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- crear_fase_proyecto: agrega fases/etapas a un proyecto (se puede llamar
varias veces seguidas para cargar todas las fases de un proyecto nuevo).
- adjuntar_documento_proyecto: guarda el archivo que el usuario acaba de
enviar por Telegram como documento del proyecto (mismo mecanismo de
adjuntos ya usado para facturas — se generalizó TelegramAttachment para
servir a ambos casos).
- listar_usuarios + asignado_id en crear_tarea + asignar_tarea: permite
asignar tareas a alguien del equipo por Telegram, disparando la misma
notificación (Telegram/email/sistema) que ya dispara el dashboard.
- El prompt del sistema ahora indica cómo decidir entre adjuntar_factura y
adjuntar_documento_proyecto según el contexto del archivo recibido.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Antes solo se avisaba en texto "llegó un documento" — la IA nunca veía el
archivo, por eso siempre tenía que preguntar cliente y monto. Ahora, cuando
el proveedor activo es Anthropic, se le adjunta la imagen/PDF real (base64)
al mensaje para que Claude lo lea directamente y extraiga cliente, monto y
número de factura, llamando a adjuntar_factura sin pedir confirmación salvo
que de verdad no pueda determinar cliente o monto.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
El bot solo procesaba mensajes de texto: un documento o foto enviado al chat
se ignoraba por completo (Telegram lo manda en message.document/photo con
caption, no en el campo text). Ahora el webhook detecta el adjunto, lo
descarga desde la API de Telegram y lo deja pendiente para el chat; el agente
usa la nueva tool adjuntar_factura para asociarlo a un cliente (preguntando
cliente/monto si el caption no los trae) y crear la Factura con el archivo ya
vinculado, igual que si se subiera desde el dashboard.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- soporte: el webhook de correo entrante era público sin ninguna validación;
ahora exige una API key (query ?key= o header) comparada en tiempo constante.
Además evita tickets duplicados por reintentos del proveedor (dedup por
Message-Id) y enhebra respuestas del mismo remitente en vez de abrir un
ticket nuevo por cada correo.
- contabilidad: marcar una cuenta por cobrar/pagar como pagada ahora crea y
vincula la Transaccion correspondiente (antes el dashboard de ingresos/
egresos nunca reflejaba esos pagos). Se corrige además que actualizar una
cuenta por cobrar borraba su transaccion_id en cada PUT.
- tareas: se activa por defecto el canal Telegram para tarea_asignada (estaba
apagado desde el seed original) y se agrega un flujo real de vinculación de
Telegram para el staff interno (código temporal + verificación), igual al
que ya existía para los usuarios del portal — sin esto el chat_id de cada
usuario había que pegarlo a mano y la notificación nunca llegaba.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implementa las 4 fases de la especificación de automatización: módulo de
plantillas/tarifas editable por el equipo, generación de PDF (HTML+JS vía
Chrome headless) para cotizaciones/contratos/arquitecturas/cuentas de cobro,
chat propio en el dashboard reutilizando el mismo motor y tools del bot de
Telegram, y nuevas tools del agente para crear estos documentos end-to-end.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>