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
+23 -19
View File
@@ -198,7 +198,7 @@ async function copiarWidget() {
function canalVacio() { function canalVacio() {
return { return {
tipo: 'telegram', bot_token: '', phone_number_id: '', access_token: '', app_secret: '', verify_token: '', tipo: 'telegram', bot_token: '', phone_number_id: '', access_token: '', app_secret: '', verify_token: '',
usar_whisper_audio: false, usar_ocr_imagenes: false, usar_whisper_audio: false, usar_ocr_imagenes: false, usar_archivos_docs: false,
} }
} }
@@ -226,6 +226,7 @@ async function guardarCanal() {
await api.post(apiUmind('/umind/canales'), { await api.post(apiUmind('/umind/canales'), {
agente_id: agenteIdNum.value, tipo: canalForm.value.tipo, credenciales, activo: true, agente_id: agenteIdNum.value, tipo: canalForm.value.tipo, credenciales, activo: true,
usar_whisper_audio: canalForm.value.usar_whisper_audio, usar_ocr_imagenes: canalForm.value.usar_ocr_imagenes, usar_whisper_audio: canalForm.value.usar_whisper_audio, usar_ocr_imagenes: canalForm.value.usar_ocr_imagenes,
usar_archivos_docs: canalForm.value.usar_archivos_docs,
}) })
showCanalForm.value = false showCanalForm.value = false
await cargarCanales() await cargarCanales()
@@ -234,29 +235,24 @@ async function guardarCanal() {
} }
} }
async function toggleCanal(c) { // El PUT de canales manda los tres interruptores siempre: si alguno faltara, el
// backend lo tomaría como false y lo apagaría sin que nadie lo pidiera.
async function guardarInterruptores(c, cambios) {
await api.put(apiUmind(`/umind/canales/${c.ID}`), { await api.put(apiUmind(`/umind/canales/${c.ID}`), {
activo: !c.activo, credenciales: {}, activo: c.activo,
usar_whisper_audio: c.usar_whisper_audio, usar_ocr_imagenes: c.usar_ocr_imagenes, credenciales: {},
usar_whisper_audio: c.usar_whisper_audio,
usar_ocr_imagenes: c.usar_ocr_imagenes,
usar_archivos_docs: c.usar_archivos_docs,
...cambios,
}) })
await cargarCanales() await cargarCanales()
} }
async function toggleCanalWhisper(c) { const toggleCanal = (c) => guardarInterruptores(c, { activo: !c.activo })
await api.put(apiUmind(`/umind/canales/${c.ID}`), { const toggleCanalWhisper = (c) => guardarInterruptores(c, { usar_whisper_audio: !c.usar_whisper_audio })
activo: c.activo, credenciales: {}, const toggleCanalOcr = (c) => guardarInterruptores(c, { usar_ocr_imagenes: !c.usar_ocr_imagenes })
usar_whisper_audio: !c.usar_whisper_audio, usar_ocr_imagenes: c.usar_ocr_imagenes, const toggleCanalArchivos = (c) => guardarInterruptores(c, { usar_archivos_docs: !c.usar_archivos_docs })
})
await cargarCanales()
}
async function toggleCanalOcr(c) {
await api.put(apiUmind(`/umind/canales/${c.ID}`), {
activo: c.activo, credenciales: {},
usar_whisper_audio: c.usar_whisper_audio, usar_ocr_imagenes: !c.usar_ocr_imagenes,
})
await cargarCanales()
}
async function eliminarCanal(c) { async function eliminarCanal(c) {
if (!confirm(`¿Eliminar el canal ${c.tipo}?`)) return if (!confirm(`¿Eliminar el canal ${c.tipo}?`)) return
@@ -585,6 +581,10 @@ watch(
<input type="checkbox" :checked="c.usar_ocr_imagenes" @change="toggleCanalOcr(c)" class="rounded border-borde text-brand focus:ring-brand" /> <input type="checkbox" :checked="c.usar_ocr_imagenes" @change="toggleCanalOcr(c)" class="rounded border-borde text-brand focus:ring-brand" />
Leer texto de imágenes (OCR) Leer texto de imágenes (OCR)
</label> </label>
<label class="flex items-center gap-1.5 text-texto cursor-pointer">
<input type="checkbox" :checked="c.usar_archivos_docs" @change="toggleCanalArchivos(c)" class="rounded border-borde text-brand focus:ring-brand" />
Leer archivos adjuntos (PDF, Word, texto)
</label>
</div> </div>
<p class="label mt-1 break-all"> <p class="label mt-1 break-all">
Webhook: <code class="bg-elevado px-1 rounded">{{ c.webhook_url }}</code> Webhook: <code class="bg-elevado px-1 rounded">{{ c.webhook_url }}</code>
@@ -640,6 +640,10 @@ watch(
<input type="checkbox" v-model="canalForm.usar_ocr_imagenes" class="rounded border-borde text-brand focus:ring-brand" /> <input type="checkbox" v-model="canalForm.usar_ocr_imagenes" class="rounded border-borde text-brand focus:ring-brand" />
Leer texto de imágenes con OCR Leer texto de imágenes con OCR
</label> </label>
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
<input type="checkbox" v-model="canalForm.usar_archivos_docs" class="rounded border-borde text-brand focus:ring-brand" />
Leer archivos adjuntos (PDF, Word, texto)
</label>
</div> </div>
<div class="flex justify-end gap-2 pt-2"> <div class="flex justify-end gap-2 pt-2">
<button type="button" class="btn-ghost" @click="showCanalForm = false">Cancelar</button> <button type="button" class="btn-ghost" @click="showCanalForm = false">Cancelar</button>
+3
View File
@@ -33,6 +33,9 @@ type UmindCanal struct {
// antes de pasarlos al agente, en vez de ignorarse. // antes de pasarlos al agente, en vez de ignorarse.
UsarWhisperAudio bool `json:"usar_whisper_audio" gorm:"column:usar_whisper_audio;default:false"` 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"` 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" } 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 package services
import ( import (
"archive/zip"
"bytes"
"fmt" "fmt"
"io"
"path/filepath"
"regexp"
"strings" "strings"
) )
@@ -24,66 +19,10 @@ var variablesPorTipo = map[string]string{
const variablesComunes = `{{.Fecha}}, {{.EmpresaNombre}}, {{.EmpresaWeb}}` const variablesComunes = `{{.Fecha}}, {{.EmpresaNombre}}, {{.EmpresaWeb}}`
// ExtraerTextoDePlantilla saca el texto de un archivo subido para usarlo como // ExtraerTextoDePlantilla lee el archivo que subió el admin como referencia.
// referencia. Los formatos de texto se leen directo; el .docx es un zip con XML // agenteID 0: es una acción del staff, no se le cobra a ningún cliente.
// adentro (stdlib alcanza) y las imágenes pasan por OCR.
func ExtraerTextoDePlantilla(nombreArchivo string, datos []byte) (string, error) { func ExtraerTextoDePlantilla(nombreArchivo string, datos []byte) (string, error) {
ext := strings.ToLower(filepath.Ext(nombreArchivo)) return ExtraerTextoDeArchivo(0, nombreArchivo, datos)
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)")
} }
// ConvertirEnPlantilla le pide a la IA que rearme el documento como plantilla // 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) { 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 { if _, err := ExtraerTextoDePlantilla("plantilla.xyz", []byte("x")); err == nil {
t.Error("una extensión desconocida debería devolver error") 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. // getFile, lo pasa por Whisper/OCR y responde igual que un mensaje de texto.
// Si no está habilitada, se ignora en silencio. // Si no está habilitada, se ignora en silencio.
func ProcesarMediaTelegramUmind(canal *models.UmindCanal, chatID int64, fileID, tipo string) error { 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) agente, err := models.GetUmindAgenteByID(canal.AgenteID)
if err != nil || !agente.Activo { if err != nil || !agente.Activo {
return fmt.Errorf("agente no encontrado o inactivo: %w", err) 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 { if err != nil {
return err 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: default:
return nil 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 // Si la conversión no está habilitada para ese tipo, se ignora en silencio
// (mismo comportamiento de antes de que existiera esta función). // (mismo comportamiento de antes de que existiera esta función).
func ProcesarMediaWhatsAppUmind(canal *models.UmindCanal, from, mediaID, tipo string) error { 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) agente, err := models.GetUmindAgenteByID(canal.AgenteID)
if err != nil || !agente.Activo { if err != nil || !agente.Activo {
return fmt.Errorf("agente no encontrado o inactivo: %w", err) 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 { if err != nil {
return err 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: default:
return nil return nil
} }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>uMind Studio</title> <title>uMind Studio</title>
<script type="module" crossorigin src="/orchestrator/assets/index-CUSjvRBR.js"></script> <script type="module" crossorigin src="/orchestrator/assets/index-CbuoSeqG.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-DUjBJRtl.css"> <link rel="stylesheet" crossorigin href="/orchestrator/assets/index-DUjBJRtl.css">
</head> </head>
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens, <!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
@@ -93,11 +93,11 @@
<div class="mb-4 border border-dashed border-gray-300 rounded p-3 bg-gray-50"> <div class="mb-4 border border-dashed border-gray-300 rounded p-3 bg-gray-50">
<p class="text-xs font-medium text-gray-600 mb-1">¿Ya tenés el documento hecho?</p> <p class="text-xs font-medium text-gray-600 mb-1">¿Ya tenés el documento hecho?</p>
<p class="text-xs text-gray-500 mb-2"> <p class="text-xs text-gray-500 mb-2">
Subí el archivo (.docx, .html, .txt o una foto/captura del documento) y la IA lo devuelve Subí el archivo (PDF, Word .docx, .html, .txt o una foto/captura del documento) y la IA lo devuelve
armado como plantilla, con las variables puestas. Después lo editás acá abajo antes de guardar. armado como plantilla, con las variables puestas. Después lo editás acá abajo antes de guardar.
</p> </p>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<input type="file" x-ref="archivo" accept=".docx,.html,.htm,.txt,.md,image/*" <input type="file" x-ref="archivo" accept=".pdf,.docx,.html,.htm,.txt,.md,image/*"
class="text-xs" /> class="text-xs" />
<button type="button" @click="importar()" :disabled="importando" <button type="button" @click="importar()" :disabled="importando"
class="px-3 py-1.5 border rounded text-xs disabled:opacity-50"> class="px-3 py-1.5 border rounded text-xs disabled:opacity-50">
@@ -16,15 +16,18 @@ type umindTgChat struct {
} }
type umindTgFileRef struct { type umindTgFileRef struct {
FileID string `json:"file_id"` FileID string `json:"file_id"`
FileName string `json:"file_name"` // solo lo traen los documentos
} }
type umindTgMessage struct { type umindTgMessage struct {
Chat umindTgChat `json:"chat"` Chat umindTgChat `json:"chat"`
Text string `json:"text"` Text string `json:"text"`
Voice *umindTgFileRef `json:"voice"` Voice *umindTgFileRef `json:"voice"`
Audio *umindTgFileRef `json:"audio"` Audio *umindTgFileRef `json:"audio"`
Photo []umindTgFileRef `json:"photo"` Photo []umindTgFileRef `json:"photo"`
Document *umindTgFileRef `json:"document"`
Caption string `json:"caption"`
} }
type umindTgUpdate struct { type umindTgUpdate struct {
@@ -55,6 +58,8 @@ func UmindTelegramWebhook(c *fiber.Ctx) error {
err = services.ProcesarMediaTelegramUmind(canal, msg.Chat.ID, msg.Audio.FileID, "audio") err = services.ProcesarMediaTelegramUmind(canal, msg.Chat.ID, msg.Audio.FileID, "audio")
case len(msg.Photo) > 0: case len(msg.Photo) > 0:
err = services.ProcesarMediaTelegramUmind(canal, msg.Chat.ID, msg.Photo[len(msg.Photo)-1].FileID, "image") err = services.ProcesarMediaTelegramUmind(canal, msg.Chat.ID, msg.Photo[len(msg.Photo)-1].FileID, "image")
case msg.Document != nil && msg.Document.FileID != "":
err = services.ProcesarMediaTelegramUmindConNombre(canal, msg.Chat.ID, msg.Document.FileID, "document", msg.Document.FileName, msg.Caption)
default: default:
texto := strings.TrimSpace(msg.Text) texto := strings.TrimSpace(msg.Text)
if texto == "" { if texto == "" {
@@ -81,8 +86,13 @@ type umindWaMessage struct {
Text struct { Text struct {
Body string `json:"body"` Body string `json:"body"`
} `json:"text"` } `json:"text"`
Image umindWaMediaRef `json:"image"` Image umindWaMediaRef `json:"image"`
Audio umindWaMediaRef `json:"audio"` Audio umindWaMediaRef `json:"audio"`
Document struct {
ID string `json:"id"`
Filename string `json:"filename"`
Caption string `json:"caption"`
} `json:"document"`
} }
type umindWaValue struct { type umindWaValue struct {
@@ -172,6 +182,11 @@ func UmindWhatsAppWebhook(c *fiber.Ctx) error {
continue continue
} }
err = services.ProcesarMediaWhatsAppUmind(canal, msg.From, msg.Image.ID, "image") err = services.ProcesarMediaWhatsAppUmind(canal, msg.From, msg.Image.ID, "image")
case "document":
if msg.Document.ID == "" {
continue
}
err = services.ProcesarMediaWhatsAppUmindConNombre(canal, msg.From, msg.Document.ID, "document", msg.Document.Filename, msg.Document.Caption)
default: default:
continue continue
} }
+4 -1
View File
@@ -547,6 +547,7 @@ func GetUmindCanalesHandler(c *fiber.Ctx) error {
"ID": canal.ID, "agente_id": canal.AgenteID, "tipo": canal.Tipo, "activo": canal.Activo, "ID": canal.ID, "agente_id": canal.AgenteID, "tipo": canal.Tipo, "activo": canal.Activo,
"webhook_url": webhookURL, "ultimo_error": canal.UltimoError, "webhook_url": webhookURL, "ultimo_error": canal.UltimoError,
"usar_whisper_audio": canal.UsarWhisperAudio, "usar_ocr_imagenes": canal.UsarOcrImagenes, "usar_whisper_audio": canal.UsarWhisperAudio, "usar_ocr_imagenes": canal.UsarOcrImagenes,
"usar_archivos_docs": canal.UsarArchivosDocs,
} }
} }
return c.JSON(fiber.Map{"items": out}) return c.JSON(fiber.Map{"items": out})
@@ -559,6 +560,7 @@ type umindCanalReq struct {
Activo bool `json:"activo"` Activo bool `json:"activo"`
UsarWhisperAudio bool `json:"usar_whisper_audio"` UsarWhisperAudio bool `json:"usar_whisper_audio"`
UsarOcrImagenes bool `json:"usar_ocr_imagenes"` UsarOcrImagenes bool `json:"usar_ocr_imagenes"`
UsarArchivosDocs bool `json:"usar_archivos_docs"`
} }
func CreateUmindCanalHandler(c *fiber.Ctx) error { func CreateUmindCanalHandler(c *fiber.Ctx) error {
@@ -594,6 +596,7 @@ func CreateUmindCanalHandler(c *fiber.Ctx) error {
canal := &models.UmindCanal{ canal := &models.UmindCanal{
AgenteID: req.AgenteID, Tipo: req.Tipo, Activo: true, CredencialesEnc: credencialesEnc, AgenteID: req.AgenteID, Tipo: req.Tipo, Activo: true, CredencialesEnc: credencialesEnc,
UsarWhisperAudio: req.UsarWhisperAudio, UsarOcrImagenes: req.UsarOcrImagenes, UsarWhisperAudio: req.UsarWhisperAudio, UsarOcrImagenes: req.UsarOcrImagenes,
UsarArchivosDocs: req.UsarArchivosDocs,
} }
if err := models.CreateUmindCanal(canal); err != nil { if err := models.CreateUmindCanal(canal); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -620,7 +623,7 @@ func UpdateUmindCanalHandler(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
} }
updates := map[string]interface{}{"activo": req.Activo, "usar_whisper_audio": req.UsarWhisperAudio, "usar_ocr_imagenes": req.UsarOcrImagenes} updates := map[string]interface{}{"activo": req.Activo, "usar_whisper_audio": req.UsarWhisperAudio, "usar_ocr_imagenes": req.UsarOcrImagenes, "usar_archivos_docs": req.UsarArchivosDocs}
if len(req.Credenciales) > 0 { if len(req.Credenciales) > 0 {
enc, err := services.CifrarCredencialesCanal(req.Credenciales) enc, err := services.CifrarCredencialesCanal(req.Credenciales)
if err != nil { if err != nil {