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
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user