fix(whisper): acepta la transcripción en texto plano y permite descargarla

El servicio devolvía la transcripción correcta y la reportábamos como
error: "respuesta inesperada del servicio de transcripción: <la
transcripción completa>". 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
  + <a download>, el texto ya está en el navegador).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-14 20:49:08 -05:00
co-authored by Claude Sonnet 5
parent 7146539f86
commit 6817ad1e6c
4 changed files with 122 additions and 8 deletions
+27 -4
View File
@@ -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
}
+43
View File
@@ -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)
}
})
}
}
+25 -2
View File
@@ -45,7 +45,16 @@
<label class="label">Imagen</label>
<input type="file" accept="image/*" @change="archivo=$event.target.files[0]" class="input-field w-full">
<p x-show="testError" x-text="testError" class="text-red-500 text-sm mt-3"></p>
<div x-show="testText" class="bg-slate-50 border border-slate-200 rounded-lg p-3 mt-3 text-sm text-slate-700 whitespace-pre-wrap" x-text="testText"></div>
<div x-show="testText" class="mt-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-slate-500" x-text="testText.length + ' caracteres'"></span>
<div class="flex gap-2">
<button type="button" @click="copiar()" class="text-xs text-slate-500 hover:text-slate-700 underline" x-text="copiado?'Copiado':'Copiar'"></button>
<button type="button" @click="descargarTxt()" class="text-xs text-slate-500 hover:text-slate-700 underline">Descargar .txt</button>
</div>
</div>
<div class="bg-slate-50 border border-slate-200 rounded-lg p-3 text-sm text-slate-700 whitespace-pre-wrap max-h-80 overflow-y-auto" x-text="testText"></div>
</div>
<div class="flex justify-end mt-4">
<button type="submit" :disabled="testing || !archivo" class="btn-primary" x-text="testing?'Extrayendo...':'Extraer texto'"></button>
</div>
@@ -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='';
+27 -2
View File
@@ -49,7 +49,16 @@
<label class="label">Audio</label>
<input type="file" accept="audio/*" @change="archivo=$event.target.files[0]" class="input-field w-full">
<p x-show="testError" x-text="testError" class="text-red-500 text-sm mt-3"></p>
<div x-show="testText" class="bg-slate-50 border border-slate-200 rounded-lg p-3 mt-3 text-sm text-slate-700 whitespace-pre-wrap" x-text="testText"></div>
<div x-show="testText" class="mt-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-slate-500" x-text="testText.length + ' caracteres'"></span>
<div class="flex gap-2">
<button type="button" @click="copiar()" class="text-xs text-slate-500 hover:text-slate-700 underline" x-text="copiado?'Copiado':'Copiar'"></button>
<button type="button" @click="descargarTxt()" class="text-xs text-slate-500 hover:text-slate-700 underline">Descargar .txt</button>
</div>
</div>
<div class="bg-slate-50 border border-slate-200 rounded-lg p-3 text-sm text-slate-700 whitespace-pre-wrap max-h-80 overflow-y-auto" x-text="testText"></div>
</div>
<div class="flex justify-end mt-4">
<button type="submit" :disabled="testing || !archivo" class="btn-primary" x-text="testing?'Transcribiendo...':'Transcribir'"></button>
</div>
@@ -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 + <a download>: 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='';