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("", "\n", "", "\n", "", "\n", "", "\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() }