@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.
@@ -8,161 +8,180 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
8
8
  step((generator = generator.apply(thisArg, _arguments || [])).next());
9
9
  });
10
10
  };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
11
14
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.reprocessRecentMedia = exports.processMediaFile = exports.describeImage = exports.transcribeAudio = void 0;
13
- const speech_1 = require("@google-cloud/speech");
14
- const index_1 = require("../prompt/index");
15
- const index_2 = require("../db/index");
16
- const index_3 = require("../err/index");
17
- // PROMPT PARA DESCRIPCIÓN DE IMÁGENES (Gemini)
18
- const IMAGE_DESCRIPTION_PROMPT = `Describe esta imagen de forma directa y completa en un solo párrafo continuo, sin introducciones ni listas.
19
- 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.
20
- Si la imagen NO es deportiva, describe su contenido de forma general pero útil para entender el contexto de la conversación.
21
- Devuelve ÚNICAMENTE la descripción en español, sin comentarios adicionales ni formato de lista. Máximo 300 palabras.`;
22
- // Mapeo de MIME types a encoding de Speech-to-Text
23
- const AUDIO_ENCODING_MAP = {
24
- 'audio/mpeg': 'MP3',
25
- 'audio/mp3': 'MP3',
26
- 'audio/wav': 'LINEAR16',
27
- 'audio/wave': 'LINEAR16',
28
- 'audio/x-wav': 'LINEAR16',
29
- 'audio/ogg': 'OGG_OPUS',
30
- 'audio/webm': 'WEBM_OPUS',
31
- 'audio/flac': 'FLAC',
32
- 'audio/x-flac': 'FLAC',
33
- 'audio/mp4': 'MP3',
34
- 'audio/m4a': 'MP3',
35
- 'audio/aac': 'MP3',
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;
16
+ const path_1 = __importDefault(require("path"));
17
+ const index_1 = require("../db/index");
18
+ const index_2 = require("../err/index");
19
+ const audio_1 = require("./audio");
20
+ Object.defineProperty(exports, "transcribeAudio", { enumerable: true, get: function () { return audio_1.transcribeAudio; } });
21
+ const image_1 = require("./image");
22
+ Object.defineProperty(exports, "describeImage", { enumerable: true, get: function () { return image_1.describeImage; } });
23
+ const video_1 = require("./video");
24
+ Object.defineProperty(exports, "describeVideo", { enumerable: true, get: function () { return video_1.describeVideo; } });
25
+ const documents_1 = require("./documents");
26
+ Object.defineProperty(exports, "describePdf", { enumerable: true, get: function () { return documents_1.describePdf; } });
27
+ Object.defineProperty(exports, "describeTextFile", { enumerable: true, get: function () { return documents_1.describeTextFile; } });
28
+ Object.defineProperty(exports, "describeOpaqueFile", { enumerable: true, get: function () { return documents_1.describeOpaqueFile; } });
29
+ // ✅ Tipos de archivo que sabemos tratar, cada uno con su extractor
30
+ const FILE_KIND = {
31
+ AUDIO: 'AUDIO',
32
+ IMAGE: 'IMAGE',
33
+ VIDEO: 'VIDEO',
34
+ PDF: 'PDF',
35
+ TEXT: 'TEXT',
36
+ OPAQUE: 'OPAQUE',
36
37
  };
38
+ exports.FILE_KIND = FILE_KIND;
39
+ // ✅ MIME types que son texto plano aunque no empiecen por 'text/'
40
+ const TEXT_MIME_TYPES = new Set([
41
+ 'application/json',
42
+ 'application/ld+json',
43
+ 'application/x-ndjson',
44
+ 'application/xml',
45
+ 'application/javascript',
46
+ 'application/x-yaml',
47
+ 'image/svg+xml', // ⭐ es una imagen, pero el modelo de visión no la renderiza: se lee como XML
48
+ ]);
49
+ // ✅ Extensiones de texto, para cuando el cliente sube un .gpx o un .csv como 'application/octet-stream'
50
+ const TEXT_EXTENSIONS = new Set([
51
+ '.txt',
52
+ '.csv',
53
+ '.tsv',
54
+ '.md',
55
+ '.json',
56
+ '.xml',
57
+ '.gpx',
58
+ '.tcx',
59
+ '.log',
60
+ '.srt',
61
+ '.vtt',
62
+ '.yaml',
63
+ '.yml',
64
+ '.html',
65
+ '.htm',
66
+ '.svg',
67
+ ]);
37
68
  /**
38
- * Transcribe un archivo de audio usando Google Cloud Speech-to-Text
39
- * Servicio especializado en reconocimiento de voz con soporte para 125+ idiomas
40
- *
41
- * @param fileBuffer - Buffer del archivo de audio
42
- * @param mimetype - Tipo MIME del archivo (ej: 'audio/mpeg', 'audio/wav')
43
- * @returns Promise<string> - Transcripción del audio en español
69
+ * Decide qué extractor le toca a un archivo a partir de su MIME type, su nombre
70
+ * y, como último recurso, el contenido del propio buffer.
44
71
  *
45
- * @example
46
- * ```typescript
47
- * const transcription = await transcribeAudio(audioBuffer, 'audio/mpeg');
48
- * // Resultado: "Hola, quería comentarte que hoy me duele un poco la rodilla..."
49
- * ```
72
+ * @param mimetype - Tipo MIME declarado por el cliente
73
+ * @param filename - Nombre original del archivo
74
+ * @param fileBuffer - Buffer del archivo
75
+ * @returns Tipo de tratamiento a aplicar
50
76
  */
51
- const transcribeAudio = (fileBuffer, mimetype) => __awaiter(void 0, void 0, void 0, function* () {
52
- var _a;
53
- try {
54
- const speechClient = new speech_1.SpeechClient();
55
- // Determinar el encoding del audio
56
- const encoding = AUDIO_ENCODING_MAP[mimetype] || 'MP3';
57
- // @ts-ignore - Tipos de Speech-to-Text son complejos
58
- const [response] = yield speechClient.recognize({
59
- audio: {
60
- content: fileBuffer.toString('base64'),
61
- },
62
- config: {
63
- // @ts-ignore - El encoding se determina dinámicamente según el mimetype
64
- encoding: encoding,
65
- sampleRateHertz: 16000,
66
- languageCode: 'es-ES', // Español de España
67
- alternativeLanguageCodes: ['es-MX', 'es-AR', 'en-US'], // Alternativas: México, Argentina, Inglés
68
- enableAutomaticPunctuation: true, // Puntuación automática
69
- model: 'latest_long', // Modelo más reciente para audios largos
70
- useEnhanced: true, // Modelo mejorado
71
- },
72
- });
73
- // Concatenar todas las transcripciones
74
- 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();
75
- if (!transcription) {
76
- return '[Audio sin contenido reconocible]';
77
- }
78
- return transcription;
77
+ const classifyFile = (mimetype, filename, fileBuffer) => {
78
+ const mime = (mimetype || '').toLowerCase().split(';')[0].trim();
79
+ const extension = path_1.default.extname(filename || '').toLowerCase();
80
+ if (!(fileBuffer === null || fileBuffer === void 0 ? void 0 : fileBuffer.length))
81
+ return FILE_KIND.OPAQUE;
82
+ // El SVG se comprueba antes que 'image/' porque hay que leerlo como texto
83
+ if (mime === 'image/svg+xml' || extension === '.svg')
84
+ return FILE_KIND.TEXT;
85
+ if (mime.startsWith('audio/'))
86
+ return FILE_KIND.AUDIO;
87
+ if (mime.startsWith('image/'))
88
+ return FILE_KIND.IMAGE;
89
+ if (mime.startsWith('video/'))
90
+ return FILE_KIND.VIDEO;
91
+ // ⭐ El PDF va entero al modelo, salvo que sea demasiado grande para mandarlo inline
92
+ if (mime === 'application/pdf' || extension === '.pdf') {
93
+ return fileBuffer.length <= documents_1.MAX_MODEL_FILE_BYTES ? FILE_KIND.PDF : FILE_KIND.OPAQUE;
79
94
  }
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
- (0, index_3.err)(null, null, error, null);
84
- return '[Error al transcribir audio]';
95
+ if (mime.startsWith('text/') || TEXT_MIME_TYPES.has(mime) || mime.endsWith('+xml') || mime.endsWith('+json')) {
96
+ return FILE_KIND.TEXT;
85
97
  }
86
- });
87
- exports.transcribeAudio = transcribeAudio;
98
+ if (TEXT_EXTENSIONS.has(extension))
99
+ return FILE_KIND.TEXT;
100
+ // ⭐ Último recurso: si el contenido parece texto legible, lo tratamos como tal
101
+ if ((0, documents_1.looksLikeText)(fileBuffer))
102
+ return FILE_KIND.TEXT;
103
+ return FILE_KIND.OPAQUE;
104
+ };
105
+ exports.classifyFile = classifyFile;
88
106
  /**
89
- * Genera una descripción de una imagen usando Gemini (Vercel AI SDK)
90
- * Contextualizada para una aplicación de fitness/entrenamiento
107
+ * Analiza cualquier adjunto y devuelve su representación en texto.
91
108
  *
92
- * @param fileBuffer - Buffer del archivo de imagen
93
- * @param mimetype - Tipo MIME del archivo (ej: 'image/jpeg', 'image/png')
94
- * @returns Promise<string> - Descripción de la imagen
109
+ * Nunca devuelve vacío: los tipos cuyo contenido no se puede extraer (comprimidos,
110
+ * binarios propietarios, ofimática antigua) reciben una ficha con nombre, tipo y tamaño.
111
+ * Esto importa porque el entrenador se salta la respuesta si un adjunto no tiene texto,
112
+ * y un archivo no soportado no debe distinguirse de uno que aún no se ha procesado.
113
+ *
114
+ * @param fileBuffer - Buffer del archivo
115
+ * @param mimetype - Tipo MIME del archivo
116
+ * @param filename - Nombre original del archivo
117
+ * @returns Promise<string> - Transcripción, descripción, resumen o ficha del archivo
95
118
  *
96
119
  * @example
97
120
  * ```typescript
98
- * const description = await describeImage(imageBuffer, 'image/jpeg');
99
- * // Resultado: "Captura de Garmin Connect mostrando un entrenamiento de 10km..."
121
+ * await analyzeFile(pdfBuffer, 'application/pdf', 'analitica.pdf');
122
+ * // "Analítica de sangre del 3 de junio con ferritina de 28 ng/ml..."
100
123
  * ```
101
124
  */
102
- const describeImage = (fileBuffer, mimetype) => __awaiter(void 0, void 0, void 0, function* () {
103
- try {
104
- const { text } = yield (0, index_1.generateText)({
105
- model: (0, index_1.createGoogleModel)('PRO'),
106
- messages: [
107
- {
108
- role: 'user',
109
- content: [
110
- { type: 'file', data: fileBuffer, mediaType: mimetype },
111
- { type: 'text', text: IMAGE_DESCRIPTION_PROMPT },
112
- ],
113
- },
114
- ],
115
- });
116
- return text || '[Imagen no analizable]';
117
- }
118
- catch (error) {
119
- // eslint-disable-next-line no-console
120
- console.error('[describeImage] Error:', (error === null || error === void 0 ? void 0 : error.message) || error);
121
- (0, index_3.err)(null, null, error, null);
122
- return '[Error al describir imagen]';
125
+ const analyzeFile = (fileBuffer, mimetype, filename) => __awaiter(void 0, void 0, void 0, function* () {
126
+ const kind = classifyFile(mimetype, filename, fileBuffer);
127
+ const fallback = () => (0, documents_1.describeOpaqueFile)(mimetype, filename, (fileBuffer === null || fileBuffer === void 0 ? void 0 : fileBuffer.length) || 0);
128
+ let fileText;
129
+ switch (kind) {
130
+ case FILE_KIND.AUDIO:
131
+ fileText = yield (0, audio_1.transcribeAudio)(fileBuffer, mimetype, filename);
132
+ break;
133
+ case FILE_KIND.IMAGE:
134
+ fileText = yield (0, image_1.describeImage)(fileBuffer, mimetype);
135
+ break;
136
+ case FILE_KIND.VIDEO:
137
+ fileText = yield (0, video_1.describeVideo)(fileBuffer, mimetype, filename);
138
+ break;
139
+ case FILE_KIND.PDF:
140
+ fileText = yield (0, documents_1.describePdf)(fileBuffer, filename);
141
+ break;
142
+ case FILE_KIND.TEXT:
143
+ fileText = yield (0, documents_1.describeTextFile)(fileBuffer, filename);
144
+ break;
145
+ default:
146
+ fileText = fallback();
123
147
  }
148
+ // ⭐ Postgres rechaza el carácter nulo en columnas text. Si se colara uno, el UPDATE fallaría
149
+ // sin avisar (query() se traga el error) y el adjunto se quedaría sin texto para siempre,
150
+ // que es justo el estado que bloquea al entrenador.
151
+ const clean = (fileText || '').split('\u0000').join('').trim();
152
+ return clean || fallback();
124
153
  });
125
- exports.describeImage = describeImage;
154
+ exports.analyzeFile = analyzeFile;
126
155
  /**
127
- * Procesa un archivo multimedia (audio o imagen) y guarda el resultado en la base de datos
128
- * - Audio: usa Google Cloud Speech-to-Text
129
- * - Imagen: usa Gemini (Vertex AI)
156
+ * Procesa un adjunto de chat y guarda su texto en la base de datos.
157
+ * Pensado para llamarse sin await (fire-and-forget) desde el envío del archivo.
130
158
  *
131
159
  * @param idMessage - ID del mensaje en CHAT MESSAGE
132
160
  * @param fileBuffer - Buffer del archivo
133
161
  * @param mimetype - Tipo MIME del archivo
162
+ * @param filename - Nombre original del archivo
134
163
  * @returns Promise<void>
135
164
  *
136
165
  * @example
137
166
  * ```typescript
138
167
  * // No usar await para ejecutar en background
139
- * processMediaFile(123, fileBuffer, 'audio/mpeg');
168
+ * processMediaFile(123, fileBuffer, 'application/pdf', 'informe.pdf');
140
169
  * ```
141
170
  */
142
- const processMediaFile = (idMessage, fileBuffer, mimetype) => __awaiter(void 0, void 0, void 0, function* () {
171
+ const processMediaFile = (idMessage, fileBuffer, mimetype, filename) => __awaiter(void 0, void 0, void 0, function* () {
143
172
  try {
144
- let fileText;
145
- if (mimetype.startsWith('audio/')) {
146
- fileText = yield transcribeAudio(fileBuffer, mimetype);
147
- }
148
- else if (mimetype.startsWith('image/')) {
149
- fileText = yield describeImage(fileBuffer, mimetype);
150
- }
151
- else {
152
- // ❌ Tipo de archivo no soportado para procesamiento
153
- return;
154
- }
173
+ const fileText = yield analyzeFile(fileBuffer, mimetype, filename);
155
174
  // ✅ Guardar el resultado en la base de datos
156
- yield (0, index_2.query)('UPDATE [CHAT MESSAGE] SET [FILE TEXT] = ? WHERE [ID] = ?', [fileText, idMessage]);
175
+ yield (0, index_1.query)('UPDATE [CHAT MESSAGE] SET [FILE TEXT] = ? WHERE [ID] = ?', [fileText, idMessage]);
157
176
  }
158
177
  catch (error) {
159
- (0, index_3.err)(null, null, error, null);
178
+ (0, index_2.err)(null, null, error, null);
160
179
  }
161
180
  });
162
181
  exports.processMediaFile = processMediaFile;
163
182
  /**
164
- * Reprocesa mensajes multimedia recientes (últimos 2 días) que no tienen FILE TEXT
165
- * Útil para procesar mensajes enviados antes del deploy de esta funcionalidad
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.
166
185
  *
167
186
  * @param bucket - Instancia del bucket de Google Cloud Storage
168
187
  * @returns Promise<{ processed: number; errors: number }> - Estadísticas del reprocesamiento
@@ -177,11 +196,11 @@ const reprocessRecentMedia = (bucket) => __awaiter(void 0, void 0, void 0, funct
177
196
  let processed = 0;
178
197
  let errors = 0;
179
198
  try {
180
- // ✅ Buscar mensajes de los últimos 2 días con MIMETYPE de audio/imagen y sin FILE TEXT
181
- const messages = yield (0, index_2.query)(`SELECT "ID", "MIMETYPE" FROM "CHAT MESSAGE"
199
+ // ✅ 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"
182
201
  WHERE "DATE" >= NOW() - INTERVAL '2 days'
183
202
  AND "FILE TEXT" IS NULL
184
- AND ("MIMETYPE" LIKE 'audio/%' OR "MIMETYPE" LIKE 'image/%')
203
+ AND "MIMETYPE" IS NOT NULL
185
204
  ORDER BY "DATE" DESC`, []);
186
205
  // eslint-disable-next-line no-console
187
206
  console.log(`[reprocessRecentMedia] Encontrados ${messages.length} mensajes para procesar`);
@@ -196,16 +215,10 @@ const reprocessRecentMedia = (bucket) => __awaiter(void 0, void 0, void 0, funct
196
215
  continue;
197
216
  }
198
217
  const [fileBuffer] = yield bucket.file(`Chat/${message.id}`).download();
199
- // ⭐ Procesar según tipo
200
- let fileText;
201
- if (message.mimetype.startsWith('audio/')) {
202
- fileText = yield transcribeAudio(fileBuffer, message.mimetype);
203
- }
204
- else {
205
- fileText = yield describeImage(fileBuffer, message.mimetype);
206
- }
218
+ // ⭐ Mismo router que en el envío en vivo
219
+ const fileText = yield analyzeFile(fileBuffer, message.mimetype, message.text);
207
220
  // ⭐ Guardar en BD
208
- yield (0, index_2.query)('UPDATE [CHAT MESSAGE] SET [FILE TEXT] = ? WHERE [ID] = ?', [fileText, message.id]);
221
+ yield (0, index_1.query)('UPDATE [CHAT MESSAGE] SET [FILE TEXT] = ? WHERE [ID] = ?', [fileText, message.id]);
209
222
  // eslint-disable-next-line no-console
210
223
  console.log(`[reprocessRecentMedia] ✅ Procesado mensaje ${message.id}: ${fileText.substring(0, 50)}...`);
211
224
  processed++;
@@ -218,7 +231,7 @@ const reprocessRecentMedia = (bucket) => __awaiter(void 0, void 0, void 0, funct
218
231
  }
219
232
  }
220
233
  catch (error) {
221
- (0, index_3.err)(null, null, error, null);
234
+ (0, index_2.err)(null, null, error, null);
222
235
  }
223
236
  return { processed, errors };
224
237
  });
@@ -0,0 +1,149 @@
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.describeVideo = void 0;
16
+ const fs_1 = __importDefault(require("fs"));
17
+ const path_1 = __importDefault(require("path"));
18
+ const ffmpegUtils_1 = require("./ffmpegUtils");
19
+ const image_1 = require("./image");
20
+ const audio_1 = require("./audio");
21
+ const index_1 = require("../err/index");
22
+ // ✅ La pista se manda como LINEAR16, y con un encoding declarado de verdad Speech-to-Text
23
+ // aplica su límite de 60s de forma estricta (ver el aviso en audio.ts). Se recorta con margen.
24
+ const AUDIO_TRACK_MAX_SECONDS = 55;
25
+ const VIDEO_FRAME_PROMPT = `Esto es un fotograma extraído de un vídeo enviado en un chat de entrenamiento.
26
+ Describe lo que se ve de forma directa en un solo párrafo continuo, sin introducciones ni listas.
27
+ Si es contenido deportivo (gesto de carrera, ejercicio de fuerza, zona del cuerpo dolorida, pantalla de un reloj, entorno de entrenamiento, etc.), céntrate en lo que sirve para valorar la técnica, el estado del atleta o su entrenamiento.
28
+ Devuelve ÚNICAMENTE la descripción en español. Máximo 250 palabras.`;
29
+ /**
30
+ * Extrae un fotograma representativo (al 10% de la duración, entre 1s y 10s)
31
+ *
32
+ * @param inputPath - Ruta del vídeo en disco
33
+ * @param outputPath - Ruta destino del JPEG
34
+ * @param duration - Duración del vídeo en segundos
35
+ */
36
+ const extractKeyFrame = (inputPath, outputPath, duration) => {
37
+ // ⭐ Buscar el segundo 1 en un clip de medio segundo deja el JPEG sin escribir: en vídeos muy
38
+ // cortos (o de duración desconocida) se coge el primer fotograma
39
+ const timestamp = duration > 1.2 ? Math.min(Math.max(duration * 0.1, 1), 10) : 0;
40
+ return (0, ffmpegUtils_1.runFfmpeg)((0, ffmpegUtils_1.ffmpeg)(inputPath)
41
+ .seekInput(timestamp)
42
+ .outputOptions(['-frames:v', '1', '-vf', "scale='min(1280,iw)':-2", '-q:v', '3'])
43
+ .output(outputPath), 'fotograma');
44
+ };
45
+ /**
46
+ * Extrae la pista de audio como WAV mono 16kHz, el formato que espera Speech-to-Text
47
+ *
48
+ * @param inputPath - Ruta del vídeo en disco
49
+ * @param outputPath - Ruta destino del WAV
50
+ */
51
+ const extractAudioTrack = (inputPath, outputPath) => (0, ffmpegUtils_1.runFfmpeg)((0, ffmpegUtils_1.ffmpeg)(inputPath)
52
+ .noVideo()
53
+ .audioCodec('pcm_s16le')
54
+ .audioFrequency(ffmpegUtils_1.SPEECH_SAMPLE_RATE)
55
+ .audioChannels(1)
56
+ .duration(AUDIO_TRACK_MAX_SECONDS)
57
+ .format('wav')
58
+ .output(outputPath), 'audio');
59
+ /**
60
+ * Formatea la duración de un vídeo en español ("1 min 12 s")
61
+ *
62
+ * @param seconds - Duración en segundos
63
+ * @returns Duración legible, o cadena vacía si no se conoce
64
+ */
65
+ const formatDuration = (seconds) => {
66
+ if (!seconds || seconds <= 0)
67
+ return '';
68
+ const total = Math.round(seconds);
69
+ if (total < 60)
70
+ return `${total} s`;
71
+ const minutes = Math.floor(total / 60);
72
+ const rest = total % 60;
73
+ return rest ? `${minutes} min ${rest} s` : `${minutes} min`;
74
+ };
75
+ /**
76
+ * Analiza un vídeo descomponiéndolo en lo que sí sabemos leer: un fotograma
77
+ * representativo (descrito por el modelo de visión) y su pista de audio
78
+ * (transcrita con Speech-to-Text). Ambas partes son opcionales: un vídeo mudo
79
+ * devuelve solo la descripción visual, y uno sin imagen solo la transcripción.
80
+ *
81
+ * @param fileBuffer - Buffer del vídeo
82
+ * @param mimetype - Tipo MIME del archivo (ej: 'video/mp4')
83
+ * @param filename - Nombre original del archivo
84
+ * @returns Promise<string> - Descripción combinada del vídeo
85
+ *
86
+ * @example
87
+ * ```typescript
88
+ * const description = await describeVideo(videoBuffer, 'video/mp4', 'tecnica.mp4');
89
+ * // "Vídeo de 12 s. Imagen: corredor de perfil sobre cinta... Audio: mira cómo apoyo el talón..."
90
+ * ```
91
+ */
92
+ const describeVideo = (fileBuffer, mimetype, filename) => __awaiter(void 0, void 0, void 0, function* () {
93
+ try {
94
+ return yield (0, ffmpegUtils_1.withTempDir)((dir) => __awaiter(void 0, void 0, void 0, function* () {
95
+ // ffmpeg detecta el formato por contenido, así que la extensión solo es cosmética
96
+ const extension = path_1.default.extname(filename || '') || '.mp4';
97
+ const inputPath = path_1.default.join(dir, `input${extension}`);
98
+ const framePath = path_1.default.join(dir, 'frame.jpg');
99
+ const audioPath = path_1.default.join(dir, 'audio.wav');
100
+ fs_1.default.writeFileSync(inputPath, fileBuffer);
101
+ const { duration, hasAudio, hasVideo } = yield (0, ffmpegUtils_1.probeMedia)(inputPath);
102
+ // ⭐ Fotograma y pista de audio se extraen en paralelo; cada rama falla por su cuenta
103
+ const [frameBuffer, audioBuffer] = yield Promise.all([
104
+ hasVideo
105
+ ? extractKeyFrame(inputPath, framePath, duration)
106
+ .then(() => fs_1.default.readFileSync(framePath))
107
+ .catch((error) => {
108
+ // eslint-disable-next-line no-console
109
+ console.error('[describeVideo] No se pudo extraer el fotograma:', (error === null || error === void 0 ? void 0 : error.message) || error);
110
+ return null;
111
+ })
112
+ : Promise.resolve(null),
113
+ hasAudio
114
+ ? extractAudioTrack(inputPath, audioPath)
115
+ .then(() => fs_1.default.readFileSync(audioPath))
116
+ .catch((error) => {
117
+ // eslint-disable-next-line no-console
118
+ console.error('[describeVideo] No se pudo extraer el audio:', (error === null || error === void 0 ? void 0 : error.message) || error);
119
+ return null;
120
+ })
121
+ : Promise.resolve(null),
122
+ ]);
123
+ const [imageText, audioText] = yield Promise.all([
124
+ frameBuffer ? (0, image_1.describeImage)(frameBuffer, 'image/jpeg', VIDEO_FRAME_PROMPT) : Promise.resolve(null),
125
+ // ⭐ La pista ya sale de ffmpeg en WAV mono 16 kHz, así que no hay que volver a convertirla
126
+ audioBuffer ? (0, audio_1.transcribeWav16k)(audioBuffer) : Promise.resolve(null),
127
+ ]);
128
+ const header = `Vídeo${formatDuration(duration) ? ` de ${formatDuration(duration)}` : ''}`;
129
+ const parts = [];
130
+ if (imageText)
131
+ parts.push(`Imagen: ${imageText}`);
132
+ if (audioText)
133
+ parts.push(`Audio: ${audioText}`);
134
+ if (duration > AUDIO_TRACK_MAX_SECONDS && audioText) {
135
+ parts.push(`(Solo se transcribieron los primeros ${AUDIO_TRACK_MAX_SECONDS} s de audio.)`);
136
+ }
137
+ if (!parts.length)
138
+ return `[${header} sin contenido analizable]`;
139
+ return `${header}. ${parts.join(' ')}`;
140
+ }));
141
+ }
142
+ catch (error) {
143
+ // eslint-disable-next-line no-console
144
+ console.error('[describeVideo] Error:', (error === null || error === void 0 ? void 0 : error.message) || error);
145
+ (0, index_1.err)(null, null, error, null);
146
+ return '[Error al analizar vídeo]';
147
+ }
148
+ });
149
+ exports.describeVideo = describeVideo;
@@ -278,12 +278,40 @@ function textPromptSignature(options) {
278
278
  if (!Array.isArray(options === null || options === void 0 ? void 0 : options.messages))
279
279
  return undefined;
280
280
  try {
281
- return JSON.stringify(options.messages, (_k, v) => typeof v === 'string' && v.length > TEXT_SIG_MAX_CHARS ? `${v.slice(0, TEXT_SIG_MAX_CHARS)}…(${v.length})` : v);
281
+ return JSON.stringify(options.messages, (_k, v) => {
282
+ const compacted = compactBinaryForSignature(v);
283
+ if (compacted !== v)
284
+ return compacted;
285
+ return typeof v === 'string' && v.length > TEXT_SIG_MAX_CHARS ? `${v.slice(0, TEXT_SIG_MAX_CHARS)}…(${v.length})` : v;
286
+ });
282
287
  }
283
288
  catch (_a) {
284
289
  return undefined;
285
290
  }
286
291
  }
292
+ /**
293
+ * Sustituye los adjuntos binarios por una marca de una línea antes de firmar la llamada.
294
+ *
295
+ * Sin esto, un `Buffer` en un `file part` se serializa byte a byte como
296
+ * `{"type":"Buffer","data":[200,…]}`, a ~4,2 caracteres por byte: un PDF de 3 MB produce una firma
297
+ * de 12,5 M de caracteres que se hashea y se INSERTa entera en LLM CACHE. El recorte de strings de
298
+ * arriba no lo cubre porque el valor ya no es un string. Como `JSON.stringify` invoca `toJSON()`
299
+ * ANTES que el replacer, aquí llega la forma ya convertida, no el Buffer original.
300
+ *
301
+ * @param value - Valor que el replacer está serializando
302
+ * @returns Marca compacta si es binario, o el mismo valor sin tocar
303
+ */
304
+ function compactBinaryForSignature(value) {
305
+ if (!value || typeof value !== 'object')
306
+ return value;
307
+ if (value.type === 'Buffer' && Array.isArray(value.data))
308
+ return `…(binario ${value.data.length} bytes)`;
309
+ if (ArrayBuffer.isView(value))
310
+ return `…(binario ${value.byteLength} bytes)`;
311
+ if (value instanceof ArrayBuffer)
312
+ return `…(binario ${value.byteLength} bytes)`;
313
+ return value;
314
+ }
287
315
  /**
288
316
  * Registra en "LLM CACHE" el coste de una llamada de `generateText`.
289
317
  *
@@ -1 +1 @@
1
- {"version":3,"file":"conversation.d.ts","sourceRoot":"","sources":["../../../../../src/chat/api/conversation.ts"],"names":[],"mappings":"AAmBA,QAAA,MAAM,iBAAiB,0BAA2B,GAAG,SA0BpD,CAAC;AAoGF;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,QAAA,MAAM,uBAAuB,qCAAkC,GAAG,iBAwCjE,CAAC;AAmGF;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,QAAA,MAAM,WAAW,0EAAuE,GAAG,kBA0E1F,CAAC;AAEF,QAAA,MAAM,gBAAgB;;;;mBAqBrB,CAAC;AAyTF,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,WAAW,EAAE,uBAAuB,EAAE,CAAC"}
1
+ {"version":3,"file":"conversation.d.ts","sourceRoot":"","sources":["../../../../../src/chat/api/conversation.ts"],"names":[],"mappings":"AAmBA,QAAA,MAAM,iBAAiB,0BAA2B,GAAG,SA0BpD,CAAC;AAoGF;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,QAAA,MAAM,uBAAuB,qCAAkC,GAAG,iBAwCjE,CAAC;AAmGF;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,QAAA,MAAM,WAAW,0EAAuE,GAAG,kBA0E1F,CAAC;AAEF,QAAA,MAAM,gBAAgB;;;;mBAqBrB,CAAC;AA2TF,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,WAAW,EAAE,uBAAuB,EAAE,CAAC"}
@@ -22,7 +22,7 @@ import { saveDoneStructuraWorkout } from './workout/estructuraWorkout';
22
22
  import { getZone } from './workout/paceZone';
23
23
  import { saveResponseTime } from './chat/saveResponseTime';
24
24
  import { updateSenderView } from './chat/api/conversation';
25
- import { transcribeAudio, describeImage, processMediaFile, reprocessRecentMedia } from './mediaProcessing';
25
+ import { transcribeAudio, describeImage, describeVideo, describePdf, describeTextFile, analyzeFile, processMediaFile, reprocessRecentMedia } from './mediaProcessing';
26
26
  import { generateObject, generateText, createGoogleModel, createGoogleModelFromString, createBedrockModelFromString, createAzureModelFromString, createModelFromString, runWithCostTracking, runWithModels, MODEL_TIER, AI_MODELS, GOOGLE_MODELS, AZURE_PREFIX, AZURE_GPT_5_5_DEPLOYMENT, AZURE_GPT_5_4_MINI_DEPLOYMENT, AZURE_GPT_5_4_NANO_DEPLOYMENT, AZURE_GPT_5_5, AZURE_GPT_5_4_MINI, AZURE_GPT_5_4_NANO, AZURE_PRIMARY_MODELS, PRODUCTION_MODELS, FALLBACK_MODELS, BEDROCK_CLAUDE_OPUS, BEDROCK_CLAUDE_SONNET, BEDROCK_CLAUDE_OPUS_4_5, BEDROCK_CLAUDE_OPUS_4_1, BEDROCK_CLAUDE_SONNET_4_5, BEDROCK_CLAUDE_SONNET_4, BEDROCK_CLAUDE_HAIKU, AISTUDIO_PREFIX, MODEL_PRICING, calculateCost } from './prompt';
27
- export { sendNotification, sleep, query, queryWithClient, longRunningQuery, batchQuery, toPgArray, pool, sendMail, err, fetchIA, notifySlack, CHANNEL_SLACK, translate, LANGUAGES, useTranslation, getExerciseTranslatedDescription, chat, chatApi, chatExposed, getCountNotificaciones, notionGetUsers, notionGetDatabase, notionAddPage, notionEditPage, NOTION_DATABASES_ID, readSheet, findCellByValue, writeSheet, appendSheet, getNumberByLetter, getLetter, reduceSizeImage, generateShareMap, getDefaultWorkoutImage, sendWorkoutToWatch, getPlanificacionPrueba7dias, saveWorkoutAplication, saveWorkoutLaps, evaluateAchievements, evaluateAchievementsBounded, getUnlockedAchievements, computeStreakStats, saveDoneStructuraWorkout, getZone, saveResponseTime, updateSenderView, transcribeAudio, describeImage, processMediaFile, reprocessRecentMedia, generateObject, generateText, createGoogleModel, createGoogleModelFromString, createBedrockModelFromString, createAzureModelFromString, createModelFromString, runWithCostTracking, runWithModels, MODEL_TIER, AI_MODELS, GOOGLE_MODELS, AZURE_PREFIX, AZURE_GPT_5_5_DEPLOYMENT, AZURE_GPT_5_4_MINI_DEPLOYMENT, AZURE_GPT_5_4_NANO_DEPLOYMENT, AZURE_GPT_5_5, AZURE_GPT_5_4_MINI, AZURE_GPT_5_4_NANO, AZURE_PRIMARY_MODELS, PRODUCTION_MODELS, FALLBACK_MODELS, BEDROCK_CLAUDE_OPUS, BEDROCK_CLAUDE_SONNET, BEDROCK_CLAUDE_OPUS_4_5, BEDROCK_CLAUDE_OPUS_4_1, BEDROCK_CLAUDE_SONNET_4_5, BEDROCK_CLAUDE_SONNET_4, BEDROCK_CLAUDE_HAIKU, AISTUDIO_PREFIX, MODEL_PRICING, calculateCost, };
27
+ export { sendNotification, sleep, query, queryWithClient, longRunningQuery, batchQuery, toPgArray, pool, sendMail, err, fetchIA, notifySlack, CHANNEL_SLACK, translate, LANGUAGES, useTranslation, getExerciseTranslatedDescription, chat, chatApi, chatExposed, getCountNotificaciones, notionGetUsers, notionGetDatabase, notionAddPage, notionEditPage, NOTION_DATABASES_ID, readSheet, findCellByValue, writeSheet, appendSheet, getNumberByLetter, getLetter, reduceSizeImage, generateShareMap, getDefaultWorkoutImage, sendWorkoutToWatch, getPlanificacionPrueba7dias, saveWorkoutAplication, saveWorkoutLaps, evaluateAchievements, evaluateAchievementsBounded, getUnlockedAchievements, computeStreakStats, saveDoneStructuraWorkout, getZone, saveResponseTime, updateSenderView, transcribeAudio, describeImage, describeVideo, describePdf, describeTextFile, analyzeFile, processMediaFile, reprocessRecentMedia, generateObject, generateText, createGoogleModel, createGoogleModelFromString, createBedrockModelFromString, createAzureModelFromString, createModelFromString, runWithCostTracking, runWithModels, MODEL_TIER, AI_MODELS, GOOGLE_MODELS, AZURE_PREFIX, AZURE_GPT_5_5_DEPLOYMENT, AZURE_GPT_5_4_MINI_DEPLOYMENT, AZURE_GPT_5_4_NANO_DEPLOYMENT, AZURE_GPT_5_5, AZURE_GPT_5_4_MINI, AZURE_GPT_5_4_NANO, AZURE_PRIMARY_MODELS, PRODUCTION_MODELS, FALLBACK_MODELS, BEDROCK_CLAUDE_OPUS, BEDROCK_CLAUDE_SONNET, BEDROCK_CLAUDE_OPUS_4_5, BEDROCK_CLAUDE_OPUS_4_1, BEDROCK_CLAUDE_SONNET_4_5, BEDROCK_CLAUDE_SONNET_4, BEDROCK_CLAUDE_HAIKU, AISTUDIO_PREFIX, MODEL_PRICING, calculateCost, };
28
28
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAChC,OAAO,EAAE,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,UAAU,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC7F,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,EAAE,GAAG,EAAE,MAAM,OAAO,CAAC;AAC5B,OAAO,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAC1C,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACrD,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,cAAc,EAAE,gCAAgC,EAAE,MAAM,eAAe,CAAC;AACvG,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,sBAAsB,EAAE,MAAM,QAAQ,CAAC;AAC5E,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,aAAa,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AACjH,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,UAAU,EAAE,WAAW,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAClH,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAC;AACxE,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EAAE,2BAA2B,EAAE,MAAM,oCAAoC,CAAC;AACjF,OAAO,EAAE,qBAAqB,EAAE,MAAM,iCAAiC,CAAC;AACxE,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAC5D,OAAO,EAAE,oBAAoB,EAAE,2BAA2B,EAAE,uBAAuB,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAIhI,OAAO,KAAK,mBAAmB,MAAM,wBAAwB,CAAC;AAC9D,OAAO,EAAE,wBAAwB,EAAE,MAAM,6BAA6B,CAAC;AACvE,OAAO,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAC7C,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAC3G,OAAO,EACL,cAAc,EACd,YAAY,EACZ,iBAAiB,EACjB,2BAA2B,EAC3B,4BAA4B,EAC5B,0BAA0B,EAC1B,qBAAqB,EACrB,mBAAmB,EACnB,aAAa,EACb,UAAU,EACV,SAAS,EACT,aAAa,EACb,YAAY,EACZ,wBAAwB,EACxB,6BAA6B,EAC7B,6BAA6B,EAC7B,aAAa,EACb,kBAAkB,EAClB,kBAAkB,EAClB,oBAAoB,EACpB,iBAAiB,EACjB,eAAe,EACf,mBAAmB,EACnB,qBAAqB,EACrB,uBAAuB,EACvB,uBAAuB,EACvB,yBAAyB,EACzB,uBAAuB,EACvB,oBAAoB,EACpB,eAAe,EACf,aAAa,EACb,aAAa,EACd,MAAM,UAAU,CAAC;AAElB,OAAO,EACL,gBAAgB,EAChB,KAAK,EACL,KAAK,EACL,eAAe,EACf,gBAAgB,EAChB,UAAU,EACV,SAAS,EACT,IAAI,EACJ,QAAQ,EACR,GAAG,EACH,OAAO,EACP,WAAW,EACX,aAAa,EACb,SAAS,EACT,SAAS,EACT,cAAc,EACd,gCAAgC,EAChC,IAAI,EACJ,OAAO,EACP,WAAW,EACX,sBAAsB,EACtB,cAAc,EACd,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,mBAAmB,EACnB,SAAS,EACT,eAAe,EACf,UAAU,EACV,WAAW,EACX,iBAAiB,EACjB,SAAS,EACT,eAAe,EACf,gBAAgB,EAChB,sBAAsB,EACtB,kBAAkB,EAClB,2BAA2B,EAC3B,qBAAqB,EACrB,eAAe,EACf,oBAAoB,EACpB,2BAA2B,EAC3B,uBAAuB,EACvB,kBAAkB,EAClB,wBAAwB,EACxB,OAAO,EACP,gBAAgB,EAChB,gBAAgB,EAChB,eAAe,EACf,aAAa,EACb,gBAAgB,EAChB,oBAAoB,EACpB,cAAc,EACd,YAAY,EACZ,iBAAiB,EACjB,2BAA2B,EAC3B,4BAA4B,EAC5B,0BAA0B,EAC1B,qBAAqB,EACrB,mBAAmB,EACnB,aAAa,EACb,UAAU,EACV,SAAS,EACT,aAAa,EACb,YAAY,EACZ,wBAAwB,EACxB,6BAA6B,EAC7B,6BAA6B,EAC7B,aAAa,EACb,kBAAkB,EAClB,kBAAkB,EAClB,oBAAoB,EACpB,iBAAiB,EACjB,eAAe,EACf,mBAAmB,EACnB,qBAAqB,EACrB,uBAAuB,EACvB,uBAAuB,EACvB,yBAAyB,EACzB,uBAAuB,EACvB,oBAAoB,EACpB,eAAe,EACf,aAAa,EACb,aAAa,GACd,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/index.ts"],"names":[],"mappings":"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"}