@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.
Files changed (28) hide show
  1. package/lib/cjs/index.js +8 -2
  2. package/lib/cjs/mediaProcessing/audio.js +169 -155
  3. package/lib/cjs/mediaProcessing/azureSpeech.js +114 -0
  4. package/lib/cjs/mediaProcessing/azureTranscribe.js +73 -0
  5. package/lib/cjs/mediaProcessing/correccionTranscripcion.js +313 -0
  6. package/lib/cjs/mediaProcessing/ffmpegUtils.js +41 -2
  7. package/lib/cjs/mediaProcessing/index.js +157 -32
  8. package/lib/cjs/mediaProcessing/video.js +4 -4
  9. package/lib/cjs/types/index.d.ts +3 -2
  10. package/lib/cjs/types/index.d.ts.map +1 -1
  11. package/lib/cjs/types/mediaProcessing/audio.d.ts +31 -36
  12. package/lib/cjs/types/mediaProcessing/audio.d.ts.map +1 -1
  13. package/lib/cjs/types/mediaProcessing/azureSpeech.d.ts +38 -0
  14. package/lib/cjs/types/mediaProcessing/azureSpeech.d.ts.map +1 -0
  15. package/lib/cjs/types/mediaProcessing/azureTranscribe.d.ts +26 -0
  16. package/lib/cjs/types/mediaProcessing/azureTranscribe.d.ts.map +1 -0
  17. package/lib/cjs/types/mediaProcessing/correccionTranscripcion.d.ts +49 -0
  18. package/lib/cjs/types/mediaProcessing/correccionTranscripcion.d.ts.map +1 -0
  19. package/lib/cjs/types/mediaProcessing/ffmpegUtils.d.ts +20 -1
  20. package/lib/cjs/types/mediaProcessing/ffmpegUtils.d.ts.map +1 -1
  21. package/lib/cjs/types/mediaProcessing/index.d.ts +42 -8
  22. package/lib/cjs/types/mediaProcessing/index.d.ts.map +1 -1
  23. package/lib/cjs/types/mediaProcessing/video.d.ts +2 -1
  24. package/lib/cjs/types/mediaProcessing/video.d.ts.map +1 -1
  25. package/lib/cjs/types/workout/saveWorkoutAplication.d.ts +22 -1
  26. package/lib/cjs/types/workout/saveWorkoutAplication.d.ts.map +1 -1
  27. package/lib/cjs/workout/saveWorkoutAplication.js +94 -2
  28. 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 espera Speech-to-Text: WAV PCM 16 bits, mono, 16 kHz
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 index_2 = require("../err/index");
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
- const fileText = yield analyzeFile(fileBuffer, mimetype, filename);
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
- (0, index_2.err)(null, null, error, null);
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
- * Reprocesa los adjuntos recientes (últimos 2 días) que no tienen FILE TEXT,
184
- * sea cual sea su tipo. Útil para rellenar mensajes anteriores al deploy.
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
- * @param bucket - Instancia del bucket de Google Cloud Storage
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(bucket);
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
- ORDER BY "DATE" DESC`, []);
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
- // Descargar archivo de Storage
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.log(`[reprocessRecentMedia] ❌ Error procesando mensaje ${message.id}:`, msgError);
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
- (0, index_2.err)(null, null, error, null);
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; } });