@runnerpro/backend 1.22.8 → 1.23.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.
@@ -508,6 +508,11 @@ const sendFile = (req, res, { sendNotification, firebaseMessaging, isClient, buc
508
508
  files.push({ data: fileData, id: idFile });
509
509
  }
510
510
  res.send({ idFile, logrosDesbloqueados });
511
+ // Procesar el adjunto en background, sea del tipo que sea: transcripción, descripción, resumen
512
+ // o ficha del archivo. El router de mediaProcessing decide qué toca. Va ANTES de subir a Storage
513
+ // a propósito: si la subida falla, el mensaje se quedaría sin FILE TEXT de forma permanente y
514
+ // eso bloquea al entrenador, que no distingue "sin texto" de "aún sin procesar".
515
+ (0, mediaProcessing_1.processMediaFile)(idFile, fileData, req.file.mimetype, req.file.originalname);
511
516
  for (const file of files) {
512
517
  yield bucket.file(`Chat/${file.id}`).save(file.data);
513
518
  }
@@ -530,10 +535,6 @@ const sendFile = (req, res, { sendNotification, firebaseMessaging, isClient, buc
530
535
  });
531
536
  yield updateSenderView({ userid, idCliente, idMessage: idFile });
532
537
  }
533
- // Procesar archivo multimedia en background (transcripción de audio / descripción de imagen)
534
- if (req.file.mimetype.startsWith('audio/') || req.file.mimetype.startsWith('image/')) {
535
- (0, mediaProcessing_1.processMediaFile)(idFile, fileData, req.file.mimetype);
536
- }
537
538
  });
538
539
  const getThumbnailFromVideo = (videoPath, duration) => __awaiter(void 0, void 0, void 0, function* () {
539
540
  const targetWidth = 500;
package/lib/cjs/index.js CHANGED
@@ -24,7 +24,7 @@ var __importStar = (this && this.__importStar) || function (mod) {
24
24
  };
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
26
  exports.describeImage = exports.transcribeAudio = exports.updateSenderView = exports.saveResponseTime = exports.getZone = exports.saveDoneStructuraWorkout = exports.computeStreakStats = exports.getUnlockedAchievements = exports.evaluateAchievementsBounded = exports.evaluateAchievements = exports.saveWorkoutLaps = exports.saveWorkoutAplication = exports.getPlanificacionPrueba7dias = exports.sendWorkoutToWatch = exports.getDefaultWorkoutImage = exports.generateShareMap = exports.reduceSizeImage = exports.getLetter = exports.getNumberByLetter = exports.appendSheet = exports.writeSheet = exports.findCellByValue = exports.readSheet = exports.NOTION_DATABASES_ID = exports.notionEditPage = exports.notionAddPage = exports.notionGetDatabase = exports.notionGetUsers = exports.getCountNotificaciones = exports.chatExposed = exports.chatApi = exports.chat = exports.getExerciseTranslatedDescription = exports.useTranslation = exports.LANGUAGES = exports.translate = exports.CHANNEL_SLACK = exports.notifySlack = exports.fetchIA = exports.err = exports.sendMail = exports.pool = exports.toPgArray = exports.batchQuery = exports.longRunningQuery = exports.queryWithClient = exports.query = exports.sleep = exports.sendNotification = exports.achievementsCatalog = void 0;
27
- exports.calculateCost = exports.MODEL_PRICING = exports.AISTUDIO_PREFIX = exports.BEDROCK_CLAUDE_HAIKU = exports.BEDROCK_CLAUDE_SONNET_4 = exports.BEDROCK_CLAUDE_SONNET_4_5 = exports.BEDROCK_CLAUDE_OPUS_4_1 = exports.BEDROCK_CLAUDE_OPUS_4_5 = exports.BEDROCK_CLAUDE_SONNET = exports.BEDROCK_CLAUDE_OPUS = exports.FALLBACK_MODELS = exports.PRODUCTION_MODELS = exports.AZURE_PRIMARY_MODELS = exports.AZURE_GPT_5_4_NANO = exports.AZURE_GPT_5_4_MINI = exports.AZURE_GPT_5_5 = exports.AZURE_GPT_5_4_NANO_DEPLOYMENT = exports.AZURE_GPT_5_4_MINI_DEPLOYMENT = exports.AZURE_GPT_5_5_DEPLOYMENT = exports.AZURE_PREFIX = exports.GOOGLE_MODELS = exports.AI_MODELS = exports.MODEL_TIER = exports.runWithModels = exports.runWithCostTracking = exports.createModelFromString = exports.createAzureModelFromString = exports.createBedrockModelFromString = exports.createGoogleModelFromString = exports.createGoogleModel = exports.generateText = exports.generateObject = exports.reprocessRecentMedia = exports.processMediaFile = void 0;
27
+ exports.calculateCost = exports.MODEL_PRICING = exports.AISTUDIO_PREFIX = exports.BEDROCK_CLAUDE_HAIKU = exports.BEDROCK_CLAUDE_SONNET_4 = exports.BEDROCK_CLAUDE_SONNET_4_5 = exports.BEDROCK_CLAUDE_OPUS_4_1 = exports.BEDROCK_CLAUDE_OPUS_4_5 = exports.BEDROCK_CLAUDE_SONNET = exports.BEDROCK_CLAUDE_OPUS = exports.FALLBACK_MODELS = exports.PRODUCTION_MODELS = exports.AZURE_PRIMARY_MODELS = exports.AZURE_GPT_5_4_NANO = exports.AZURE_GPT_5_4_MINI = exports.AZURE_GPT_5_5 = exports.AZURE_GPT_5_4_NANO_DEPLOYMENT = exports.AZURE_GPT_5_4_MINI_DEPLOYMENT = exports.AZURE_GPT_5_5_DEPLOYMENT = exports.AZURE_PREFIX = exports.GOOGLE_MODELS = exports.AI_MODELS = exports.MODEL_TIER = exports.runWithModels = exports.runWithCostTracking = exports.createModelFromString = exports.createAzureModelFromString = exports.createBedrockModelFromString = exports.createGoogleModelFromString = exports.createGoogleModel = exports.generateText = exports.generateObject = exports.reprocessRecentMedia = exports.processMediaFile = exports.analyzeFile = exports.describeTextFile = exports.describePdf = exports.describeVideo = void 0;
28
28
  const sendNotification_1 = require("./sendNotification");
29
29
  Object.defineProperty(exports, "sendNotification", { enumerable: true, get: function () { return sendNotification_1.sendNotification; } });
30
30
  const sleep_1 = require("./sleep");
@@ -102,6 +102,10 @@ Object.defineProperty(exports, "updateSenderView", { enumerable: true, get: func
102
102
  const mediaProcessing_1 = require("./mediaProcessing");
103
103
  Object.defineProperty(exports, "transcribeAudio", { enumerable: true, get: function () { return mediaProcessing_1.transcribeAudio; } });
104
104
  Object.defineProperty(exports, "describeImage", { enumerable: true, get: function () { return mediaProcessing_1.describeImage; } });
105
+ Object.defineProperty(exports, "describeVideo", { enumerable: true, get: function () { return mediaProcessing_1.describeVideo; } });
106
+ Object.defineProperty(exports, "describePdf", { enumerable: true, get: function () { return mediaProcessing_1.describePdf; } });
107
+ Object.defineProperty(exports, "describeTextFile", { enumerable: true, get: function () { return mediaProcessing_1.describeTextFile; } });
108
+ Object.defineProperty(exports, "analyzeFile", { enumerable: true, get: function () { return mediaProcessing_1.analyzeFile; } });
105
109
  Object.defineProperty(exports, "processMediaFile", { enumerable: true, get: function () { return mediaProcessing_1.processMediaFile; } });
106
110
  Object.defineProperty(exports, "reprocessRecentMedia", { enumerable: true, get: function () { return mediaProcessing_1.reprocessRecentMedia; } });
107
111
  const prompt_1 = require("./prompt");
@@ -0,0 +1,216 @@
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
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.SYNC_MAX_SECONDS = exports.AUDIO_ENCODING_MAP = exports.convertToWav16k = exports.transcribeWithModel = exports.transcribeWav16k = exports.transcribeAudio = void 0;
16
+ const fs_1 = __importDefault(require("fs"));
17
+ const path_1 = __importDefault(require("path"));
18
+ const speech_1 = require("@google-cloud/speech");
19
+ const ffmpegUtils_1 = require("./ffmpegUtils");
20
+ const index_1 = require("../prompt/index");
21
+ const constants_1 = require("../prompt/constants");
22
+ const index_2 = require("../err/index");
23
+ // ✅ Mapeo de MIME types a encoding de Speech-to-Text. Solo se usa en el camino de emergencia,
24
+ // cuando ffmpeg no ha podido convertir el archivo y hay que mandarlo tal cual.
25
+ const AUDIO_ENCODING_MAP = {
26
+ 'audio/mpeg': 'MP3',
27
+ 'audio/mp3': 'MP3',
28
+ 'audio/wav': 'LINEAR16',
29
+ 'audio/wave': 'LINEAR16',
30
+ 'audio/x-wav': 'LINEAR16',
31
+ 'audio/ogg': 'OGG_OPUS',
32
+ 'audio/webm': 'WEBM_OPUS',
33
+ 'audio/flac': 'FLAC',
34
+ 'audio/x-flac': 'FLAC',
35
+ 'audio/mp4': 'MP3',
36
+ 'audio/m4a': 'MP3',
37
+ 'audio/aac': 'MP3',
38
+ };
39
+ exports.AUDIO_ENCODING_MAP = AUDIO_ENCODING_MAP;
40
+ // ✅ `recognize` (síncrono) rechaza audio de más de 60s. Cuando se le declara LINEAR16 deduce la
41
+ // duración exacta de los bytes y lo aplica a rajatabla, así que por encima de esto no se le manda.
42
+ const SYNC_MAX_SECONDS = 55;
43
+ exports.SYNC_MAX_SECONDS = SYNC_MAX_SECONDS;
44
+ // ✅ Speech-to-Text admite 10 MB en línea; a 32 KB/s son ~290s. Gemini aguanta mucho más,
45
+ // pero recortamos para acotar el coste y la latencia de una nota de voz.
46
+ const TRANSCRIPTION_MAX_SECONDS = 600;
47
+ const TRANSCRIPTION_PROMPT = `Transcribe literalmente lo que se dice en este audio. Es una nota de voz que un corredor manda a su entrenador.
48
+ Devuelve SOLO la transcripción en su idioma original, sin comentarios, sin introducción y sin describir el audio.
49
+ Si de verdad no se oye ninguna voz, responde exactamente: SIN_VOZ`;
50
+ /**
51
+ * Llama a Speech-to-Text con un buffer y una configuración ya decididas
52
+ *
53
+ * @param content - Buffer del audio
54
+ * @param encoding - Encoding declarado (LINEAR16, MP3...)
55
+ * @returns Promise<string | null> - Transcripción, o null si no se reconoció nada
56
+ */
57
+ const recognizeSpeech = (content, encoding) => __awaiter(void 0, void 0, void 0, function* () {
58
+ var _a;
59
+ try {
60
+ const speechClient = new speech_1.SpeechClient();
61
+ // @ts-ignore - Tipos de Speech-to-Text son complejos
62
+ const [response] = yield speechClient.recognize({
63
+ audio: {
64
+ content: content.toString('base64'),
65
+ },
66
+ config: {
67
+ // @ts-ignore - El encoding se determina dinámicamente
68
+ encoding: encoding,
69
+ sampleRateHertz: ffmpegUtils_1.SPEECH_SAMPLE_RATE,
70
+ languageCode: 'es-ES', // Español de España
71
+ alternativeLanguageCodes: ['es-MX', 'es-AR', 'en-US'], // Alternativas: México, Argentina, Inglés
72
+ enableAutomaticPunctuation: true, // Puntuación automática
73
+ model: 'latest_long', // Modelo más reciente para audios largos
74
+ useEnhanced: true, // Modelo mejorado
75
+ },
76
+ });
77
+ const transcription = (_a = response.results) === null || _a === void 0 ? void 0 : _a.map((result) => { var _a, _b; return (_b = (_a = result.alternatives) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.transcript; }).filter(Boolean).join(' ').trim();
78
+ return transcription || null;
79
+ }
80
+ catch (error) {
81
+ // eslint-disable-next-line no-console
82
+ console.error('[transcribeAudio] Error Speech-to-Text:', (error === null || error === void 0 ? void 0 : error.message) || error);
83
+ return null;
84
+ }
85
+ });
86
+ /**
87
+ * Transcribe con un modelo multimodal de Google. Es la red que recoge lo que Speech-to-Text
88
+ * no reconoce y, sobre todo, los audios de más de 60s, que el modo síncrono no admite.
89
+ *
90
+ * @param wavBuffer - Buffer WAV mono 16 kHz
91
+ * @returns Promise<string | null> - Transcripción, o null si no hay voz
92
+ */
93
+ const transcribeWithModel = (wavBuffer) => __awaiter(void 0, void 0, void 0, function* () {
94
+ try {
95
+ const model = (0, index_1.createGoogleModelFromString)(constants_1.GOOGLE_MODELS.FLASH);
96
+ // El primario es Vertex; si el proyecto no lo tiene disponible se pasa a AI Studio
97
+ model._fallbackModelNames = [`${constants_1.AISTUDIO_PREFIX}${constants_1.GOOGLE_MODELS.FLASH}`, 'gemini-2.5-flash', `${constants_1.AISTUDIO_PREFIX}gemini-2.5-flash`];
98
+ const { text } = yield (0, index_1.generateText)({
99
+ model,
100
+ // ⭐ Sin registro en LLM CACHE: el audio no aporta nada como clave y engorda la tabla
101
+ log: false,
102
+ messages: [
103
+ {
104
+ role: 'user',
105
+ content: [
106
+ { type: 'file', data: wavBuffer, mediaType: 'audio/wav' },
107
+ { type: 'text', text: TRANSCRIPTION_PROMPT },
108
+ ],
109
+ },
110
+ ],
111
+ });
112
+ const transcription = (text || '').trim();
113
+ return !transcription || transcription === 'SIN_VOZ' ? null : transcription;
114
+ }
115
+ catch (error) {
116
+ // eslint-disable-next-line no-console
117
+ console.error('[transcribeAudio] Error transcribiendo con el modelo:', (error === null || error === void 0 ? void 0 : error.message) || error);
118
+ return null;
119
+ }
120
+ });
121
+ exports.transcribeWithModel = transcribeWithModel;
122
+ /**
123
+ * Convierte cualquier audio al formato que espera Speech-to-Text (WAV PCM mono 16 kHz)
124
+ *
125
+ * @param fileBuffer - Buffer del audio original
126
+ * @param filename - Nombre original del archivo
127
+ * @returns WAV mono 16 kHz y la duración original en segundos
128
+ */
129
+ const convertToWav16k = (fileBuffer, filename) => (0, ffmpegUtils_1.withTempDir)((dir) => __awaiter(void 0, void 0, void 0, function* () {
130
+ // ffmpeg detecta el formato por contenido, así que la extensión solo es cosmética
131
+ const extension = path_1.default.extname(filename || '') || '.m4a';
132
+ const inputPath = path_1.default.join(dir, `input${extension}`);
133
+ const outputPath = path_1.default.join(dir, 'audio.wav');
134
+ fs_1.default.writeFileSync(inputPath, fileBuffer);
135
+ const { duration } = yield (0, ffmpegUtils_1.probeMedia)(inputPath);
136
+ yield (0, ffmpegUtils_1.runFfmpeg)((0, ffmpegUtils_1.ffmpeg)(inputPath)
137
+ .noVideo()
138
+ .audioCodec('pcm_s16le')
139
+ .audioFrequency(ffmpegUtils_1.SPEECH_SAMPLE_RATE)
140
+ .audioChannels(1)
141
+ .duration(TRANSCRIPTION_MAX_SECONDS)
142
+ .format('wav')
143
+ .output(outputPath), 'conversión de audio');
144
+ return { wav: fs_1.default.readFileSync(outputPath), duration };
145
+ }));
146
+ exports.convertToWav16k = convertToWav16k;
147
+ /**
148
+ * Transcribe una nota de voz.
149
+ *
150
+ * El audio se normaliza a WAV mono 16 kHz y luego se elige motor por duración:
151
+ * - hasta 55s → Speech-to-Text, que es barato y preciso.
152
+ * - más de 55s → modelo multimodal, porque `recognize` no admite audios más largos y
153
+ * `longRunningRecognize` exige subir el archivo a GCS antes.
154
+ * - si Speech-to-Text no reconoce nada, se reintenta con el modelo antes de darlo por perdido.
155
+ *
156
+ * ⚠️ El orden importa y no es intercambiable. Medido sobre notas de voz reales de producción
157
+ * que hoy acaban en '[Audio sin contenido reconocible]':
158
+ * - mandar el AAC crudo declarando 'MP3' (lo que se hacía): 0 de 14 recuperadas.
159
+ * - convertir a WAV y declarar LINEAR16: 13 de 14 (la que falla es de 81s).
160
+ * - modelo multimodal: 14 de 14.
161
+ * Y a la inversa, convertir a WAV SIN respetar el límite de 55s rompe los audios largos que hoy
162
+ * sí funcionan (4 de 4 medidos): con LINEAR16 la API deduce la duración exacta y los rechaza,
163
+ * mientras que con un códec comprimido declarado se saltaba esa comprobación.
164
+ *
165
+ * @param fileBuffer - Buffer del archivo de audio
166
+ * @param mimetype - Tipo MIME del archivo (ej: 'audio/aac', 'audio/x-m4a')
167
+ * @param filename - Nombre original del archivo
168
+ * @returns Promise<string> - Transcripción del audio
169
+ *
170
+ * @example
171
+ * ```typescript
172
+ * const transcription = await transcribeAudio(audioBuffer, 'audio/aac', 'audio.aac');
173
+ * // Resultado: "Hola, quería comentarte que hoy me duele un poco la rodilla..."
174
+ * ```
175
+ */
176
+ const transcribeAudio = (fileBuffer, mimetype, filename) => __awaiter(void 0, void 0, void 0, function* () {
177
+ let wav;
178
+ let duration;
179
+ try {
180
+ ({ wav, duration } = yield convertToWav16k(fileBuffer, filename));
181
+ }
182
+ catch (error) {
183
+ // ⭐ Sin ffmpeg no se puede elegir motor: se manda el original tal cual, como se hacía antes
184
+ // eslint-disable-next-line no-console
185
+ console.error('[transcribeAudio] No se pudo convertir el audio, se envía sin normalizar:', (error === null || error === void 0 ? void 0 : error.message) || error);
186
+ (0, index_2.err)(null, null, error, null);
187
+ const mime = (mimetype || '').toLowerCase().split(';')[0].trim();
188
+ return (yield recognizeSpeech(fileBuffer, AUDIO_ENCODING_MAP[mime] || 'MP3')) || '[Audio sin contenido reconocible]';
189
+ }
190
+ if (duration > 0 && duration <= SYNC_MAX_SECONDS) {
191
+ const transcription = yield recognizeSpeech(wav, 'LINEAR16');
192
+ if (transcription)
193
+ return transcription;
194
+ }
195
+ const fromModel = yield transcribeWithModel(wav);
196
+ if (fromModel) {
197
+ return duration > TRANSCRIPTION_MAX_SECONDS
198
+ ? `${fromModel} (Solo se transcribieron los primeros ${Math.round(TRANSCRIPTION_MAX_SECONDS / 60)} minutos.)`
199
+ : fromModel;
200
+ }
201
+ return '[Audio sin contenido reconocible]';
202
+ });
203
+ exports.transcribeAudio = transcribeAudio;
204
+ /**
205
+ * Transcribe un WAV que ya viene en el formato que espera Speech-to-Text
206
+ * (PCM 16 bits, mono, 16 kHz) y dura menos de 55 segundos. Lo usa el análisis de vídeo,
207
+ * que extrae la pista así y ya la recorta.
208
+ *
209
+ * @param wavBuffer - Buffer WAV mono 16 kHz de menos de 55 segundos
210
+ * @returns Promise<string> - Transcripción del audio
211
+ */
212
+ const transcribeWav16k = (wavBuffer) => __awaiter(void 0, void 0, void 0, function* () {
213
+ const transcription = (yield recognizeSpeech(wavBuffer, 'LINEAR16')) || (yield transcribeWithModel(wavBuffer));
214
+ return transcription || '[Audio sin contenido reconocible]';
215
+ });
216
+ exports.transcribeWav16k = transcribeWav16k;
@@ -0,0 +1,225 @@
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.MAX_MODEL_FILE_BYTES = exports.formatBytes = exports.looksLikeText = exports.decodeTextBuffer = exports.describeOpaqueFile = exports.describeTextFile = exports.describePdf = void 0;
13
+ const index_1 = require("../prompt/index");
14
+ const index_2 = require("../err/index");
15
+ // ✅ Límite de tamaño para mandar un documento entero al modelo (por encima, solo ficha)
16
+ const MAX_MODEL_FILE_BYTES = 15 * 1024 * 1024;
17
+ exports.MAX_MODEL_FILE_BYTES = MAX_MODEL_FILE_BYTES;
18
+ // ✅ Cuánto texto plano se le pasa al modelo (~10k tokens); el resto se recorta
19
+ const MAX_TEXT_CHARS = 40000;
20
+ // ✅ Por debajo de este tamaño, el texto se guarda tal cual en vez de resumirlo
21
+ const VERBATIM_TEXT_CHARS = 2000;
22
+ const PDF_DESCRIPTION_PROMPT = `Resume este documento en un solo párrafo continuo, sin introducciones ni listas.
23
+ Si es contenido deportivo o de salud (informe médico, prueba de esfuerzo, analítica, plan de entrenamiento, inscripción a una carrera, factura de material, resultados, etc.), indica de qué tipo de documento se trata y extrae los datos concretos que importan para entender el estado o el entrenamiento del atleta: fechas, diagnósticos, valores medidos con sus unidades, distancias, tiempos, recomendaciones y restricciones.
24
+ Si NO es deportivo, resume su contenido de forma general pero útil para entender el contexto de la conversación.
25
+ Devuelve ÚNICAMENTE el resumen en español, sin comentarios adicionales. Máximo 400 palabras.`;
26
+ const TEXT_SUMMARY_PROMPT = `Resume el contenido de este archivo en un solo párrafo continuo, sin introducciones ni listas.
27
+ Indica de qué tipo de archivo se trata y extrae los datos concretos que importan para entender el estado o el entrenamiento del atleta: fechas, distancias, tiempos, ritmos, frecuencias cardíacas, desniveles, valores medidos con sus unidades.
28
+ Si el archivo es una traza GPS o un registro de actividad, resume el recorrido y sus métricas agregadas en vez de enumerar puntos.
29
+ Devuelve ÚNICAMENTE el resumen en español, sin comentarios adicionales. Máximo 300 palabras.`;
30
+ // ✅ Nombres legibles para los tipos que no se pueden abrir, para que la ficha diga algo útil
31
+ const MIME_LABELS = {
32
+ 'application/zip': 'archivo comprimido ZIP',
33
+ 'application/x-zip-compressed': 'archivo comprimido ZIP',
34
+ 'application/x-rar-compressed': 'archivo comprimido RAR',
35
+ 'application/x-7z-compressed': 'archivo comprimido 7z',
36
+ 'application/gzip': 'archivo comprimido GZIP',
37
+ 'application/msword': 'documento de Word (.doc)',
38
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'documento de Word (.docx)',
39
+ 'application/vnd.ms-excel': 'hoja de cálculo de Excel (.xls)',
40
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'hoja de cálculo de Excel (.xlsx)',
41
+ 'application/vnd.ms-powerpoint': 'presentación de PowerPoint (.ppt)',
42
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'presentación de PowerPoint (.pptx)',
43
+ 'application/vnd.oasis.opendocument.text': 'documento OpenDocument',
44
+ 'application/vnd.ant.fit': 'archivo de actividad FIT del reloj',
45
+ 'application/octet-stream': 'archivo binario',
46
+ 'text/vcard': 'contacto (vCard)',
47
+ 'text/x-vcard': 'contacto (vCard)',
48
+ };
49
+ /**
50
+ * Formatea un tamaño en bytes de forma legible en español (1024 → "1 KB")
51
+ *
52
+ * @param bytes - Tamaño en bytes
53
+ * @returns Tamaño legible con unidad
54
+ */
55
+ const formatBytes = (bytes) => {
56
+ if (!bytes || bytes < 1024)
57
+ return `${bytes || 0} B`;
58
+ const units = ['KB', 'MB', 'GB'];
59
+ let value = bytes / 1024;
60
+ let unitIdx = 0;
61
+ while (value >= 1024 && unitIdx < units.length - 1) {
62
+ value /= 1024;
63
+ unitIdx++;
64
+ }
65
+ return `${value.toFixed(1).replace('.', ',').replace(',0', '')} ${units[unitIdx]}`;
66
+ };
67
+ exports.formatBytes = formatBytes;
68
+ /**
69
+ * Heurística para saber si un buffer es texto plano legible.
70
+ * Útil cuando el cliente manda un .gpx o un .csv como 'application/octet-stream'.
71
+ *
72
+ * @param fileBuffer - Buffer del archivo
73
+ * @returns true si los primeros KB parecen texto UTF-8
74
+ */
75
+ const looksLikeText = (fileBuffer) => {
76
+ const sample = fileBuffer.subarray(0, 4096);
77
+ if (!sample.length)
78
+ return false;
79
+ let printable = 0;
80
+ for (const byte of sample) {
81
+ if (byte === 0)
82
+ return false; // ⭐ un byte nulo descarta texto plano
83
+ // Imprimibles ASCII + tabulador/salto de línea/retorno, o cualquier byte alto (UTF-8 multibyte)
84
+ if (byte >= 32 || byte === 9 || byte === 10 || byte === 13)
85
+ printable++;
86
+ }
87
+ return printable / sample.length > 0.9;
88
+ };
89
+ exports.looksLikeText = looksLikeText;
90
+ /**
91
+ * Decodifica un buffer de texto respetando su BOM.
92
+ *
93
+ * Importa porque los .txt y .csv guardados como "Unicode" desde el Bloc de notas o Excel vienen
94
+ * en UTF-16: leerlos como UTF-8 intercala bytes nulos, y Postgres rechaza el carácter nulo en una
95
+ * columna text, así que el UPDATE de FILE TEXT fallaría (en silencio, porque query() se traga el
96
+ * error) y el adjunto se quedaría sin texto para siempre.
97
+ *
98
+ * @param fileBuffer - Buffer del archivo
99
+ * @returns Contenido decodificado
100
+ */
101
+ const decodeTextBuffer = (fileBuffer) => {
102
+ // UTF-16 LE
103
+ if (fileBuffer.length >= 2 && fileBuffer[0] === 0xff && fileBuffer[1] === 0xfe) {
104
+ return fileBuffer.subarray(2).toString('utf16le');
105
+ }
106
+ // UTF-16 BE: Node solo sabe leer LE, así que se le da la vuelta a cada par de bytes
107
+ if (fileBuffer.length >= 2 && fileBuffer[0] === 0xfe && fileBuffer[1] === 0xff) {
108
+ const body = Buffer.from(fileBuffer.subarray(2, fileBuffer.length - ((fileBuffer.length - 2) % 2)));
109
+ body.swap16();
110
+ return body.toString('utf16le');
111
+ }
112
+ // BOM UTF-8
113
+ if (fileBuffer.length >= 3 && fileBuffer[0] === 0xef && fileBuffer[1] === 0xbb && fileBuffer[2] === 0xbf) {
114
+ return fileBuffer.subarray(3).toString('utf8');
115
+ }
116
+ return fileBuffer.toString('utf8');
117
+ };
118
+ exports.decodeTextBuffer = decodeTextBuffer;
119
+ /**
120
+ * Genera un resumen de un PDF mandándoselo entero al modelo, que lo lee de forma nativa
121
+ *
122
+ * @param fileBuffer - Buffer del PDF
123
+ * @param filename - Nombre original del archivo (ayuda al modelo a contextualizar)
124
+ * @returns Promise<string> - Resumen del documento
125
+ *
126
+ * @example
127
+ * ```typescript
128
+ * const summary = await describePdf(pdfBuffer, 'informe-traumatologo.pdf');
129
+ * // Resultado: "Informe de traumatología del 12/03 con diagnóstico de fascitis plantar..."
130
+ * ```
131
+ */
132
+ const describePdf = (fileBuffer, filename) => __awaiter(void 0, void 0, void 0, function* () {
133
+ try {
134
+ const { text } = yield (0, index_1.generateText)({
135
+ model: (0, index_1.createGoogleModel)('PRO'),
136
+ messages: [
137
+ {
138
+ role: 'user',
139
+ content: [
140
+ { type: 'file', data: fileBuffer, mediaType: 'application/pdf', filename: filename || 'documento.pdf' },
141
+ { type: 'text', text: PDF_DESCRIPTION_PROMPT },
142
+ ],
143
+ },
144
+ ],
145
+ });
146
+ return text || '[Documento no analizable]';
147
+ }
148
+ catch (error) {
149
+ // eslint-disable-next-line no-console
150
+ console.error('[describePdf] Error:', (error === null || error === void 0 ? void 0 : error.message) || error);
151
+ (0, index_2.err)(null, null, error, null);
152
+ return '[Error al analizar documento]';
153
+ }
154
+ });
155
+ exports.describePdf = describePdf;
156
+ /**
157
+ * Extrae el contenido de un archivo de texto (txt, csv, gpx, json, xml...).
158
+ * Los archivos cortos se guardan literales; los largos se resumen con el modelo.
159
+ *
160
+ * @param fileBuffer - Buffer del archivo
161
+ * @param filename - Nombre original del archivo
162
+ * @returns Promise<string> - Contenido literal o resumen
163
+ *
164
+ * @example
165
+ * ```typescript
166
+ * const content = await describeTextFile(gpxBuffer, 'ruta-domingo.gpx');
167
+ * // Resultado: "Traza GPS de una carrera de 12,4 km con 340 m de desnivel..."
168
+ * ```
169
+ */
170
+ const describeTextFile = (fileBuffer, filename) => __awaiter(void 0, void 0, void 0, function* () {
171
+ const content = decodeTextBuffer(fileBuffer).trim();
172
+ if (!content)
173
+ return '[Archivo de texto vacío]';
174
+ // ⭐ Si cabe entero y es corto, no gastamos una llamada al modelo
175
+ if (content.length <= VERBATIM_TEXT_CHARS) {
176
+ return filename ? `Contenido de ${filename}: ${content}` : content;
177
+ }
178
+ const truncated = content.slice(0, MAX_TEXT_CHARS);
179
+ const wasTruncated = content.length > MAX_TEXT_CHARS;
180
+ try {
181
+ const { text } = yield (0, index_1.generateText)({
182
+ model: (0, index_1.createGoogleModel)('PRO'),
183
+ prompt: `${TEXT_SUMMARY_PROMPT}
184
+
185
+ Nombre del archivo: ${filename || 'desconocido'}
186
+ ${wasTruncated ? `(Contenido recortado a los primeros ${MAX_TEXT_CHARS} caracteres de ${content.length}.)` : ''}
187
+
188
+ --- CONTENIDO ---
189
+ ${truncated}`,
190
+ });
191
+ return text || '[Archivo de texto no analizable]';
192
+ }
193
+ catch (error) {
194
+ // eslint-disable-next-line no-console
195
+ console.error('[describeTextFile] Error:', (error === null || error === void 0 ? void 0 : error.message) || error);
196
+ (0, index_2.err)(null, null, error, null);
197
+ // ⭐ Si el modelo falla, al menos guardamos el principio del archivo
198
+ return `Contenido de ${filename || 'archivo de texto'} (recortado): ${content.slice(0, VERBATIM_TEXT_CHARS)}`;
199
+ }
200
+ });
201
+ exports.describeTextFile = describeTextFile;
202
+ /**
203
+ * Ficha determinista para los archivos cuyo contenido no se puede extraer
204
+ * (comprimidos, binarios propietarios, ofimática antigua...).
205
+ *
206
+ * No llama a ningún modelo: su valor es que FILE TEXT nunca se quede a NULL, de forma
207
+ * que el entrenador sabe que hay un adjunto y de qué tipo, en vez de quedarse bloqueado.
208
+ *
209
+ * @param mimetype - Tipo MIME del archivo
210
+ * @param filename - Nombre original del archivo
211
+ * @param size - Tamaño en bytes
212
+ * @returns Ficha del archivo en español
213
+ *
214
+ * @example
215
+ * ```typescript
216
+ * describeOpaqueFile('application/zip', 'fotos.zip', 2515000);
217
+ * // "[Archivo adjunto: fotos.zip (archivo comprimido ZIP, 2,4 MB). No se puede leer su contenido automáticamente.]"
218
+ * ```
219
+ */
220
+ const describeOpaqueFile = (mimetype, filename, size) => {
221
+ const label = MIME_LABELS[mimetype] || mimetype || 'tipo desconocido';
222
+ const name = filename || 'sin nombre';
223
+ return `[Archivo adjunto: ${name} (${label}, ${formatBytes(size || 0)}). No se puede leer su contenido automáticamente.]`;
224
+ };
225
+ exports.describeOpaqueFile = describeOpaqueFile;
@@ -0,0 +1,107 @@
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
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
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;
16
+ const fs_1 = __importDefault(require("fs"));
17
+ const os_1 = __importDefault(require("os"));
18
+ const path_1 = __importDefault(require("path"));
19
+ const fluent_ffmpeg_1 = __importDefault(require("fluent-ffmpeg"));
20
+ exports.ffmpeg = fluent_ffmpeg_1.default;
21
+ const ffmpeg_static_1 = __importDefault(require("ffmpeg-static"));
22
+ const ffprobe_static_1 = __importDefault(require("ffprobe-static"));
23
+ fluent_ffmpeg_1.default.setFfmpegPath(ffmpeg_static_1.default);
24
+ fluent_ffmpeg_1.default.setFfprobePath(ffprobe_static_1.default.path);
25
+ // ✅ Techo por si ffmpeg se cuelga: sin él la promesa nunca se resuelve, el finally no llega
26
+ // a borrar el temporal (que en Cloud Run vive en RAM) y el adjunto se queda sin FILE TEXT
27
+ const FFMPEG_TIMEOUT_MS = 120000;
28
+ exports.FFMPEG_TIMEOUT_MS = FFMPEG_TIMEOUT_MS;
29
+ // ✅ Formato que espera Speech-to-Text: WAV PCM 16 bits, mono, 16 kHz
30
+ const SPEECH_SAMPLE_RATE = 16000;
31
+ exports.SPEECH_SAMPLE_RATE = SPEECH_SAMPLE_RATE;
32
+ /**
33
+ * Ejecuta un comando de ffmpeg como promesa, matándolo si se pasa del tiempo máximo
34
+ *
35
+ * @param command - Comando de fluent-ffmpeg ya configurado
36
+ * @param label - Etiqueta para el mensaje de error
37
+ */
38
+ const runFfmpeg = (command, label) => new Promise((resolve, reject) => {
39
+ let settled = false;
40
+ const finish = (fn) => {
41
+ if (settled)
42
+ return;
43
+ settled = true;
44
+ clearTimeout(timer);
45
+ fn();
46
+ };
47
+ const timer = setTimeout(() => {
48
+ finish(() => {
49
+ try {
50
+ command.kill('SIGKILL');
51
+ }
52
+ catch (_a) {
53
+ // el proceso ya no está; da igual
54
+ }
55
+ reject(new Error(`${label}: ffmpeg superó ${FFMPEG_TIMEOUT_MS / 1000}s`));
56
+ });
57
+ }, FFMPEG_TIMEOUT_MS);
58
+ command.on('end', () => finish(resolve)).on('error', (error) => finish(() => reject(error))).run();
59
+ });
60
+ exports.runFfmpeg = runFfmpeg;
61
+ /**
62
+ * Lee duración y pistas disponibles de un archivo multimedia con ffprobe
63
+ *
64
+ * @param inputPath - Ruta del archivo en disco
65
+ * @returns Duración en segundos y si tiene pista de vídeo y de audio
66
+ */
67
+ const probeMedia = (inputPath) => new Promise((resolve, reject) => {
68
+ // ⭐ Si ffprobe no contesta, la promesa debe rechazar igualmente para que el finally
69
+ // llegue a borrar el temporal
70
+ const timer = setTimeout(() => reject(new Error(`ffprobe superó ${FFMPEG_TIMEOUT_MS / 1000}s`)), FFMPEG_TIMEOUT_MS);
71
+ fluent_ffmpeg_1.default.ffprobe(inputPath, (error, data) => {
72
+ var _a;
73
+ clearTimeout(timer);
74
+ if (error)
75
+ return reject(error);
76
+ const streams = (data === null || data === void 0 ? void 0 : data.streams) || [];
77
+ resolve({
78
+ duration: Number((_a = data === null || data === void 0 ? void 0 : data.format) === null || _a === void 0 ? void 0 : _a.duration) || 0,
79
+ hasAudio: streams.some((stream) => stream.codec_type === 'audio'),
80
+ hasVideo: streams.some((stream) => stream.codec_type === 'video'),
81
+ });
82
+ });
83
+ });
84
+ exports.probeMedia = probeMedia;
85
+ /**
86
+ * Crea un directorio temporal, ejecuta el trabajo y lo borra pase lo que pase.
87
+ * En Cloud Run el temporal vive en RAM, así que dejarlo sin borrar cuesta memoria.
88
+ *
89
+ * @param work - Función que recibe la ruta del directorio temporal
90
+ * @returns Lo que devuelva `work`
91
+ */
92
+ // eslint-disable-next-line no-unused-vars -- el nombre del parámetro es obligatorio en un tipo función
93
+ const withTempDir = (work) => __awaiter(void 0, void 0, void 0, function* () {
94
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'rp-media-'));
95
+ try {
96
+ return yield work(dir);
97
+ }
98
+ finally {
99
+ try {
100
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
101
+ }
102
+ catch (_a) {
103
+ // El temporal se queda; no merece romper el procesado
104
+ }
105
+ }
106
+ });
107
+ exports.withTempDir = withTempDir;
@@ -0,0 +1,59 @@
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.IMAGE_DESCRIPTION_PROMPT = exports.describeImage = void 0;
13
+ const index_1 = require("../prompt/index");
14
+ const index_2 = require("../err/index");
15
+ // ✅ PROMPT PARA DESCRIPCIÓN DE IMÁGENES
16
+ const IMAGE_DESCRIPTION_PROMPT = `Describe esta imagen de forma directa y completa en un solo párrafo continuo, sin introducciones ni listas.
17
+ Si la imagen es de contenido deportivo (captura de Garmin/Strava/reloj deportivo, foto de lesión/dolor, gráfica de entrenamiento, selfie deportivo, foto de equipamiento, datos de entrenamiento, etc.), incluye el tipo de imagen, los datos relevantes si es una captura (ritmo, distancia, frecuencia cardíaca, zonas, etc.), y cualquier contexto útil para entender el estado del atleta o su entrenamiento.
18
+ Si la imagen NO es deportiva, describe su contenido de forma general pero útil para entender el contexto de la conversación.
19
+ Devuelve ÚNICAMENTE la descripción en español, sin comentarios adicionales ni formato de lista. Máximo 300 palabras.`;
20
+ exports.IMAGE_DESCRIPTION_PROMPT = IMAGE_DESCRIPTION_PROMPT;
21
+ /**
22
+ * Genera una descripción de una imagen usando el modelo de visión (Vercel AI SDK)
23
+ * Contextualizada para una aplicación de fitness/entrenamiento
24
+ *
25
+ * @param fileBuffer - Buffer del archivo de imagen
26
+ * @param mimetype - Tipo MIME del archivo (ej: 'image/jpeg', 'image/png')
27
+ * @param promptOverride - Prompt alternativo (ej: para fotogramas extraídos de un vídeo)
28
+ * @returns Promise<string> - Descripción de la imagen
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * const description = await describeImage(imageBuffer, 'image/jpeg');
33
+ * // Resultado: "Captura de Garmin Connect mostrando un entrenamiento de 10km..."
34
+ * ```
35
+ */
36
+ const describeImage = (fileBuffer, mimetype, promptOverride) => __awaiter(void 0, void 0, void 0, function* () {
37
+ try {
38
+ const { text } = yield (0, index_1.generateText)({
39
+ model: (0, index_1.createGoogleModel)('PRO'),
40
+ messages: [
41
+ {
42
+ role: 'user',
43
+ content: [
44
+ { type: 'file', data: fileBuffer, mediaType: mimetype },
45
+ { type: 'text', text: promptOverride || IMAGE_DESCRIPTION_PROMPT },
46
+ ],
47
+ },
48
+ ],
49
+ });
50
+ return text || '[Imagen no analizable]';
51
+ }
52
+ catch (error) {
53
+ // eslint-disable-next-line no-console
54
+ console.error('[describeImage] Error:', (error === null || error === void 0 ? void 0 : error.message) || error);
55
+ (0, index_2.err)(null, null, error, null);
56
+ return '[Error al describir imagen]';
57
+ }
58
+ });
59
+ exports.describeImage = describeImage;