@aj-archipelago/cortex 1.1.12 → 1.1.14

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.
@@ -87,32 +87,24 @@ async function splitMediaFile(inputPath, chunkDurationInSeconds = 500) {
87
87
  inputPath = downloadPath;
88
88
  }
89
89
 
90
-
91
90
  const metadata = await ffmpegProbe(inputPath);
92
91
  const duration = metadata.format.duration;
93
92
  const numChunks = Math.ceil((duration - 1) / chunkDurationInSeconds);
94
93
 
95
94
  const chunkPromises = [];
96
-
97
-
95
+ const chunkOffsets = [];
98
96
 
99
97
  for (let i = 0; i < numChunks; i++) {
100
- const outputFileName = path.join(
101
- uniqueOutputPath,
102
- `chunk-${i + 1}-${path.parse(inputPath).name}.mp3`
103
- );
104
-
105
- const chunkPromise = processChunk(
106
- inputPath,
107
- outputFileName,
108
- i * chunkDurationInSeconds,
109
- chunkDurationInSeconds
110
- );
98
+ const outputFileName = path.join(uniqueOutputPath, `chunk-${i + 1}-${path.parse(inputPath).name}.mp3`);
99
+ const offset = i * chunkDurationInSeconds;
111
100
 
101
+ const chunkPromise = processChunk(inputPath, outputFileName, offset, chunkDurationInSeconds);
102
+
112
103
  chunkPromises.push(chunkPromise);
104
+ chunkOffsets.push(offset);
113
105
  }
114
106
 
115
- return { chunkPromises, uniqueOutputPath };
107
+ return { chunkPromises, chunkOffsets, uniqueOutputPath };
116
108
  } catch (err) {
117
109
  const msg = `Error processing media file, check if the file is a valid media file or is accessible`;
118
110
  console.error(msg, err);
@@ -143,7 +143,7 @@ async function main(context, req) {
143
143
  file = await processYoutubeUrl(file);
144
144
  }
145
145
 
146
- const { chunkPromises, uniqueOutputPath } = await splitMediaFile(file);
146
+ const { chunkPromises, chunkOffsets, uniqueOutputPath } = await splitMediaFile(file);
147
147
  folder = uniqueOutputPath;
148
148
 
149
149
  numberOfChunks = chunkPromises.length; // for progress reporting
@@ -158,9 +158,11 @@ async function main(context, req) {
158
158
  }
159
159
 
160
160
  // sequential processing of chunks
161
- for (const chunk of chunks) {
161
+ for (let index = 0; index < chunks.length; index++) {
162
+ const chunk = chunks[index];
162
163
  const blobName = useAzure ? await saveFileToBlob(chunk, requestId) : await moveFileToPublicFolder(chunk, requestId);
163
- result.push(blobName);
164
+ const chunkOffset = chunkOffsets[index];
165
+ result.push({ uri:blobName, offset:chunkOffset });
164
166
  context.log(`Saved chunk as: ${blobName}`);
165
167
  sendProgress();
166
168
  }
@@ -182,7 +184,10 @@ async function main(context, req) {
182
184
  }
183
185
  }
184
186
 
185
- console.log(`result: ${result}`);
187
+ console.log('result:', result.map(item =>
188
+ typeof item === 'object' ? JSON.stringify(item, null, 2) : item
189
+ ).join('\n'));
190
+
186
191
  context.res = {
187
192
  body: result
188
193
  };
@@ -323,8 +323,13 @@ const makeRequest = async (cortexRequest) => {
323
323
  return { response, duration };
324
324
  }
325
325
  } else {
326
- // if there are multiple endpoints, retry everything as it
327
- // could be going to a different host
326
+ // if there are multiple endpoints, retry everything by default
327
+ // as it could be a temporary issue with one endpoint
328
+ // certain errors (e.g. 400) are problems with the request itself
329
+ // and should not be retried
330
+ if (status == 400) {
331
+ return { response, duration };
332
+ }
328
333
  cortexRequest.selectNewEndpoint();
329
334
  }
330
335
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aj-archipelago/cortex",
3
- "version": "1.1.12",
3
+ "version": "1.1.14",
4
4
  "description": "Cortex is a GraphQL API for AI. It provides a simple, extensible interface for using AI services from OpenAI, Azure and others.",
5
5
  "private": false,
6
6
  "repository": {
@@ -276,7 +276,9 @@ class ModelPlugin {
276
276
 
277
277
  const errorData = Array.isArray(responseData) ? responseData[0] : responseData;
278
278
  if (errorData && errorData.error) {
279
- throw new Error(`Server error: ${JSON.stringify(errorData.error)}`);
279
+ const newError = new Error(errorData.error.message);
280
+ newError.data = errorData;
281
+ throw newError;
280
282
  }
281
283
 
282
284
  this.logAIRequestFinished(requestDuration);
@@ -55,8 +55,12 @@ class OpenAIDallE3Plugin extends ModelPlugin {
55
55
  .catch((error) => handleResponse(error));
56
56
 
57
57
  function handleResponse(response) {
58
- const status = response?.error ? "failed" : "succeeded";
59
- const data = JSON.stringify(response?.error ? response : response);
58
+ let status = "succeeded";
59
+ let data = JSON.stringify(response);
60
+ if (response.data.error) {
61
+ status = "failed";
62
+ data = JSON.stringify(response.data);
63
+ }
60
64
 
61
65
  const requestProgress = {
62
66
  requestId,
@@ -15,6 +15,7 @@ import { v4 as uuidv4 } from 'uuid';
15
15
  import { promisify } from 'util';
16
16
  import { publishRequestProgress } from '../../lib/redisSubscription.js';
17
17
  import logger from '../../lib/logger.js';
18
+ import CortexRequest from '../../lib/cortexRequest.js';
18
19
  const pipeline = promisify(stream.pipeline);
19
20
 
20
21
  const API_URL = config.get('whisperMediaApiUrl');
@@ -25,7 +26,7 @@ if(WHISPER_TS_API_URL){
25
26
  logger.warn(`WHISPER API URL not set using default OpenAI API Whisper`);
26
27
  }
27
28
 
28
- const OFFSET_CHUNK = 1000 * 500; // 500 seconds chunk offset
29
+ const OFFSET_CHUNK = 500; //seconds of each chunk offset, only used if helper does not provide
29
30
 
30
31
  async function deleteTempPath(path) {
31
32
  try {
@@ -96,7 +97,7 @@ function convertToText(str) {
96
97
  .join(' ');
97
98
  }
98
99
 
99
- function alignSubtitles(subtitles, format) {
100
+ function alignSubtitles(subtitles, format, offsets) {
100
101
  const result = [];
101
102
 
102
103
  function preprocessStr(str) {
@@ -116,7 +117,7 @@ function alignSubtitles(subtitles, format) {
116
117
  }
117
118
 
118
119
  for (let i = 0; i < subtitles.length; i++) {
119
- result.push(...shiftSubtitles(subtitles[i], i * OFFSET_CHUNK));
120
+ result.push(...shiftSubtitles(subtitles[i], offsets[i]*1000)); // convert to milliseconds
120
121
  }
121
122
 
122
123
  try {
@@ -171,12 +172,14 @@ class OpenAIWhisperPlugin extends ModelPlugin {
171
172
  // Execute the request to the OpenAI Whisper API
172
173
  async execute(text, parameters, prompt, cortexRequest) {
173
174
  const { pathwayResolver } = cortexRequest;
175
+
174
176
  const { responseFormat, wordTimestamped, highlightWords, maxLineWidth, maxLineCount, maxWordsPerLine } = parameters;
175
- cortexRequest.url = this.requestUrl(text);
176
177
 
177
178
  const chunks = [];
178
179
  const processChunk = async (uri) => {
179
180
  try {
181
+ const cortexRequest = new CortexRequest({ pathwayResolver });
182
+
180
183
  const chunk = await downloadFile(uri);
181
184
  chunks.push(chunk);
182
185
 
@@ -205,6 +208,8 @@ class OpenAIWhisperPlugin extends ModelPlugin {
205
208
  }
206
209
 
207
210
  const processTS = async (uri) => {
211
+ const cortexRequest = new CortexRequest({ pathwayResolver });
212
+
208
213
  const tsparams = { fileurl:uri };
209
214
  const { language } = parameters;
210
215
  if(language) tsparams.language = language;
@@ -315,14 +320,20 @@ async function processURI(uri) {
315
320
  return result;
316
321
  }
317
322
 
323
+ let offsets = [];
324
+ let uris = []
325
+
318
326
  try {
319
- const uris = await this.getMediaChunks(file, requestId);
327
+ const mediaChunks = await this.getMediaChunks(file, requestId);
320
328
 
321
- if (!uris || !uris.length) {
329
+ if (!mediaChunks || !mediaChunks.length) {
322
330
  throw new Error(`Error in getting chunks from media helper for file ${file}`);
323
331
  }
324
332
 
325
- totalCount = uris.length + 1; // total number of chunks that will be processed
333
+ uris = mediaChunks.map((chunk) => chunk?.uri || chunk);
334
+ offsets = mediaChunks.map((chunk, index) => chunk?.offset || index * OFFSET_CHUNK);
335
+
336
+ totalCount = mediaChunks.length + 1; // total number of chunks that will be processed
326
337
 
327
338
  const batchSize = 2;
328
339
  sendProgress();
@@ -369,7 +380,7 @@ try {
369
380
  }
370
381
 
371
382
  if (['srt','vtt'].includes(responseFormat) || wordTimestamped) { // align subtitles for formats
372
- return alignSubtitles(result, responseFormat);
383
+ return alignSubtitles(result, responseFormat, offsets);
373
384
  }
374
385
  return result.join(` `);
375
386
  }
@@ -1,358 +0,0 @@
1
- // openAiWhisperPlugin.js
2
- import ModelPlugin from './modelPlugin.js';
3
- import { config } from '../../config.js';
4
- import subsrt from 'subsrt';
5
- import FormData from 'form-data';
6
- import fs from 'fs';
7
- import { axios } from '../../lib/requestExecutor.js';
8
- import stream from 'stream';
9
- import os from 'os';
10
- import path from 'path';
11
- import http from 'http';
12
- import https from 'https';
13
- import { URL } from 'url';
14
- import { v4 as uuidv4 } from 'uuid';
15
- import { promisify } from 'util';
16
- import { publishRequestProgress } from '../../lib/redisSubscription.js';
17
- import logger from '../../lib/logger.js';
18
- const pipeline = promisify(stream.pipeline);
19
-
20
- const API_URL = config.get('whisperMediaApiUrl');
21
- const WHISPER_TS_API_URL = config.get('whisperTSApiUrl');
22
- if(WHISPER_TS_API_URL){
23
- logger.info(`WHISPER API URL using ${WHISPER_TS_API_URL}`);
24
- }else{
25
- logger.warn(`WHISPER API URL not set using default OpenAI API Whisper`);
26
- }
27
-
28
- const OFFSET_CHUNK = 1000 * 60 * 10; // 10 minutes for each chunk
29
-
30
- async function deleteTempPath(path) {
31
- try {
32
- if (!path) {
33
- logger.warn('Temporary path is not defined.');
34
- return;
35
- }
36
- if (!fs.existsSync(path)) {
37
- logger.warn(`Temporary path ${path} does not exist.`);
38
- return;
39
- }
40
- const stats = fs.statSync(path);
41
- if (stats.isFile()) {
42
- fs.unlinkSync(path);
43
- logger.info(`Temporary file ${path} deleted successfully.`);
44
- } else if (stats.isDirectory()) {
45
- fs.rmSync(path, { recursive: true });
46
- logger.info(`Temporary folder ${path} and its contents deleted successfully.`);
47
- }
48
- } catch (err) {
49
- logger.error(`Error occurred while deleting the temporary path: ${err}`);
50
- }
51
- }
52
-
53
- function generateUniqueFilename(extension) {
54
- return `${uuidv4()}.${extension}`;
55
- }
56
-
57
- const downloadFile = async (fileUrl) => {
58
- const fileExtension = path.extname(fileUrl).slice(1);
59
- const uniqueFilename = generateUniqueFilename(fileExtension);
60
- const tempDir = os.tmpdir();
61
- const localFilePath = `${tempDir}/${uniqueFilename}`;
62
-
63
- // eslint-disable-next-line no-async-promise-executor
64
- return new Promise(async (resolve, reject) => {
65
- try {
66
- const parsedUrl = new URL(fileUrl);
67
- const protocol = parsedUrl.protocol === 'https:' ? https : http;
68
-
69
- const response = await new Promise((resolve, reject) => {
70
- protocol.get(parsedUrl, (res) => {
71
- if (res.statusCode === 200) {
72
- resolve(res);
73
- } else {
74
- reject(new Error(`HTTP request failed with status code ${res.statusCode}`));
75
- }
76
- }).on('error', reject);
77
- });
78
-
79
- await pipeline(response, fs.createWriteStream(localFilePath));
80
- logger.info(`Downloaded file to ${localFilePath}`);
81
- resolve(localFilePath);
82
- } catch (error) {
83
- fs.unlink(localFilePath, () => {
84
- reject(error);
85
- });
86
- //throw error;
87
- }
88
- });
89
- };
90
-
91
- // convert srt format to text
92
- function convertToText(str) {
93
- return str
94
- .split('\n')
95
- .filter(line => !line.match(/^\d+$/) && !line.match(/^\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}$/) && line !== '')
96
- .join(' ');
97
- }
98
-
99
- function alignSubtitles(subtitles, format) {
100
- const result = [];
101
-
102
- function preprocessStr(str) {
103
- try{
104
- if(!str) return '';
105
- return str.trim().replace(/(\n\n)(?!\n)/g, '\n\n\n');
106
- }catch(e){
107
- logger.error(`An error occurred in content text preprocessing: ${e}`);
108
- return '';
109
- }
110
- }
111
-
112
- function shiftSubtitles(subtitle, shiftOffset) {
113
- const captions = subsrt.parse(preprocessStr(subtitle));
114
- const resynced = subsrt.resync(captions, { offset: shiftOffset });
115
- return resynced;
116
- }
117
-
118
- for (let i = 0; i < subtitles.length; i++) {
119
- result.push(...shiftSubtitles(subtitles[i], i * OFFSET_CHUNK));
120
- }
121
-
122
- try {
123
- //if content has needed html style tags, keep them
124
- for(const obj of result) {
125
- if(obj && obj.content){
126
- obj.text = obj.content;
127
- }
128
- }
129
- } catch (error) {
130
- logger.error(`An error occurred in content text parsing: ${error}`);
131
- }
132
-
133
- return subsrt.build(result, { format: format === 'vtt' ? 'vtt' : 'srt' });
134
- }
135
-
136
-
137
- class OpenAIWhisperPlugin extends ModelPlugin {
138
- constructor(pathway, model) {
139
- super(pathway, model);
140
- }
141
-
142
- async getMediaChunks(file, requestId) {
143
- try {
144
- if (API_URL) {
145
- //call helper api and get list of file uris
146
- const res = await axios.get(API_URL, { params: { uri: file, requestId } });
147
- return res.data;
148
- } else {
149
- logger.info(`No API_URL set, returning file as chunk`);
150
- return [file];
151
- }
152
- } catch (err) {
153
- logger.error(`Error getting media chunks list from api: ${err}`);
154
- throw err;
155
- }
156
- }
157
-
158
- async markCompletedForCleanUp(requestId) {
159
- try {
160
- if (API_URL) {
161
- //call helper api to mark processing as completed
162
- const res = await axios.delete(API_URL, { params: { requestId } });
163
- logger.info(`Marked request ${requestId} as completed:`, res.data);
164
- return res.data;
165
- }
166
- } catch (err) {
167
- logger.error(`Error marking request ${requestId} as completed: ${err}`);
168
- }
169
- }
170
-
171
- // Execute the request to the OpenAI Whisper API
172
- async execute(text, parameters, prompt, cortexRequest) {
173
- const { pathwayResolver } = cortexRequest;
174
- const { responseFormat, wordTimestamped, highlightWords, maxLineWidth, maxLineCount, maxWordsPerLine } = parameters;
175
- cortexRequest.url = this.requestUrl(text);
176
-
177
- const chunks = [];
178
- const processChunk = async (uri) => {
179
- try {
180
- const chunk = await downloadFile(uri);
181
- chunks.push(chunk);
182
-
183
- const { language, responseFormat } = parameters;
184
- cortexRequest.url = this.requestUrl(text);
185
- const params = {};
186
- const { modelPromptText } = this.getCompiledPrompt(text, parameters, prompt);
187
- const response_format = responseFormat || 'text';
188
-
189
- const formData = new FormData();
190
- formData.append('file', fs.createReadStream(chunk));
191
- formData.append('model', this.model.params.model);
192
- formData.append('response_format', response_format);
193
- language && formData.append('language', language);
194
- modelPromptText && formData.append('prompt', modelPromptText);
195
-
196
- cortexRequest.data = formData;
197
- cortexRequest.params = params;
198
- cortexRequest.headers = { ...cortexRequest.headers, ...formData.getHeaders() };
199
-
200
- return this.executeRequest(cortexRequest);
201
- } catch (err) {
202
- logger.error(`Error getting word timestamped data from api: ${err}`);
203
- throw err;
204
- }
205
- }
206
-
207
- const processTS = async (uri) => {
208
- try {
209
- const tsparams = { fileurl:uri };
210
-
211
- const { language } = parameters;
212
- if(language) tsparams.language = language;
213
- if(highlightWords) tsparams.highlight_words = highlightWords ? "True" : "False";
214
- if(maxLineWidth) tsparams.max_line_width = maxLineWidth;
215
- if(maxLineCount) tsparams.max_line_count = maxLineCount;
216
- if(maxWordsPerLine) tsparams.max_words_per_line = maxWordsPerLine;
217
- if(wordTimestamped!=null) {
218
- if(!wordTimestamped) {
219
- tsparams.word_timestamps = "False";
220
- }else{
221
- tsparams.word_timestamps = wordTimestamped;
222
- }
223
- }
224
-
225
- cortexRequest.url = WHISPER_TS_API_URL;
226
- cortexRequest.data = tsparams;
227
-
228
- const res = await this.executeRequest(cortexRequest);
229
-
230
- if(!wordTimestamped && !responseFormat){
231
- //if no response format, convert to text
232
- return convertToText(res);
233
- }
234
- return res;
235
- } catch (err) {
236
- logger.error(`Error getting word timestamped data from api: ${err}`);
237
- throw err;
238
- }
239
- }
240
-
241
- let result = [];
242
- let { file } = parameters;
243
- let totalCount = 0;
244
- let completedCount = 0;
245
- let partialCount = 0;
246
- const { requestId } = pathwayResolver;
247
-
248
- const MAXPARTIALCOUNT = 60;
249
- const sendProgress = (partial=false) => {
250
- if(partial){
251
- partialCount = Math.min(partialCount + 1, MAXPARTIALCOUNT-1);
252
- }else {
253
- partialCount = 0;
254
- completedCount++;
255
- }
256
- if (completedCount >= totalCount) return;
257
-
258
- const progress = (partialCount / MAXPARTIALCOUNT + completedCount) / totalCount;
259
- logger.info(`Progress for ${requestId}: ${progress}`);
260
-
261
- publishRequestProgress({
262
- requestId,
263
- progress,
264
- data: null,
265
- });
266
- }
267
-
268
- async function processInBatches(tasks, batchSize) {
269
- const batches = chunkArray(tasks,batchSize);
270
- const result = [];
271
- for(let i=0; i < batches.length; i++){
272
- let batch = batches[i];
273
- // execute all tasks in current batch and wait for them to finish
274
- const curBatchResults = await Promise.all(batch.map(task => task()));
275
- // accumulate the results
276
- result.push(...curBatchResults);
277
- }
278
- return result;
279
- }
280
-
281
- // helper function to chunk an array into smaller arrays of size n
282
- function chunkArray(array, chunkSize) {
283
- const results = [];
284
- while (array.length) {
285
- results.push(array.splice(0, chunkSize));
286
- }
287
- return results;
288
- }
289
-
290
- async function processURI(uri) {
291
- let result = null;
292
- let _promise = null;
293
- if(WHISPER_TS_API_URL){
294
- _promise = processTS
295
- }else {
296
- _promise = processChunk;
297
- }
298
- _promise(uri).then((ts) => { result = ts;});
299
-
300
- //send updates while waiting for result
301
- while(!result) {
302
- sendProgress(true);
303
- await new Promise(r => setTimeout(r, 3000));
304
- }
305
- return result;
306
- }
307
-
308
- try {
309
- const uris = await this.getMediaChunks(file, requestId); // array of remote file uris
310
- if (!uris || !uris.length) {
311
- throw new Error(`Error in getting chunks from media helper for file ${file}`);
312
- }
313
- totalCount = uris.length + 1; // total number of chunks that will be processed
314
-
315
- // parallel process of chunks with limit
316
- const tasks = uris.map(uri => () => processURI(uri)); // map each uri to a function that returns a Promise
317
- result = await processInBatches(tasks, 2); // wait for all Promises to resolve, 2 at a time
318
-
319
-
320
- } catch (error) {
321
- const errMsg = `Transcribe error: ${error?.response?.data || error?.message || error}`;
322
- logger.error(errMsg);
323
- return errMsg;
324
- }
325
- finally {
326
- try {
327
- for (const chunk of chunks) {
328
- try {
329
- await deleteTempPath(chunk);
330
- } catch (error) {
331
- //ignore error
332
- }
333
- }
334
-
335
- await this.markCompletedForCleanUp(requestId);
336
-
337
- //check cleanup for whisper temp uploaded files url
338
- const regex = /whispertempfiles\/([a-z0-9-]+)/;
339
- const match = file.match(regex);
340
- if (match && match[1]) {
341
- const extractedValue = match[1];
342
- await this.markCompletedForCleanUp(extractedValue);
343
- logger.info(`Cleaned temp whisper file ${file} with request id ${extractedValue}`);
344
- }
345
-
346
- } catch (error) {
347
- logger.error(`An error occurred while deleting: ${error}`);
348
- }
349
- }
350
-
351
- if (['srt','vtt'].includes(responseFormat) || wordTimestamped) { // align subtitles for formats
352
- return alignSubtitles(result, responseFormat);
353
- }
354
- return result.join(` `);
355
- }
356
- }
357
-
358
- export default OpenAIWhisperPlugin;