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:
co-authored by
Claude Opus 5
parent
e681f41639
commit
c4b0305112
@@ -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>
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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("&", "&", "<", "<", ">", ">", """, `"`, "'", "'").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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("&", "&", "<", "<", ">", ">", """, `"`, "'", "'").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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user