feat(umind): los agentes también leen archivos, no solo audios e imágenes

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>
This commit is contained in:
Lizandro Guarnizo
2026-08-17 18:50:59 -05:00
co-authored by Claude Opus 5
parent e681f41639
commit c4b0305112
14 changed files with 384 additions and 124 deletions
+3
View File
@@ -33,6 +33,9 @@ type UmindCanal struct {
// antes de pasarlos al agente, en vez de ignorarse.
UsarWhisperAudio bool `json:"usar_whisper_audio" gorm:"column:usar_whisper_audio;default:false"`
UsarOcrImagenes bool `json:"usar_ocr_imagenes" gorm:"column:usar_ocr_imagenes;default:false"`
// Documentos adjuntos (PDF, Word, texto): se les extrae el contenido y se
// le pasa al agente como si el cliente lo hubiera escrito.
UsarArchivosDocs bool `json:"usar_archivos_docs" gorm:"column:usar_archivos_docs;default:false"`
}
func (UmindCanal) TableName() string { return "umind_canales" }
+193
View File
@@ -0,0 +1,193 @@
package services
import (
"archive/zip"
"bytes"
"compress/zlib"
"fmt"
"io"
"path/filepath"
"regexp"
"strings"
"unicode"
)
// ExtraerTextoDeArchivo saca el texto de un archivo cualquiera para dárselo al
// agente. Es el equivalente de Whisper para audio y OCR para imágenes, pero
// para documentos.
//
// agenteID identifica a quién cobrarle si hace falta pasar por OCR; 0 = no medir.
func ExtraerTextoDeArchivo(agenteID uint, nombreArchivo string, datos []byte) (string, error) {
ext := strings.ToLower(filepath.Ext(nombreArchivo))
switch ext {
case ".txt", ".md", ".csv", ".json", ".xml", ".log", ".html", ".htm":
return string(datos), nil
case ".docx":
return textoDeDocx(datos)
case ".pdf":
return textoDePDF(agenteID, datos)
case ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff":
return ExtraerTextoOCR(agenteID, datos, mimeDeImagen(ext))
default:
return "", fmt.Errorf("no sé leer archivos %s; probá con PDF, Word (.docx), texto o una imagen", ext)
}
}
func mimeDeImagen(ext string) string {
switch ext {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".tif", ".tiff":
return "image/tiff"
default:
return "image/" + strings.TrimPrefix(ext, ".")
}
}
var etiquetaXML = regexp.MustCompile(`<[^>]+>`)
// textoDeDocx lee word/document.xml del .docx y lo aplana a texto. No pretende
// conservar el formato: el agente solo necesita el contenido y el orden.
func textoDeDocx(datos []byte) (string, error) {
zr, err := zip.NewReader(bytes.NewReader(datos), int64(len(datos)))
if err != nil {
return "", fmt.Errorf("el .docx no se pudo abrir: %w", err)
}
for _, f := range zr.File {
if f.Name != "word/document.xml" {
continue
}
rc, err := f.Open()
if err != nil {
return "", err
}
defer rc.Close()
xmlBytes, err := io.ReadAll(io.LimitReader(rc, 8<<20))
if err != nil {
return "", err
}
s := string(xmlBytes)
// Un párrafo, un salto de línea explícito y un fin de fila valen como
// salto; las celdas se separan con tab. El resto de etiquetas se tira.
s = strings.NewReplacer("</w:p>", "\n", "<w:br/>", "\n", "</w:tr>", "\n", "</w:tc>", "\t").Replace(s)
s = etiquetaXML.ReplaceAllString(s, "")
s = strings.NewReplacer("&amp;", "&", "&lt;", "<", "&gt;", ">", "&quot;", `"`, "&apos;", "'").Replace(s)
return strings.TrimSpace(s), nil
}
return "", fmt.Errorf("el archivo no parece un .docx (no tiene word/document.xml)")
}
// textoDePDF saca el texto de un PDF digital (facturas, cotizaciones, cualquier
// cosa exportada por un programa) leyendo los operadores de texto de sus
// streams. Si el PDF es un escaneo no hay texto que leer, y ahí cae al OCR.
//
// ponytail: parser mínimo — entiende streams FlateDecode y los operadores Tj/TJ,
// que es lo que usan los PDFs generados por software. No maneja fuentes con
// codificaciones raras ni CID; para esos casos el fallback a OCR es la salida.
func textoDePDF(agenteID uint, datos []byte) (string, error) {
texto := strings.TrimSpace(textoDeStreamsPDF(datos))
// Un PDF escaneado devuelve nada o cuatro letras sueltas de un encabezado.
if len([]rune(texto)) >= 40 {
return texto, nil
}
ocrTexto, err := ExtraerTextoOCR(agenteID, datos, "application/pdf")
if err != nil {
if texto != "" {
return texto, nil
}
return "", fmt.Errorf("el PDF no tiene texto legible y el OCR no pudo procesarlo: %w", err)
}
return ocrTexto, nil
}
var streamRe = regexp.MustCompile(`(?s)stream\r?\n(.*?)endstream`)
func textoDeStreamsPDF(datos []byte) string {
var out strings.Builder
for _, m := range streamRe.FindAllSubmatch(datos, -1) {
crudo := m[1]
contenido := crudo
if zr, err := zlib.NewReader(bytes.NewReader(crudo)); err == nil {
if inflado, err := io.ReadAll(io.LimitReader(zr, 16<<20)); err == nil {
contenido = inflado
}
zr.Close()
}
if !bytes.Contains(contenido, []byte("Tj")) && !bytes.Contains(contenido, []byte("TJ")) {
continue
}
out.WriteString(textoDeContenidoPDF(contenido))
}
return out.String()
}
// textoDeContenidoPDF junta las cadenas entre paréntesis de un content stream,
// que es donde vive el texto visible, y respeta los saltos de línea (T*, TD, Td).
func textoDeContenidoPDF(contenido []byte) string {
var out strings.Builder
for i := 0; i < len(contenido); i++ {
switch contenido[i] {
case '(':
var s strings.Builder
for i++; i < len(contenido); i++ {
c := contenido[i]
if c == '\\' && i+1 < len(contenido) {
i++
switch contenido[i] {
case 'n':
s.WriteByte('\n')
case 't':
s.WriteByte('\t')
case 'r':
default:
s.WriteByte(contenido[i])
}
continue
}
if c == ')' {
break
}
s.WriteByte(c)
}
out.WriteString(s.String())
case 'T':
// T* / Td / TD mueven el cursor a otra línea.
if i+1 < len(contenido) {
switch contenido[i+1] {
case '*', 'd', 'D':
out.WriteByte('\n')
}
}
}
}
return limpiarNoImprimibles(out.String())
}
func limpiarNoImprimibles(s string) string {
return strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' || unicode.IsPrint(r) {
return r
}
return -1
}, s)
}
// TextoDeArchivoParaAgente arma el mensaje que ve el agente cuando alguien le
// manda un documento: el contenido solo, sin contexto, hace que el modelo
// conteste como si el cliente hubiera escrito una factura.
func TextoDeArchivoParaAgente(nombreArchivo, caption, contenido string) string {
if len(contenido) > 30000 {
contenido = contenido[:30000] + "\n…(archivo recortado)"
}
var b strings.Builder
b.WriteString("El cliente adjuntó un archivo")
if nombreArchivo != "" {
b.WriteString(" llamado \"" + nombreArchivo + "\"")
}
b.WriteString(".")
if strings.TrimSpace(caption) != "" {
b.WriteString(" Escribió junto al archivo: " + strings.TrimSpace(caption))
}
b.WriteString("\n\nContenido del archivo:\n---\n" + strings.TrimSpace(contenido) + "\n---")
return b.String()
}
+68
View File
@@ -0,0 +1,68 @@
package services
import (
"bytes"
"compress/zlib"
"strconv"
"strings"
"testing"
)
// pdfDePrueba arma un PDF mínimo con el content stream comprimido, igual que
// los que genera cualquier programa que exporta a PDF.
func pdfDePrueba(t *testing.T, contenido string) []byte {
t.Helper()
var comp bytes.Buffer
zw := zlib.NewWriter(&comp)
if _, err := zw.Write([]byte(contenido)); err != nil {
t.Fatal(err)
}
zw.Close()
var pdf bytes.Buffer
pdf.WriteString("%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n")
pdf.WriteString("2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n")
pdf.WriteString("3 0 obj<</Type/Page/Parent 2 0 R/Contents 4 0 R>>endobj\n")
pdf.WriteString("4 0 obj<</Length " + strconv.Itoa(comp.Len()) + "/Filter/FlateDecode>>stream\n")
pdf.Write(comp.Bytes())
pdf.WriteString("\nendstream endobj\ntrailer<</Root 1 0 R>>\n%%EOF")
return pdf.Bytes()
}
func TestExtraerTextoDeArchivoPDFDigital(t *testing.T) {
contenido := `BT /F1 12 Tf 72 720 Td (FACTURA DE VENTA No. 1042) Tj T* ` +
`(Cliente: Acme SAS NIT 900.123.456-7) Tj T* (Total: \$1.500.000 COP) Tj ET`
got, err := ExtraerTextoDeArchivo(0, "factura.pdf", pdfDePrueba(t, contenido))
if err != nil {
t.Fatalf("ExtraerTextoDeArchivo: %v", err)
}
for _, quiero := range []string{"FACTURA DE VENTA No. 1042", "Acme SAS", "NIT 900.123.456-7", "$1.500.000 COP"} {
if !strings.Contains(got, quiero) {
t.Errorf("falta %q en el texto extraído:\n%s", quiero, got)
}
}
// T* separa renglones: sin eso la factura llega al agente como un chorizo.
if lineas := strings.Count(strings.TrimSpace(got), "\n"); lineas < 2 {
t.Errorf("esperaba al menos 3 renglones, hay %d:\n%s", lineas+1, got)
}
}
func TestExtraerTextoDeArchivoTexto(t *testing.T) {
got, err := ExtraerTextoDeArchivo(0, "notas.txt", []byte("hola\nmundo"))
if err != nil || got != "hola\nmundo" {
t.Errorf("got %q, err %v", got, err)
}
if _, err := ExtraerTextoDeArchivo(0, "cosa.exe", []byte("x")); err == nil {
t.Error("una extensión desconocida debería devolver error")
}
}
func TestTextoDeArchivoParaAgente(t *testing.T) {
got := TextoDeArchivoParaAgente("factura.pdf", "me cobraron de más", "Total: 1000")
for _, quiero := range []string{"factura.pdf", "me cobraron de más", "Total: 1000"} {
if !strings.Contains(got, quiero) {
t.Errorf("falta %q en:\n%s", quiero, got)
}
}
}
+3 -64
View File
@@ -1,12 +1,7 @@
package services
import (
"archive/zip"
"bytes"
"fmt"
"io"
"path/filepath"
"regexp"
"strings"
)
@@ -24,66 +19,10 @@ var variablesPorTipo = map[string]string{
const variablesComunes = `{{.Fecha}}, {{.EmpresaNombre}}, {{.EmpresaWeb}}`
// ExtraerTextoDePlantilla saca el texto de un archivo subido para usarlo como
// referencia. Los formatos de texto se leen directo; el .docx es un zip con XML
// adentro (stdlib alcanza) y las imágenes pasan por OCR.
// ExtraerTextoDePlantilla lee el archivo que subió el admin como referencia.
// agenteID 0: es una acción del staff, no se le cobra a ningún cliente.
func ExtraerTextoDePlantilla(nombreArchivo string, datos []byte) (string, error) {
ext := strings.ToLower(filepath.Ext(nombreArchivo))
switch ext {
case ".html", ".htm", ".txt", ".md":
return string(datos), nil
case ".docx":
return textoDeDocx(datos)
case ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff":
mime := "image/png"
if ext == ".jpg" || ext == ".jpeg" {
mime = "image/jpeg"
} else if ext != ".png" {
mime = "image/" + strings.TrimPrefix(ext, ".")
}
// agenteID 0: es una acción del staff, no se le cobra a ningún cliente.
return ExtraerTextoOCR(0, datos, mime)
case ".pdf":
return "", fmt.Errorf("el PDF todavía no se puede leer acá; exportalo a .docx o subí una captura de pantalla del documento")
default:
return "", fmt.Errorf("formato %s no soportado: subí .docx, .html, .txt o una imagen del documento", ext)
}
}
var etiquetaXML = regexp.MustCompile(`<[^>]+>`)
// textoDeDocx lee word/document.xml del .docx y lo aplana a texto. No pretende
// conservar el formato: la IA solo necesita el contenido y el orden.
func textoDeDocx(datos []byte) (string, error) {
zr, err := zip.NewReader(bytes.NewReader(datos), int64(len(datos)))
if err != nil {
return "", fmt.Errorf("el .docx no se pudo abrir: %w", err)
}
for _, f := range zr.File {
if f.Name != "word/document.xml" {
continue
}
rc, err := f.Open()
if err != nil {
return "", err
}
defer rc.Close()
xmlBytes, err := io.ReadAll(io.LimitReader(rc, 8<<20))
if err != nil {
return "", err
}
s := string(xmlBytes)
// Un párrafo y un salto de línea explícito valen como salto de línea;
// el resto de las etiquetas se descarta.
s = strings.ReplaceAll(s, "</w:p>", "\n")
s = strings.ReplaceAll(s, "<w:br/>", "\n")
s = strings.ReplaceAll(s, "</w:tr>", "\n")
s = strings.ReplaceAll(s, "</w:tc>", "\t")
s = etiquetaXML.ReplaceAllString(s, "")
s = strings.NewReplacer("&amp;", "&", "&lt;", "<", "&gt;", ">", "&quot;", `"`, "&apos;", "'").Replace(s)
return strings.TrimSpace(s), nil
}
return "", fmt.Errorf("el archivo no parece un .docx (no tiene word/document.xml)")
return ExtraerTextoDeArchivo(0, nombreArchivo, datos)
}
// ConvertirEnPlantilla le pide a la IA que rearme el documento como plantilla
-3
View File
@@ -48,9 +48,6 @@ func TestExtraerTextoDePlantillaDocx(t *testing.T) {
}
func TestExtraerTextoDePlantillaFormatoNoSoportado(t *testing.T) {
if _, err := ExtraerTextoDePlantilla("plantilla.pdf", []byte("x")); err == nil {
t.Error("el PDF debería devolver error explicando la alternativa")
}
if _, err := ExtraerTextoDePlantilla("plantilla.xyz", []byte("x")); err == nil {
t.Error("una extensión desconocida debería devolver error")
}
@@ -45,6 +45,12 @@ func ProcesarMensajeTelegramUmind(canal *models.UmindCanal, chatID int64, texto
// getFile, lo pasa por Whisper/OCR y responde igual que un mensaje de texto.
// Si no está habilitada, se ignora en silencio.
func ProcesarMediaTelegramUmind(canal *models.UmindCanal, chatID int64, fileID, tipo string) error {
return ProcesarMediaTelegramUmindConNombre(canal, chatID, fileID, tipo, "", "")
}
// ProcesarMediaTelegramUmindConNombre es la versión completa: los documentos
// traen nombre de archivo y, a veces, un texto que los acompaña (caption).
func ProcesarMediaTelegramUmindConNombre(canal *models.UmindCanal, chatID int64, fileID, tipo, nombreArchivo, caption string) error {
agente, err := models.GetUmindAgenteByID(canal.AgenteID)
if err != nil || !agente.Activo {
return fmt.Errorf("agente no encontrado o inactivo: %w", err)
@@ -84,6 +90,19 @@ func ProcesarMediaTelegramUmind(canal *models.UmindCanal, chatID int64, fileID,
if err != nil {
return err
}
case "document":
if !canal.UsarArchivosDocs {
return nil
}
data, err := descargarArchivoTelegram(botToken, fileID)
if err != nil {
return fmt.Errorf("no se pudo descargar el archivo de Telegram: %w", err)
}
texto, err = ExtraerTextoDeArchivo(canal.AgenteID, nombreArchivo, data)
if err != nil {
return err
}
texto = TextoDeArchivoParaAgente(nombreArchivo, caption, texto)
default:
return nil
}
@@ -58,6 +58,12 @@ func ProcesarMensajeWhatsAppUmind(canal *models.UmindCanal, from, texto string)
// Si la conversión no está habilitada para ese tipo, se ignora en silencio
// (mismo comportamiento de antes de que existiera esta función).
func ProcesarMediaWhatsAppUmind(canal *models.UmindCanal, from, mediaID, tipo string) error {
return ProcesarMediaWhatsAppUmindConNombre(canal, from, mediaID, tipo, "", "")
}
// ProcesarMediaWhatsAppUmindConNombre es la versión completa: los documentos
// traen nombre de archivo y, a veces, un texto que los acompaña (caption).
func ProcesarMediaWhatsAppUmindConNombre(canal *models.UmindCanal, from, mediaID, tipo, nombreArchivo, caption string) error {
agente, err := models.GetUmindAgenteByID(canal.AgenteID)
if err != nil || !agente.Activo {
return fmt.Errorf("agente no encontrado o inactivo: %w", err)
@@ -96,6 +102,19 @@ func ProcesarMediaWhatsAppUmind(canal *models.UmindCanal, from, mediaID, tipo st
if err != nil {
return err
}
case "document":
if !canal.UsarArchivosDocs {
return nil
}
data, _, err := descargarMediaWhatsApp(credenciales["access_token"], mediaID)
if err != nil {
return fmt.Errorf("no se pudo descargar el archivo de WhatsApp: %w", err)
}
texto, err = ExtraerTextoDeArchivo(canal.AgenteID, nombreArchivo, data)
if err != nil {
return err
}
texto = TextoDeArchivoParaAgente(nombreArchivo, caption, texto)
default:
return nil
}