@runnerpro/backend 1.34.0 → 1.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/cjs/index.js +8 -2
- package/lib/cjs/mediaProcessing/audio.js +169 -155
- package/lib/cjs/mediaProcessing/azureSpeech.js +114 -0
- package/lib/cjs/mediaProcessing/azureTranscribe.js +73 -0
- package/lib/cjs/mediaProcessing/correccionTranscripcion.js +313 -0
- package/lib/cjs/mediaProcessing/ffmpegUtils.js +41 -2
- package/lib/cjs/mediaProcessing/index.js +157 -32
- package/lib/cjs/mediaProcessing/video.js +4 -4
- package/lib/cjs/types/index.d.ts +3 -2
- package/lib/cjs/types/index.d.ts.map +1 -1
- package/lib/cjs/types/mediaProcessing/audio.d.ts +31 -36
- package/lib/cjs/types/mediaProcessing/audio.d.ts.map +1 -1
- package/lib/cjs/types/mediaProcessing/azureSpeech.d.ts +38 -0
- package/lib/cjs/types/mediaProcessing/azureSpeech.d.ts.map +1 -0
- package/lib/cjs/types/mediaProcessing/azureTranscribe.d.ts +26 -0
- package/lib/cjs/types/mediaProcessing/azureTranscribe.d.ts.map +1 -0
- package/lib/cjs/types/mediaProcessing/correccionTranscripcion.d.ts +49 -0
- package/lib/cjs/types/mediaProcessing/correccionTranscripcion.d.ts.map +1 -0
- package/lib/cjs/types/mediaProcessing/ffmpegUtils.d.ts +20 -1
- package/lib/cjs/types/mediaProcessing/ffmpegUtils.d.ts.map +1 -1
- package/lib/cjs/types/mediaProcessing/index.d.ts +42 -8
- package/lib/cjs/types/mediaProcessing/index.d.ts.map +1 -1
- package/lib/cjs/types/mediaProcessing/video.d.ts +2 -1
- package/lib/cjs/types/mediaProcessing/video.d.ts.map +1 -1
- package/lib/cjs/types/workout/saveWorkoutAplication.d.ts +22 -1
- package/lib/cjs/types/workout/saveWorkoutAplication.d.ts.map +1 -1
- package/lib/cjs/workout/saveWorkoutAplication.js +94 -2
- package/package.json +3 -3
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.normalizar = exports.GLOSARIO_RUNNING = exports.corregirTranscripcion = void 0;
|
|
13
|
+
// ✅ Capa de corrección de transcripciones: un modelo de texto propone, el código decide.
|
|
14
|
+
//
|
|
15
|
+
// Sin filtro esta capa es peligrosa: en pruebas convirtió «Bernabéu» en «RunnerPro», «La Elipa» en
|
|
16
|
+
// «10K» y «zona» en «suegra». Con el filtro de abajo solo pasan sustituciones cuyo reemplazo es jerga
|
|
17
|
+
// de running o un nombre del contexto, que suenan parecido a lo que había y que no tocan números:
|
|
18
|
+
// 10 aceptadas (todas correctas) y 18 rechazadas sobre 72 transcripciones reales (06-sep-2026).
|
|
19
|
+
const index_1 = require("../prompt/index");
|
|
20
|
+
// ✅ Vocabulario que el reconocedor destroza si no se le ayuda (fartlek → «Farley», Hyrox → «Airox»,
|
|
21
|
+
// Garmin → «garam»). Alimenta la phraseList de Speech y el filtro de esta capa. Cada palabra nueva
|
|
22
|
+
// abre una puerta más al filtro: solo jerga y marcas, nunca palabras comunes.
|
|
23
|
+
const GLOSARIO_RUNNING = [
|
|
24
|
+
'fartlek',
|
|
25
|
+
'series',
|
|
26
|
+
'rodaje',
|
|
27
|
+
'tirada larga',
|
|
28
|
+
'progresivo',
|
|
29
|
+
'tempo',
|
|
30
|
+
'umbral',
|
|
31
|
+
'zona 2',
|
|
32
|
+
'VAM',
|
|
33
|
+
'VO2max',
|
|
34
|
+
'cuestas',
|
|
35
|
+
'cadencia',
|
|
36
|
+
'pulsaciones',
|
|
37
|
+
'pulsómetro',
|
|
38
|
+
'banda de pecho',
|
|
39
|
+
'RPE',
|
|
40
|
+
'agujetas',
|
|
41
|
+
'sobrecarga',
|
|
42
|
+
'fascitis plantar',
|
|
43
|
+
'periostitis',
|
|
44
|
+
'cintilla iliotibial',
|
|
45
|
+
'tendón de Aquiles',
|
|
46
|
+
'isquiotibiales',
|
|
47
|
+
'cuádriceps',
|
|
48
|
+
'gemelo',
|
|
49
|
+
'sóleo',
|
|
50
|
+
'glúteo',
|
|
51
|
+
'psoas',
|
|
52
|
+
'lumbar',
|
|
53
|
+
'cervical',
|
|
54
|
+
'Garmin',
|
|
55
|
+
'Garmin Connect',
|
|
56
|
+
'Suunto',
|
|
57
|
+
'Polar',
|
|
58
|
+
'Coros',
|
|
59
|
+
'Strava',
|
|
60
|
+
'TrainingPeaks',
|
|
61
|
+
'Hyrox',
|
|
62
|
+
'DEKA',
|
|
63
|
+
'media maratón',
|
|
64
|
+
'maratón',
|
|
65
|
+
'trail',
|
|
66
|
+
'RunnerPro',
|
|
67
|
+
'Bizum',
|
|
68
|
+
'Revolut',
|
|
69
|
+
'prueba de esfuerzo',
|
|
70
|
+
'fisio',
|
|
71
|
+
'hidratos',
|
|
72
|
+
'proteína',
|
|
73
|
+
'Enantyum',
|
|
74
|
+
'ibuprofeno',
|
|
75
|
+
'Aquarius',
|
|
76
|
+
];
|
|
77
|
+
exports.GLOSARIO_RUNNING = GLOSARIO_RUNNING;
|
|
78
|
+
// ✅ Por debajo de esto no hay nada que corregir («Gracias Rubén»)
|
|
79
|
+
const CORRECCION_MIN_CARACTERES = 15;
|
|
80
|
+
// ✅ Si el modelo no contesta a tiempo se guarda la transcripción sin corregir: esta capa nunca bloquea
|
|
81
|
+
const CORRECCION_TIMEOUT_MS = 15000;
|
|
82
|
+
// ✅ Parecido mínimo entre lo transcrito y el reemplazo ("farley" → "fartlek" da 0,43; "zona" → "suegra" da 0,17)
|
|
83
|
+
const SIMILITUD_MINIMA = 0.4;
|
|
84
|
+
/**
|
|
85
|
+
* Normaliza para comparar: minúsculas, sin acentos, solo letras, dígitos y espacios
|
|
86
|
+
*
|
|
87
|
+
* @param texto - Texto de entrada
|
|
88
|
+
* @returns Texto normalizado
|
|
89
|
+
*/
|
|
90
|
+
const normalizar = (texto) => (texto || '')
|
|
91
|
+
.toLowerCase()
|
|
92
|
+
.normalize('NFD')
|
|
93
|
+
.replace(/[̀-ͯ]/g, '')
|
|
94
|
+
.replace(/[^a-z0-9ñ ]+/g, ' ')
|
|
95
|
+
.replace(/\s+/g, ' ')
|
|
96
|
+
.trim();
|
|
97
|
+
exports.normalizar = normalizar;
|
|
98
|
+
const palabras = (texto) => normalizar(texto).split(' ').filter(Boolean);
|
|
99
|
+
const levenshtein = (a, b) => {
|
|
100
|
+
if (!a.length)
|
|
101
|
+
return b.length;
|
|
102
|
+
if (!b.length)
|
|
103
|
+
return a.length;
|
|
104
|
+
let previa = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
105
|
+
for (let i = 1; i <= a.length; i++) {
|
|
106
|
+
const actual = [i];
|
|
107
|
+
for (let j = 1; j <= b.length; j++) {
|
|
108
|
+
actual[j] = Math.min(previa[j] + 1, actual[j - 1] + 1, previa[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
109
|
+
}
|
|
110
|
+
previa = actual;
|
|
111
|
+
}
|
|
112
|
+
return previa[b.length];
|
|
113
|
+
};
|
|
114
|
+
const similitud = (a, b) => 1 - levenshtein(a, b) / Math.max(a.length, b.length, 1);
|
|
115
|
+
/**
|
|
116
|
+
* Alinea dos listas de palabras (subsecuencia común más larga) y devuelve los tramos que difieren,
|
|
117
|
+
* con su posición en la lista original
|
|
118
|
+
*
|
|
119
|
+
* @param original - Palabras normalizadas de la transcripción
|
|
120
|
+
* @param propuesta - Palabras normalizadas de la propuesta del modelo
|
|
121
|
+
* @returns Tramos distintos: palabras originales, palabras propuestas y rango [desde, hasta) en `original`
|
|
122
|
+
*/
|
|
123
|
+
const alinear = (original, propuesta) => {
|
|
124
|
+
const lcs = Array.from({ length: original.length + 1 }, () => new Array(propuesta.length + 1).fill(0));
|
|
125
|
+
for (let i = original.length - 1; i >= 0; i--) {
|
|
126
|
+
for (let j = propuesta.length - 1; j >= 0; j--) {
|
|
127
|
+
lcs[i][j] = original[i] === propuesta[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const tramos = [];
|
|
131
|
+
let i = 0;
|
|
132
|
+
let j = 0;
|
|
133
|
+
let pendiente = null;
|
|
134
|
+
const cerrar = () => {
|
|
135
|
+
if (pendiente && (pendiente.de.length || pendiente.a.length))
|
|
136
|
+
tramos.push(pendiente);
|
|
137
|
+
pendiente = null;
|
|
138
|
+
};
|
|
139
|
+
const abrir = () => {
|
|
140
|
+
if (!pendiente)
|
|
141
|
+
pendiente = { de: [], a: [], desde: i, hasta: i };
|
|
142
|
+
};
|
|
143
|
+
while (i < original.length && j < propuesta.length) {
|
|
144
|
+
if (original[i] === propuesta[j]) {
|
|
145
|
+
cerrar();
|
|
146
|
+
i++;
|
|
147
|
+
j++;
|
|
148
|
+
}
|
|
149
|
+
else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
|
150
|
+
abrir();
|
|
151
|
+
pendiente.de.push(original[i]);
|
|
152
|
+
i++;
|
|
153
|
+
pendiente.hasta = i;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
abrir();
|
|
157
|
+
pendiente.a.push(propuesta[j]);
|
|
158
|
+
j++;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
while (i < original.length) {
|
|
162
|
+
abrir();
|
|
163
|
+
pendiente.de.push(original[i]);
|
|
164
|
+
i++;
|
|
165
|
+
pendiente.hasta = i;
|
|
166
|
+
}
|
|
167
|
+
while (j < propuesta.length) {
|
|
168
|
+
abrir();
|
|
169
|
+
pendiente.a.push(propuesta[j]);
|
|
170
|
+
j++;
|
|
171
|
+
}
|
|
172
|
+
cerrar();
|
|
173
|
+
return tramos;
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* Forma escrita de un reemplazo aceptado: la entrada del glosario o el nombre que coincide, y si no, tal cual
|
|
177
|
+
*
|
|
178
|
+
* @param reemplazo - Palabras normalizadas del reemplazo
|
|
179
|
+
* @param canonicas - Mapa forma normalizada → forma escrita (glosario + nombres)
|
|
180
|
+
* @returns Reemplazo con mayúsculas y acentos correctos
|
|
181
|
+
*/
|
|
182
|
+
const formaEscrita = (reemplazo, canonicas) => {
|
|
183
|
+
const entera = canonicas.get(reemplazo.join(' '));
|
|
184
|
+
if (entera)
|
|
185
|
+
return entera;
|
|
186
|
+
return reemplazo.map((palabra) => canonicas.get(palabra) || canonicas.get(palabra.replace(/s$/, '')) || palabra).join(' ');
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* Aplica sobre el texto original (conservando puntuación y mayúsculas) las sustituciones aceptadas.
|
|
190
|
+
* Si la tokenización del original no casa con sus palabras normalizadas, no se toca nada.
|
|
191
|
+
*
|
|
192
|
+
* @param texto - Transcripción original
|
|
193
|
+
* @param tramos - Tramos aceptados, con su rango de palabras en el original
|
|
194
|
+
* @param canonicas - Mapa forma normalizada → forma escrita
|
|
195
|
+
* @returns Texto corregido
|
|
196
|
+
*/
|
|
197
|
+
const aplicar = (texto, tramos, canonicas) => {
|
|
198
|
+
const tokens = texto.match(/[\p{L}\p{N}]+|[^\p{L}\p{N}]+/gu) || [];
|
|
199
|
+
const indicesPalabra = [];
|
|
200
|
+
tokens.forEach((token, idx) => {
|
|
201
|
+
if (/[\p{L}\p{N}]/u.test(token))
|
|
202
|
+
indicesPalabra.push(idx);
|
|
203
|
+
});
|
|
204
|
+
if (indicesPalabra.length !== palabras(texto).length)
|
|
205
|
+
return texto;
|
|
206
|
+
const salida = [...tokens];
|
|
207
|
+
for (const tramo of tramos) {
|
|
208
|
+
const primero = indicesPalabra[tramo.desde];
|
|
209
|
+
const ultimo = indicesPalabra[tramo.hasta - 1];
|
|
210
|
+
if (primero === undefined || ultimo === undefined)
|
|
211
|
+
continue;
|
|
212
|
+
let reemplazo = formaEscrita(tramo.a, canonicas);
|
|
213
|
+
if (/^\p{Lu}/u.test(tokens[primero]) && /^\p{Ll}/u.test(reemplazo))
|
|
214
|
+
reemplazo = reemplazo[0].toUpperCase() + reemplazo.slice(1);
|
|
215
|
+
salida[primero] = reemplazo;
|
|
216
|
+
for (let k = primero + 1; k <= ultimo; k++)
|
|
217
|
+
salida[k] = '';
|
|
218
|
+
}
|
|
219
|
+
return salida.join('');
|
|
220
|
+
};
|
|
221
|
+
const nombreCliente = (contexto) => { var _a; return (((_a = contexto.nombres) === null || _a === void 0 ? void 0 : _a[0]) || 'un cliente').trim(); };
|
|
222
|
+
const promptCorreccion = (texto, contexto) => {
|
|
223
|
+
const entrenadores = (contexto.nombres || []).slice(1).filter(Boolean).join(' y ') || 'su entrenador';
|
|
224
|
+
const previos = (contexto.previos || [])
|
|
225
|
+
.map((mensaje) => `- ${mensaje.quien === 'cliente' ? nombreCliente(contexto) : 'Entrenador'}: ${(mensaje.texto || '').replace(/\s+/g, ' ').trim()}`)
|
|
226
|
+
.join('\n');
|
|
227
|
+
return `Eres un corrector de transcripciones automáticas. Te doy la transcripción de una nota de voz que ${nombreCliente(contexto)} (cliente) manda a su entrenador (${entrenadores}) en RunnerPro, una app de running.
|
|
228
|
+
|
|
229
|
+
Tu ÚNICA tarea: corregir palabras que el transcriptor haya escrito mal por sonar parecidas a un nombre propio, una marca o un término de running. Ejemplos: "Farley" → "fartlek", "garam" → "Garmin", "Airox" → "Hyrox", "Runnea Pro" → "RunnerPro".
|
|
230
|
+
Prohibido: añadir palabras, quitar palabras, reordenar, cambiar el sentido, corregir gramática o estilo, tocar números. Si no hay nada que corregir, devuelve el texto tal cual.
|
|
231
|
+
|
|
232
|
+
Últimos mensajes del chat:
|
|
233
|
+
${previos || '(ninguno)'}
|
|
234
|
+
|
|
235
|
+
Vocabulario: ${GLOSARIO_RUNNING.join(', ')}.
|
|
236
|
+
|
|
237
|
+
Transcripción:
|
|
238
|
+
${texto}
|
|
239
|
+
|
|
240
|
+
Devuelve SOLO la transcripción corregida, sin comentarios.`;
|
|
241
|
+
};
|
|
242
|
+
/**
|
|
243
|
+
* Pide al modelo LITE una propuesta de corrección; si no llega a tiempo, devuelve null
|
|
244
|
+
*
|
|
245
|
+
* @param prompt - Prompt de corrección
|
|
246
|
+
* @returns Texto propuesto, o null
|
|
247
|
+
*/
|
|
248
|
+
const pedirPropuesta = (prompt) => __awaiter(void 0, void 0, void 0, function* () {
|
|
249
|
+
const llamada = (0, index_1.generateText)({
|
|
250
|
+
model: (0, index_1.createGoogleModel)('LITE'),
|
|
251
|
+
prompt,
|
|
252
|
+
// ⭐ Sin registro en LLM CACHE: una transcripción no vale como clave y engorda la tabla
|
|
253
|
+
log: false,
|
|
254
|
+
providerOptions: { openai: { reasoningEffort: 'low' } },
|
|
255
|
+
}).then((resultado) => ((resultado === null || resultado === void 0 ? void 0 : resultado.text) || '').trim());
|
|
256
|
+
const timeout = new Promise((resolve) => setTimeout(() => resolve(null), CORRECCION_TIMEOUT_MS));
|
|
257
|
+
return Promise.race([llamada, timeout]);
|
|
258
|
+
});
|
|
259
|
+
/**
|
|
260
|
+
* Corrige jerga y nombres de una transcripción con el modelo LITE, dejando pasar solo las
|
|
261
|
+
* sustituciones que el filtro determinista acepta. Nunca lanza: ante cualquier fallo devuelve el
|
|
262
|
+
* texto tal cual.
|
|
263
|
+
*
|
|
264
|
+
* @param texto - Transcripción tal como sale del reconocedor
|
|
265
|
+
* @param contexto - Nombres y últimos mensajes del chat
|
|
266
|
+
* @returns Texto corregido y la lista de sustituciones propuestas con su veredicto
|
|
267
|
+
*
|
|
268
|
+
* @example
|
|
269
|
+
* ```typescript
|
|
270
|
+
* const { texto, sustituciones } = await corregirTranscripcion('Hoy he hecho Farley con el garam', { nombres: ['Ana', 'Rubén'] });
|
|
271
|
+
* // texto: 'Hoy he hecho fartlek con el Garmin'
|
|
272
|
+
* ```
|
|
273
|
+
*/
|
|
274
|
+
const corregirTranscripcion = (texto, contexto) => __awaiter(void 0, void 0, void 0, function* () {
|
|
275
|
+
const sinCambios = { texto, sustituciones: [] };
|
|
276
|
+
if (!texto || texto.length < CORRECCION_MIN_CARACTERES || process.env.TRANSCRIPCION_CORRECCION === 'off')
|
|
277
|
+
return sinCambios;
|
|
278
|
+
try {
|
|
279
|
+
const propuesta = yield pedirPropuesta(promptCorreccion(texto, contexto || {}));
|
|
280
|
+
if (!propuesta || normalizar(propuesta) === normalizar(texto))
|
|
281
|
+
return sinCambios;
|
|
282
|
+
const nombres = ((contexto === null || contexto === void 0 ? void 0 : contexto.nombres) || []).filter(Boolean);
|
|
283
|
+
const canonicas = new Map();
|
|
284
|
+
for (const entrada of [...GLOSARIO_RUNNING, ...nombres]) {
|
|
285
|
+
canonicas.set(normalizar(entrada), entrada.trim());
|
|
286
|
+
for (const palabra of palabras(entrada))
|
|
287
|
+
if (!canonicas.has(palabra))
|
|
288
|
+
canonicas.set(palabra, palabra);
|
|
289
|
+
}
|
|
290
|
+
const admitidas = new Set(canonicas.keys());
|
|
291
|
+
const sustituciones = [];
|
|
292
|
+
const aceptadas = [];
|
|
293
|
+
for (const tramo of alinear(palabras(texto), palabras(propuesta))) {
|
|
294
|
+
const de = tramo.de.join(' ');
|
|
295
|
+
const a = tramo.a.join(' ');
|
|
296
|
+
const aceptada = tramo.de.length > 0 &&
|
|
297
|
+
tramo.a.length > 0 &&
|
|
298
|
+
!/\d/.test(de + a) &&
|
|
299
|
+
tramo.a.every((palabra) => admitidas.has(palabra) || admitidas.has(palabra.replace(/s$/, ''))) &&
|
|
300
|
+
similitud(de.replace(/ /g, ''), a.replace(/ /g, '')) >= SIMILITUD_MINIMA;
|
|
301
|
+
sustituciones.push({ de, a, aceptada });
|
|
302
|
+
if (aceptada)
|
|
303
|
+
aceptadas.push(tramo);
|
|
304
|
+
}
|
|
305
|
+
return { texto: aceptadas.length ? aplicar(texto, aceptadas, canonicas) : texto, sustituciones };
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
// eslint-disable-next-line no-console
|
|
309
|
+
console.error('[corregirTranscripcion] Se guarda sin corregir:', (error === null || error === void 0 ? void 0 : error.message) || error);
|
|
310
|
+
return sinCambios;
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
exports.corregirTranscripcion = corregirTranscripcion;
|
|
@@ -12,10 +12,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
12
12
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
13
|
};
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
-
exports.SPEECH_SAMPLE_RATE = exports.FFMPEG_TIMEOUT_MS = exports.withTempDir = exports.probeMedia = exports.runFfmpeg = exports.ffmpeg = void 0;
|
|
15
|
+
exports.SIN_VOZ_DB = exports.SIN_VOZ_DB_CLIP_CORTO = exports.SIN_VOZ_DURACION_MAX_S = exports.SPEECH_SAMPLE_RATE = exports.FFMPEG_TIMEOUT_MS = exports.withTempDir = exports.esAudioSinVoz = exports.measureLoudness = exports.probeMedia = exports.runFfmpeg = exports.ffmpeg = void 0;
|
|
16
16
|
const fs_1 = __importDefault(require("fs"));
|
|
17
17
|
const os_1 = __importDefault(require("os"));
|
|
18
18
|
const path_1 = __importDefault(require("path"));
|
|
19
|
+
const child_process_1 = require("child_process");
|
|
19
20
|
const fluent_ffmpeg_1 = __importDefault(require("fluent-ffmpeg"));
|
|
20
21
|
exports.ffmpeg = fluent_ffmpeg_1.default;
|
|
21
22
|
const ffmpeg_static_1 = __importDefault(require("ffmpeg-static"));
|
|
@@ -26,9 +27,19 @@ fluent_ffmpeg_1.default.setFfprobePath(ffprobe_static_1.default.path);
|
|
|
26
27
|
// a borrar el temporal (que en Cloud Run vive en RAM) y el adjunto se queda sin FILE TEXT
|
|
27
28
|
const FFMPEG_TIMEOUT_MS = 120000;
|
|
28
29
|
exports.FFMPEG_TIMEOUT_MS = FFMPEG_TIMEOUT_MS;
|
|
29
|
-
// ✅ Formato que
|
|
30
|
+
// ✅ Formato del WAV que se manda a los motores de transcripción: PCM 16 bits, mono, 16 kHz
|
|
30
31
|
const SPEECH_SAMPLE_RATE = 16000;
|
|
31
32
|
exports.SPEECH_SAMPLE_RATE = SPEECH_SAMPLE_RATE;
|
|
33
|
+
// ✅ Umbrales de la guarda física: por debajo no hay voz que transcribir y no se llama a ningún motor.
|
|
34
|
+
// Medidos sobre 74 notas de voz reales: los dos clips vacíos (0,5 s a −45 dB y 1,2 s a −66 dB) caen
|
|
35
|
+
// dentro; el audio con voz más bajo (−43 dB, 41 s) y uno con una tele de fondo (−55 dB) quedan fuera.
|
|
36
|
+
// El umbral absoluto es deliberadamente bajo: mejor transcribir de más que callar un audio con voz.
|
|
37
|
+
const SIN_VOZ_DURACION_MAX_S = 2;
|
|
38
|
+
exports.SIN_VOZ_DURACION_MAX_S = SIN_VOZ_DURACION_MAX_S;
|
|
39
|
+
const SIN_VOZ_DB_CLIP_CORTO = -40;
|
|
40
|
+
exports.SIN_VOZ_DB_CLIP_CORTO = SIN_VOZ_DB_CLIP_CORTO;
|
|
41
|
+
const SIN_VOZ_DB = -60;
|
|
42
|
+
exports.SIN_VOZ_DB = SIN_VOZ_DB;
|
|
32
43
|
/**
|
|
33
44
|
* Ejecuta un comando de ffmpeg como promesa, matándolo si se pasa del tiempo máximo
|
|
34
45
|
*
|
|
@@ -82,6 +93,34 @@ const probeMedia = (inputPath) => new Promise((resolve, reject) => {
|
|
|
82
93
|
});
|
|
83
94
|
});
|
|
84
95
|
exports.probeMedia = probeMedia;
|
|
96
|
+
/**
|
|
97
|
+
* Mide el volumen medio de un audio con el filtro `volumedetect` de ffmpeg. Nunca lanza.
|
|
98
|
+
*
|
|
99
|
+
* @param inputPath - Ruta del audio en disco (cualquier formato que ffmpeg decodifique)
|
|
100
|
+
* @returns Volumen medio en dB, o null si no se pudo medir
|
|
101
|
+
*/
|
|
102
|
+
const measureLoudness = (inputPath) => new Promise((resolve) => {
|
|
103
|
+
(0, child_process_1.execFile)(ffmpeg_static_1.default, ['-hide_banner', '-nostats', '-i', inputPath, '-vn', '-af', 'volumedetect', '-f', 'null', '-'], { timeout: FFMPEG_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 }, (error, _stdout, stderr) => {
|
|
104
|
+
const match = /mean_volume:\s*(-?[\d.]+)\s*dB/.exec(String(stderr || ''));
|
|
105
|
+
if (!match) {
|
|
106
|
+
// eslint-disable-next-line no-console
|
|
107
|
+
console.error('[measureLoudness] No se pudo medir el volumen:', (error === null || error === void 0 ? void 0 : error.message) || 'sin mean_volume en la salida de ffmpeg');
|
|
108
|
+
return resolve(null);
|
|
109
|
+
}
|
|
110
|
+
resolve(Number(match[1]));
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
exports.measureLoudness = measureLoudness;
|
|
114
|
+
/**
|
|
115
|
+
* Decide si un audio no tiene voz que transcribir: muy corto y bajo, o directamente en silencio.
|
|
116
|
+
* Sin medida de volumen nunca se marca: mejor transcribir de más que callar un audio con voz.
|
|
117
|
+
*
|
|
118
|
+
* @param duration - Duración en segundos (0 si no se conoce)
|
|
119
|
+
* @param meanDb - Volumen medio en dB, o null si no se pudo medir
|
|
120
|
+
* @returns true si no merece la pena llamar a ningún motor
|
|
121
|
+
*/
|
|
122
|
+
const esAudioSinVoz = (duration, meanDb) => meanDb !== null && ((duration > 0 && duration < SIN_VOZ_DURACION_MAX_S && meanDb < SIN_VOZ_DB_CLIP_CORTO) || meanDb < SIN_VOZ_DB);
|
|
123
|
+
exports.esAudioSinVoz = esAudioSinVoz;
|
|
85
124
|
/**
|
|
86
125
|
* Crea un directorio temporal, ejecuta el trabajo y lo borra pase lo que pase.
|
|
87
126
|
* En Cloud Run el temporal vive en RAM, así que dejarlo sin borrar cuesta memoria.
|
|
@@ -12,10 +12,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
12
12
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
13
|
};
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
-
exports.FILE_KIND = exports.reprocessRecentMedia = exports.processMediaFile = exports.analyzeFile = exports.classifyFile = exports.describeOpaqueFile = exports.describeTextFile = exports.describePdf = exports.describeVideo = exports.describeImage = exports.transcribeAudio = void 0;
|
|
15
|
+
exports.MARCADORES_AUDIO = exports.MARCADOR_NO_TRANSCRITO = exports.MARCADOR_SIN_VOZ = exports.GLOSARIO_RUNNING = exports.corregirTranscripcion = exports.FILE_KIND = exports.reprocessMediaByIds = exports.reprocessRecentMedia = exports.processMediaFile = exports.analyzeFile = exports.classifyFile = exports.describeOpaqueFile = exports.describeTextFile = exports.describePdf = exports.describeVideo = exports.describeImage = exports.transcribeAudio = void 0;
|
|
16
16
|
const path_1 = __importDefault(require("path"));
|
|
17
17
|
const index_1 = require("../db/index");
|
|
18
|
-
const
|
|
18
|
+
const almacenamiento_1 = require("../almacenamiento");
|
|
19
19
|
const audio_1 = require("./audio");
|
|
20
20
|
Object.defineProperty(exports, "transcribeAudio", { enumerable: true, get: function () { return audio_1.transcribeAudio; } });
|
|
21
21
|
const image_1 = require("./image");
|
|
@@ -114,6 +114,7 @@ exports.classifyFile = classifyFile;
|
|
|
114
114
|
* @param fileBuffer - Buffer del archivo
|
|
115
115
|
* @param mimetype - Tipo MIME del archivo
|
|
116
116
|
* @param filename - Nombre original del archivo
|
|
117
|
+
* @param contexto - Idioma, nombres y últimos mensajes del cliente; solo lo usan audio y vídeo
|
|
117
118
|
* @returns Promise<string> - Transcripción, descripción, resumen o ficha del archivo
|
|
118
119
|
*
|
|
119
120
|
* @example
|
|
@@ -122,19 +123,19 @@ exports.classifyFile = classifyFile;
|
|
|
122
123
|
* // "Analítica de sangre del 3 de junio con ferritina de 28 ng/ml..."
|
|
123
124
|
* ```
|
|
124
125
|
*/
|
|
125
|
-
const analyzeFile = (fileBuffer, mimetype, filename) => __awaiter(void 0, void 0, void 0, function* () {
|
|
126
|
+
const analyzeFile = (fileBuffer, mimetype, filename, contexto) => __awaiter(void 0, void 0, void 0, function* () {
|
|
126
127
|
const kind = classifyFile(mimetype, filename, fileBuffer);
|
|
127
128
|
const fallback = () => (0, documents_1.describeOpaqueFile)(mimetype, filename, (fileBuffer === null || fileBuffer === void 0 ? void 0 : fileBuffer.length) || 0);
|
|
128
129
|
let fileText;
|
|
129
130
|
switch (kind) {
|
|
130
131
|
case FILE_KIND.AUDIO:
|
|
131
|
-
fileText = yield (0, audio_1.transcribeAudio)(fileBuffer, mimetype, filename);
|
|
132
|
+
fileText = yield (0, audio_1.transcribeAudio)(fileBuffer, mimetype, filename, contexto);
|
|
132
133
|
break;
|
|
133
134
|
case FILE_KIND.IMAGE:
|
|
134
135
|
fileText = yield (0, image_1.describeImage)(fileBuffer, mimetype);
|
|
135
136
|
break;
|
|
136
137
|
case FILE_KIND.VIDEO:
|
|
137
|
-
fileText = yield (0, video_1.describeVideo)(fileBuffer, mimetype, filename);
|
|
138
|
+
fileText = yield (0, video_1.describeVideo)(fileBuffer, mimetype, filename, contexto);
|
|
138
139
|
break;
|
|
139
140
|
case FILE_KIND.PDF:
|
|
140
141
|
fileText = yield (0, documents_1.describePdf)(fileBuffer, filename);
|
|
@@ -152,6 +153,45 @@ const analyzeFile = (fileBuffer, mimetype, filename) => __awaiter(void 0, void 0
|
|
|
152
153
|
return clean || fallback();
|
|
153
154
|
});
|
|
154
155
|
exports.analyzeFile = analyzeFile;
|
|
156
|
+
/**
|
|
157
|
+
* Carga lo que ayuda a transcribir una nota de voz: idioma del cliente, nombres (cliente y
|
|
158
|
+
* entrenadores, para la lista de frases y el filtro de corrección) y los últimos mensajes del chat.
|
|
159
|
+
* Si falla, se transcribe sin contexto: nunca bloquea.
|
|
160
|
+
*
|
|
161
|
+
* @param idMessage - ID del mensaje en CHAT MESSAGE
|
|
162
|
+
* @returns Contexto, o null si no se pudo cargar
|
|
163
|
+
*/
|
|
164
|
+
const cargarContextoTranscripcion = (idMessage) => __awaiter(void 0, void 0, void 0, function* () {
|
|
165
|
+
try {
|
|
166
|
+
const [fila] = yield (0, index_1.query)(`SELECT c."PREFERRED LANGUAGE" AS idioma, c."NAME" AS cliente, e1."NAME" AS entrenador1, e2."NAME" AS entrenador2,
|
|
167
|
+
(SELECT json_agg(json_build_object(
|
|
168
|
+
'quien', CASE WHEN p."ID SENDER" = m."ID CLIENTE" THEN 'cliente' ELSE 'entrenador' END,
|
|
169
|
+
'texto', left(CASE WHEN p."MIMETYPE" IS NOT NULL THEN p."FILE TEXT" ELSE p."TEXT" END, 300)) ORDER BY p."DATE")
|
|
170
|
+
FROM (SELECT * FROM "CHAT MESSAGE" p2
|
|
171
|
+
WHERE p2."ID CLIENTE" = m."ID CLIENTE" AND p2."DATE" < m."DATE" AND p2."ID" <> m."ID"
|
|
172
|
+
AND COALESCE(p2."ELIMINADO", false) = false AND COALESCE(p2."SHOW CLIENT", true) = true
|
|
173
|
+
ORDER BY p2."DATE" DESC LIMIT 6) p) AS previos
|
|
174
|
+
FROM "CHAT MESSAGE" m
|
|
175
|
+
JOIN "CLIENTE" c ON c."ID" = m."ID CLIENTE"
|
|
176
|
+
LEFT JOIN "ENTRENADOR" e1 ON e1."ID" = c."ID ENTRENADOR PRINCIPAL"
|
|
177
|
+
LEFT JOIN "ENTRENADOR" e2 ON e2."ID" = c."ID ENTRENADOR SECUNDARIO"
|
|
178
|
+
WHERE m."ID" = ?`, [idMessage]);
|
|
179
|
+
if (!fila)
|
|
180
|
+
return null;
|
|
181
|
+
return {
|
|
182
|
+
idMensaje: idMessage,
|
|
183
|
+
idioma: fila.idioma || null,
|
|
184
|
+
// ⭐ El primero es siempre el cliente (aunque no tenga nombre): la corrección cuenta con ese orden
|
|
185
|
+
nombres: [fila.cliente, fila.entrenador1, fila.entrenador2].map((nombre) => (nombre || '').trim()),
|
|
186
|
+
previos: (fila.previos || []).filter((mensaje) => mensaje === null || mensaje === void 0 ? void 0 : mensaje.texto),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
// eslint-disable-next-line no-console
|
|
191
|
+
console.error(`[processMediaFile] Sin contexto para transcribir el mensaje ${idMessage}:`, (error === null || error === void 0 ? void 0 : error.message) || error);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
});
|
|
155
195
|
/**
|
|
156
196
|
* Procesa un adjunto de chat y guarda su texto en la base de datos.
|
|
157
197
|
* Pensado para llamarse sin await (fire-and-forget) desde el envío del archivo.
|
|
@@ -170,25 +210,65 @@ exports.analyzeFile = analyzeFile;
|
|
|
170
210
|
*/
|
|
171
211
|
const processMediaFile = (idMessage, fileBuffer, mimetype, filename) => __awaiter(void 0, void 0, void 0, function* () {
|
|
172
212
|
try {
|
|
173
|
-
|
|
213
|
+
// ⭐ Idioma, nombres y últimos mensajes solo hacen falta para transcribir voz
|
|
214
|
+
const kind = classifyFile(mimetype, filename, fileBuffer);
|
|
215
|
+
const contexto = kind === FILE_KIND.AUDIO || kind === FILE_KIND.VIDEO ? yield cargarContextoTranscripcion(idMessage) : null;
|
|
216
|
+
const fileText = yield analyzeFile(fileBuffer, mimetype, filename, contexto);
|
|
174
217
|
// ✅ Guardar el resultado en la base de datos
|
|
175
218
|
yield (0, index_1.query)('UPDATE [CHAT MESSAGE] SET [FILE TEXT] = ? WHERE [ID] = ?', [fileText, idMessage]);
|
|
176
219
|
}
|
|
177
220
|
catch (error) {
|
|
178
|
-
|
|
221
|
+
// eslint-disable-next-line no-console
|
|
222
|
+
console.error(`[processMediaFile] Error procesando el mensaje ${idMessage}:`, (error === null || error === void 0 ? void 0 : error.message) || error);
|
|
179
223
|
}
|
|
180
224
|
});
|
|
181
225
|
exports.processMediaFile = processMediaFile;
|
|
182
226
|
/**
|
|
183
|
-
*
|
|
184
|
-
*
|
|
227
|
+
* Almacenamiento donde viven los adjuntos del chat (`Chat/<id>`), construido con la configuración
|
|
228
|
+
* del propio servicio (`CLOUD_STORAGE_BUCKET` + clave de la cuenta de Azure).
|
|
185
229
|
*
|
|
186
|
-
* @
|
|
230
|
+
* @returns Contenedor con `file(ruta).exists()` y `download()`
|
|
231
|
+
*/
|
|
232
|
+
const almacenamientoAdjuntos = () => new almacenamiento_1.Storage().bucket(process.env.CLOUD_STORAGE_BUCKET);
|
|
233
|
+
/**
|
|
234
|
+
* Vuelve a procesar un adjunto ya subido a Storage: descarga, analiza con el mismo router que en
|
|
235
|
+
* vivo y guarda FILE TEXT (salvo en simulación).
|
|
236
|
+
*
|
|
237
|
+
* @param bucket - Contenedor con `file(ruta).exists()` y `download()`
|
|
238
|
+
* @param message - Fila con id, mimetype, text (nombre original del archivo) y, si se leyó, fileText actual
|
|
239
|
+
* @param dryRun - true para calcular el texto nuevo sin guardarlo
|
|
240
|
+
* @returns Texto anterior y nuevo, o null si el archivo no está en Storage
|
|
241
|
+
*/
|
|
242
|
+
const reprocesarMensaje = (bucket, message, dryRun = false) => __awaiter(void 0, void 0, void 0, function* () {
|
|
243
|
+
var _a;
|
|
244
|
+
const [fileExists] = yield bucket.file(`Chat/${message.id}`).exists();
|
|
245
|
+
if (!fileExists) {
|
|
246
|
+
// eslint-disable-next-line no-console
|
|
247
|
+
console.log(`[reprocessMedia] Archivo no encontrado: Chat/${message.id}`);
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
const [fileBuffer] = yield bucket.file(`Chat/${message.id}`).download();
|
|
251
|
+
const kind = classifyFile(message.mimetype, message.text, fileBuffer);
|
|
252
|
+
const contexto = kind === FILE_KIND.AUDIO || kind === FILE_KIND.VIDEO ? yield cargarContextoTranscripcion(message.id) : null;
|
|
253
|
+
// ⭐ Mismo router que en el envío en vivo
|
|
254
|
+
const fileText = yield analyzeFile(fileBuffer, message.mimetype, message.text, contexto);
|
|
255
|
+
if (!dryRun)
|
|
256
|
+
yield (0, index_1.query)('UPDATE [CHAT MESSAGE] SET [FILE TEXT] = ? WHERE [ID] = ?', [fileText, message.id]);
|
|
257
|
+
// eslint-disable-next-line no-console
|
|
258
|
+
console.log(`[reprocessMedia] ${dryRun ? '🔍 Simulado' : '✅ Procesado'} mensaje ${message.id}: ${fileText.substring(0, 50)}...`);
|
|
259
|
+
return { id: message.id, antes: (_a = message.fileText) !== null && _a !== void 0 ? _a : null, despues: fileText, guardado: !dryRun };
|
|
260
|
+
});
|
|
261
|
+
/**
|
|
262
|
+
* Reprocesa los adjuntos recientes (últimos 2 días) que no tienen FILE TEXT o cuya transcripción
|
|
263
|
+
* quedó en un marcador de fallo transitorio, sea cual sea su tipo. Útil tras un deploy o una caída
|
|
264
|
+
* del motor de transcripción. El marcador «sin voz» no se reintenta: es definitivo.
|
|
265
|
+
*
|
|
266
|
+
* @param bucket - Contenedor con `file(ruta).exists()` y `download()`; si no se pasa, el del propio servicio
|
|
187
267
|
* @returns Promise<{ processed: number; errors: number }> - Estadísticas del reprocesamiento
|
|
188
268
|
*
|
|
189
269
|
* @example
|
|
190
270
|
* ```typescript
|
|
191
|
-
* const stats = await reprocessRecentMedia(
|
|
271
|
+
* const stats = await reprocessRecentMedia();
|
|
192
272
|
* // Resultado: { processed: 5, errors: 1 }
|
|
193
273
|
* ```
|
|
194
274
|
*/
|
|
@@ -196,43 +276,88 @@ const reprocessRecentMedia = (bucket) => __awaiter(void 0, void 0, void 0, funct
|
|
|
196
276
|
let processed = 0;
|
|
197
277
|
let errors = 0;
|
|
198
278
|
try {
|
|
279
|
+
const contenedor = bucket || almacenamientoAdjuntos();
|
|
199
280
|
// ✅ Cualquier adjunto sin FILE TEXT, no solo audio e imagen
|
|
200
|
-
const messages = yield (0, index_1.query)(`SELECT "ID", "MIMETYPE", "TEXT" FROM "CHAT MESSAGE"
|
|
281
|
+
const messages = yield (0, index_1.query)(`SELECT "ID", "MIMETYPE", "TEXT", "FILE TEXT" FROM "CHAT MESSAGE"
|
|
201
282
|
WHERE "DATE" >= NOW() - INTERVAL '2 days'
|
|
202
|
-
AND "FILE TEXT" IS NULL
|
|
203
283
|
AND "MIMETYPE" IS NOT NULL
|
|
204
|
-
|
|
284
|
+
AND ("FILE TEXT" IS NULL OR "FILE TEXT" IN (?, ?))
|
|
285
|
+
ORDER BY "DATE" DESC`, [audio_1.MARCADOR_NO_TRANSCRITO, audio_1.MARCADOR_ANTIGUO]);
|
|
205
286
|
// eslint-disable-next-line no-console
|
|
206
287
|
console.log(`[reprocessRecentMedia] Encontrados ${messages.length} mensajes para procesar`);
|
|
207
288
|
for (const message of messages) {
|
|
208
289
|
try {
|
|
209
|
-
|
|
210
|
-
const [fileExists] = yield bucket.file(`Chat/${message.id}`).exists();
|
|
211
|
-
if (!fileExists) {
|
|
212
|
-
// eslint-disable-next-line no-console
|
|
213
|
-
console.log(`[reprocessRecentMedia] Archivo no encontrado: Chat/${message.id}`);
|
|
214
|
-
errors++;
|
|
215
|
-
continue;
|
|
216
|
-
}
|
|
217
|
-
const [fileBuffer] = yield bucket.file(`Chat/${message.id}`).download();
|
|
218
|
-
// ⭐ Mismo router que en el envío en vivo
|
|
219
|
-
const fileText = yield analyzeFile(fileBuffer, message.mimetype, message.text);
|
|
220
|
-
// ⭐ Guardar en BD
|
|
221
|
-
yield (0, index_1.query)('UPDATE [CHAT MESSAGE] SET [FILE TEXT] = ? WHERE [ID] = ?', [fileText, message.id]);
|
|
222
|
-
// eslint-disable-next-line no-console
|
|
223
|
-
console.log(`[reprocessRecentMedia] ✅ Procesado mensaje ${message.id}: ${fileText.substring(0, 50)}...`);
|
|
224
|
-
processed++;
|
|
290
|
+
(yield reprocesarMensaje(contenedor, message)) ? processed++ : errors++;
|
|
225
291
|
}
|
|
226
292
|
catch (msgError) {
|
|
227
293
|
// eslint-disable-next-line no-console
|
|
228
|
-
console.
|
|
294
|
+
console.error(`[reprocessRecentMedia] ❌ Error procesando mensaje ${message.id}:`, (msgError === null || msgError === void 0 ? void 0 : msgError.message) || msgError);
|
|
229
295
|
errors++;
|
|
230
296
|
}
|
|
231
297
|
}
|
|
232
298
|
}
|
|
233
299
|
catch (error) {
|
|
234
|
-
|
|
300
|
+
// eslint-disable-next-line no-console
|
|
301
|
+
console.error('[reprocessRecentMedia] Error:', (error === null || error === void 0 ? void 0 : error.message) || error);
|
|
235
302
|
}
|
|
236
303
|
return { processed, errors };
|
|
237
304
|
});
|
|
238
305
|
exports.reprocessRecentMedia = reprocessRecentMedia;
|
|
306
|
+
/**
|
|
307
|
+
* Reprocesa una lista concreta de adjuntos (p. ej. transcripciones que se sabe que salieron mal)
|
|
308
|
+
* con el pipeline actual. Con `dryRun` calcula el texto nuevo sin guardarlo, para ver antes y
|
|
309
|
+
* después. Sin `dryRun` escribe FILE TEXT: ejecutar a mano y con criterio.
|
|
310
|
+
*
|
|
311
|
+
* @param ids - IDs de CHAT MESSAGE
|
|
312
|
+
* @param opts - `dryRun` para simular; `bucket` para usar otro almacenamiento que el del propio servicio
|
|
313
|
+
* @returns Estadísticas y, por mensaje, texto anterior y nuevo
|
|
314
|
+
*
|
|
315
|
+
* @example
|
|
316
|
+
* ```typescript
|
|
317
|
+
* const { resultados } = await reprocessMediaByIds([523437, 501309], { dryRun: true });
|
|
318
|
+
* // resultados[0]: { id: 523437, antes: 'Hola Víctor…', despues: '[Audio sin voz reconocible]', guardado: false }
|
|
319
|
+
* ```
|
|
320
|
+
*/
|
|
321
|
+
const reprocessMediaByIds = (ids, opts = {}) => __awaiter(void 0, void 0, void 0, function* () {
|
|
322
|
+
let processed = 0;
|
|
323
|
+
let errors = 0;
|
|
324
|
+
const resultados = [];
|
|
325
|
+
// ⭐ Solo enteros: la lista va incrustada en el SQL
|
|
326
|
+
const validos = [...new Set((ids || []).map(Number).filter(Number.isInteger))];
|
|
327
|
+
if (!validos.length)
|
|
328
|
+
return { processed, errors, resultados };
|
|
329
|
+
try {
|
|
330
|
+
const contenedor = opts.bucket || almacenamientoAdjuntos();
|
|
331
|
+
const messages = yield (0, index_1.query)(`SELECT "ID", "MIMETYPE", "TEXT", "FILE TEXT" FROM "CHAT MESSAGE" WHERE "MIMETYPE" IS NOT NULL AND "ID" IN (${validos.join(',')}) ORDER BY "ID"`, []);
|
|
332
|
+
for (const message of messages) {
|
|
333
|
+
try {
|
|
334
|
+
const resultado = yield reprocesarMensaje(contenedor, message, !!opts.dryRun);
|
|
335
|
+
if (resultado) {
|
|
336
|
+
resultados.push(resultado);
|
|
337
|
+
processed++;
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
errors++;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
catch (msgError) {
|
|
344
|
+
// eslint-disable-next-line no-console
|
|
345
|
+
console.error(`[reprocessMediaByIds] ❌ Error procesando mensaje ${message.id}:`, (msgError === null || msgError === void 0 ? void 0 : msgError.message) || msgError);
|
|
346
|
+
errors++;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
// eslint-disable-next-line no-console
|
|
352
|
+
console.error('[reprocessMediaByIds] Error:', (error === null || error === void 0 ? void 0 : error.message) || error);
|
|
353
|
+
}
|
|
354
|
+
return { processed, errors, resultados };
|
|
355
|
+
});
|
|
356
|
+
exports.reprocessMediaByIds = reprocessMediaByIds;
|
|
357
|
+
var correccionTranscripcion_1 = require("./correccionTranscripcion");
|
|
358
|
+
Object.defineProperty(exports, "corregirTranscripcion", { enumerable: true, get: function () { return correccionTranscripcion_1.corregirTranscripcion; } });
|
|
359
|
+
Object.defineProperty(exports, "GLOSARIO_RUNNING", { enumerable: true, get: function () { return correccionTranscripcion_1.GLOSARIO_RUNNING; } });
|
|
360
|
+
var audio_2 = require("./audio");
|
|
361
|
+
Object.defineProperty(exports, "MARCADOR_SIN_VOZ", { enumerable: true, get: function () { return audio_2.MARCADOR_SIN_VOZ; } });
|
|
362
|
+
Object.defineProperty(exports, "MARCADOR_NO_TRANSCRITO", { enumerable: true, get: function () { return audio_2.MARCADOR_NO_TRANSCRITO; } });
|
|
363
|
+
Object.defineProperty(exports, "MARCADORES_AUDIO", { enumerable: true, get: function () { return audio_2.MARCADORES_AUDIO; } });
|