From 6817ad1e6c95d6d72b2a4bcfcc172d9c53c0cf73 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:49:08 -0500 Subject: [PATCH] =?UTF-8?q?fix(whisper):=20acepta=20la=20transcripci=C3=B3?= =?UTF-8?q?n=20en=20texto=20plano=20y=20permite=20descargarla?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El servicio devolvía la transcripción correcta y la reportábamos como error: "respuesta inesperada del servicio de transcripción: ". La causa es que mandábamos response_format=json, que es la convención de la API de OpenAI — whisper-asr-webservice usa el query param `output`, así que ignoró el campo y respondió en su formato por defecto (txt), y el parser exigía JSON. - textoDeRespuestaWhisper acepta las dos formas (JSON {"text":...} o texto pelado) en vez de adivinar qué variante corre del otro lado. Con test: 8 casos, incluidos JSON sin campo text y un texto que empieza con "{". - Whisper y OCR: la caja de resultado ahora muestra el conteo de caracteres, scrollea si es largo, y tiene Copiar y Descargar .txt (Blob + , el texto ya está en el navegador). Co-Authored-By: Claude Sonnet 5 --- pkg/services/whisper_asr_service.go | 31 +++++++++++++++--- pkg/services/whisper_asr_test.go | 43 +++++++++++++++++++++++++ resources/views/ocr_config.html | 27 ++++++++++++++-- resources/views/whisper_asr_config.html | 29 +++++++++++++++-- 4 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 pkg/services/whisper_asr_test.go diff --git a/pkg/services/whisper_asr_service.go b/pkg/services/whisper_asr_service.go index 1cfc956..64ac374 100644 --- a/pkg/services/whisper_asr_service.go +++ b/pkg/services/whisper_asr_service.go @@ -7,6 +7,7 @@ import ( "io" "mime/multipart" "net/http" + "strings" "time" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" @@ -63,12 +64,34 @@ func TranscribirAudioSelfHosted(agenteID uint, audioBytes []byte, filename strin return "", fmt.Errorf("el servicio de transcripción respondió %d: %s", resp.StatusCode, string(raw)) } + texto, err := textoDeRespuestaWhisper(raw) + if err != nil { + return "", err + } + RegistrarUso(agenteID, models.UsoTipoWhisper, 1, "transcripcion") + return texto, nil +} + +// textoDeRespuestaWhisper acepta las dos formas en que puede volver una +// transcripción, en vez de adivinar cuál variante corre del otro lado: +// +// - JSON {"text": "..."} — las APIs compatibles con OpenAI. +// - El texto pelado — whisper-asr-webservice devuelve txt por defecto: su +// parámetro es `output` (query), no `response_format` (form), así que +// ignora el que mandamos. +// +// Exigir JSON hacía que una transcripción buena se reportara como "respuesta +// inesperada", con el texto correcto adentro del mensaje de error. +func textoDeRespuestaWhisper(raw []byte) (string, error) { var out struct { Text string `json:"text"` } - if err := json.Unmarshal(raw, &out); err != nil { - return "", fmt.Errorf("respuesta inesperada del servicio de transcripción: %s", string(raw)) + if err := json.Unmarshal(raw, &out); err == nil && strings.TrimSpace(out.Text) != "" { + return strings.TrimSpace(out.Text), nil } - RegistrarUso(agenteID, models.UsoTipoWhisper, 1, "transcripcion") - return out.Text, nil + texto := strings.TrimSpace(string(raw)) + if texto == "" { + return "", fmt.Errorf("el servicio de transcripción devolvió una respuesta vacía") + } + return texto, nil } diff --git a/pkg/services/whisper_asr_test.go b/pkg/services/whisper_asr_test.go new file mode 100644 index 0000000..81c15e3 --- /dev/null +++ b/pkg/services/whisper_asr_test.go @@ -0,0 +1,43 @@ +package services + +import "testing" + +// El bug real: whisper-asr-webservice devuelve texto plano (su parámetro es +// `output`, no `response_format`), y exigir JSON hacía que una transcripción +// buena se reportara como error con el texto correcto dentro del mensaje. +func TestTextoDeRespuestaWhisper(t *testing.T) { + casos := []struct { + nombre string + cuerpo string + esperado string + falla bool + }{ + {"texto plano", "Buenas tardes, respecto al punto uno...", "Buenas tardes, respecto al punto uno...", false}, + {"texto plano con espacios", " hola mundo \n", "hola mundo", false}, + {"json compatible con openai", `{"text":"hola mundo"}`, "hola mundo", false}, + {"json con espacios en el texto", `{"text":" hola "}`, "hola", false}, + {"json sin campo text cae a crudo", `{"resultado":"x"}`, `{"resultado":"x"}`, false}, + {"json con text vacío cae a crudo", `{"text":""}`, `{"text":""}`, false}, + {"respuesta vacía es error", " ", "", true}, + // Un texto que empieza con { pero no es JSON no debe romper el parseo. + {"texto que parece json roto", `{esto no es json`, `{esto no es json`, false}, + } + + for _, cas := range casos { + t.Run(cas.nombre, func(t *testing.T) { + got, err := textoDeRespuestaWhisper([]byte(cas.cuerpo)) + if cas.falla { + if err == nil { + t.Fatalf("esperaba error para %q", cas.cuerpo) + } + return + } + if err != nil { + t.Fatalf("no esperaba error: %v", err) + } + if got != cas.esperado { + t.Errorf("textoDeRespuestaWhisper(%q) = %q, esperaba %q", cas.cuerpo, got, cas.esperado) + } + }) + } +} diff --git a/resources/views/ocr_config.html b/resources/views/ocr_config.html index d3835a2..4972346 100644 --- a/resources/views/ocr_config.html +++ b/resources/views/ocr_config.html @@ -45,7 +45,16 @@

-
+
+
+ +
+ + +
+
+
+
@@ -59,7 +68,7 @@ function ocrApp() { return { form:{ base_url:'', token:'', notas:'' }, showToken:false, saving:false, error:'', saved:false, - archivo:null, testing:false, testError:'', testText:'', + archivo:null, testing:false, testError:'', testText:'', copiado:false, async init(){ await this.loadConfig(); }, @@ -82,6 +91,20 @@ function ocrApp() { finally{ this.saving=false; } }, + copiar(){ + navigator.clipboard.writeText(this.testText); + this.copiado=true; setTimeout(()=>this.copiado=false, 2000); + }, + + descargarTxt(){ + const base=(this.archivo?.name||'texto-extraido').replace(/\.[^.]+$/, ''); + const url=URL.createObjectURL(new Blob([this.testText], {type:'text/plain;charset=utf-8'})); + const a=document.createElement('a'); + a.href=url; a.download=base+'.txt'; + document.body.appendChild(a); a.click(); a.remove(); + URL.revokeObjectURL(url); + }, + async test(){ if(!this.archivo) return; this.testing=true; this.testError=''; this.testText=''; diff --git a/resources/views/whisper_asr_config.html b/resources/views/whisper_asr_config.html index 7bb96e3..6e6abcb 100644 --- a/resources/views/whisper_asr_config.html +++ b/resources/views/whisper_asr_config.html @@ -49,7 +49,16 @@

-
+
+
+ +
+ + +
+
+
+
@@ -63,7 +72,7 @@ function whisperAsrApp() { return { form:{ base_url:'', username:'', password:'', notas:'' }, showPass:false, saving:false, error:'', saved:false, - archivo:null, testing:false, testError:'', testText:'', + archivo:null, testing:false, testError:'', testText:'', copiado:false, async init(){ await this.loadConfig(); }, @@ -86,6 +95,22 @@ function whisperAsrApp() { finally{ this.saving=false; } }, + copiar(){ + navigator.clipboard.writeText(this.testText); + this.copiado=true; setTimeout(()=>this.copiado=false, 2000); + }, + + // Blob +
: no hace falta que el servidor sirva el archivo, + // el texto ya está en el navegador. + descargarTxt(){ + const base=(this.archivo?.name||'transcripcion').replace(/\.[^.]+$/, ''); + const url=URL.createObjectURL(new Blob([this.testText], {type:'text/plain;charset=utf-8'})); + const a=document.createElement('a'); + a.href=url; a.download=base+'.txt'; + document.body.appendChild(a); a.click(); a.remove(); + URL.revokeObjectURL(url); + }, + async test(){ if(!this.archivo) return; this.testing=true; this.testError=''; this.testText='';