@aj-archipelago/cortex 1.0.19 → 1.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config.js +6 -0
- package/helper_apps/MediaFileChunker/docHelper.js +34 -2
- package/helper_apps/MediaFileChunker/index.js +4 -3
- package/lib/request.js +2 -1
- package/lib/requestDurationEstimator.js +90 -0
- package/package.json +1 -1
- package/pathways/basePathway.js +2 -0
- package/pathways/image.js +4 -0
- package/server/graphql.js +1 -0
- package/server/pathwayPrompter.js +5 -1
- package/server/pathwayResolver.js +14 -8
- package/server/plugins/azureCognitivePlugin.js +5 -4
- package/server/plugins/openAiImagePlugin.js +85 -0
- package/server/plugins/openAiWhisperPlugin.js +3 -3
- package/tests/requestDurationEstimator.test.js +59 -0
package/config.js
CHANGED
|
@@ -95,6 +95,7 @@ var config = convict({
|
|
|
95
95
|
"api-key": "{{AZURE_COGNITIVE_API_KEY}}",
|
|
96
96
|
"Content-Type": "application/json"
|
|
97
97
|
},
|
|
98
|
+
"requestsPerSecond": 6
|
|
98
99
|
},
|
|
99
100
|
"oai-embeddings": {
|
|
100
101
|
"type": "OPENAI-EMBEDDINGS",
|
|
@@ -148,6 +149,11 @@ var config = convict({
|
|
|
148
149
|
sensitive: true,
|
|
149
150
|
env: 'STORAGE_CONNECTION_STRING'
|
|
150
151
|
},
|
|
152
|
+
dalleImageApiUrl: {
|
|
153
|
+
format: String,
|
|
154
|
+
default: 'null',
|
|
155
|
+
env: 'DALLE_IMAGE_API_URL'
|
|
156
|
+
},
|
|
151
157
|
whisperMediaApiUrl: {
|
|
152
158
|
format: String,
|
|
153
159
|
default: 'null',
|
|
@@ -30,17 +30,49 @@ export async function xlsxToText(filePath) {
|
|
|
30
30
|
return finalText;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
async function pdfToText(filePath) {
|
|
34
34
|
const pdf = await pdfjsLib.getDocument(filePath).promise;
|
|
35
|
+
const meta = await pdf.getMetadata();
|
|
36
|
+
|
|
37
|
+
// Check if pdf is scanned
|
|
38
|
+
if (meta && meta.metadata && meta.metadata._metadataMap && meta.metadata._metadataMap.has('dc:format')) {
|
|
39
|
+
const format = meta.metadata._metadataMap.get('dc:format');
|
|
40
|
+
if (format && format._value && format._value.toLowerCase() === 'application/pdf; version=1.3') {
|
|
41
|
+
throw new Error('Scanned PDFs are not supported');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check if pdf is encrypted
|
|
46
|
+
if (pdf._pdfInfo && pdf._pdfInfo.encrypt) {
|
|
47
|
+
throw new Error('Encrypted PDFs are not supported');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check if pdf is password protected
|
|
51
|
+
if (pdf._passwordNeeded) {
|
|
52
|
+
throw new Error('Password protected PDFs are not supported');
|
|
53
|
+
}
|
|
54
|
+
|
|
35
55
|
let finalText = '';
|
|
56
|
+
let ocrNeeded = true; // Initialize the variable as true
|
|
36
57
|
|
|
37
|
-
for(let i = 1; i <= pdf.numPages; i++) {
|
|
58
|
+
for (let i = 1; i <= pdf.numPages; i++) {
|
|
38
59
|
const page = await pdf.getPage(i);
|
|
60
|
+
const operatorList = await page.getOperatorList();
|
|
61
|
+
|
|
62
|
+
// Check if there are any fonts used in the PDF
|
|
63
|
+
if (operatorList.fnArray.some(fn => fn === pdfjsLib.OPS.setFont)) {
|
|
64
|
+
ocrNeeded = false; // Set ocrNeeded to false if fonts are found
|
|
65
|
+
}
|
|
66
|
+
|
|
39
67
|
const textContent = await page.getTextContent();
|
|
40
68
|
const strings = textContent.items.map(item => item.str);
|
|
41
69
|
finalText += strings.join(' ') + '\n';
|
|
42
70
|
}
|
|
43
71
|
|
|
72
|
+
if (ocrNeeded) {
|
|
73
|
+
throw new Error('OCR might be needed for this document!');
|
|
74
|
+
}
|
|
75
|
+
|
|
44
76
|
return finalText.trim();
|
|
45
77
|
}
|
|
46
78
|
|
|
@@ -150,6 +150,9 @@ async function main(context, req) {
|
|
|
150
150
|
}
|
|
151
151
|
} catch (error) {
|
|
152
152
|
console.error("An error occurred:", error);
|
|
153
|
+
context.res.status(500);
|
|
154
|
+
context.res.body = error.message || error;
|
|
155
|
+
return;
|
|
153
156
|
} finally {
|
|
154
157
|
try {
|
|
155
158
|
(isYoutubeUrl) && (await deleteTempPath(file));
|
|
@@ -159,13 +162,11 @@ async function main(context, req) {
|
|
|
159
162
|
}
|
|
160
163
|
}
|
|
161
164
|
|
|
162
|
-
|
|
163
165
|
console.log(`result: ${result}`);
|
|
164
|
-
|
|
165
166
|
context.res = {
|
|
166
|
-
// status: 200, /* Defaults to 200 */
|
|
167
167
|
body: result
|
|
168
168
|
};
|
|
169
|
+
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
|
package/lib/request.js
CHANGED
|
@@ -169,7 +169,8 @@ const postRequest = async ({ url, data, params, headers, cache }, model, request
|
|
|
169
169
|
try {
|
|
170
170
|
const response = await Promise.race(promises);
|
|
171
171
|
|
|
172
|
-
if
|
|
172
|
+
// if response status is 2xx
|
|
173
|
+
if (response.status >= 200 && response.status < 300) {
|
|
173
174
|
return response;
|
|
174
175
|
} else {
|
|
175
176
|
throw new Error(`Received error response: ${response.status}`);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A class to get request durations and estimate their average.
|
|
3
|
+
*/
|
|
4
|
+
export default class RequestDurationEstimator {
|
|
5
|
+
// Initializing the class with given number of durations to track.
|
|
6
|
+
constructor(n = 10) {
|
|
7
|
+
this.n = n; // Number of last durations to consider
|
|
8
|
+
this.durations = []; // List to keep track of last n durations
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Private method to add a request duration to the durations list.
|
|
13
|
+
* If the list is full (n durations already), the oldest duration is removed.
|
|
14
|
+
* @param {number} duration - The duration of the request
|
|
15
|
+
*/
|
|
16
|
+
#add(duration) {
|
|
17
|
+
this.durations.push(duration);
|
|
18
|
+
// Remove the oldest duration if we have stored n durations
|
|
19
|
+
if (this.durations.length > this.n) {
|
|
20
|
+
this.durations.shift();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* To be invoked when a request starts.
|
|
26
|
+
* If there is an ongoing request, it ends that request.
|
|
27
|
+
* @param {string} requestId - The ID of the request
|
|
28
|
+
*/
|
|
29
|
+
startRequest(requestId) {
|
|
30
|
+
// If there is an ongoing request, end it
|
|
31
|
+
if (this.requestId) {
|
|
32
|
+
this.endRequest();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Store the starting details of the new request
|
|
36
|
+
this.requestId = requestId;
|
|
37
|
+
this.startTime = Date.now();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* To be invoked when a request ends.
|
|
42
|
+
* Calculates the duration of the request and adds it to the durations list.
|
|
43
|
+
*/
|
|
44
|
+
endRequest() {
|
|
45
|
+
// If there is an ongoing request, add its duration to the durations list
|
|
46
|
+
if (this.requestId) {
|
|
47
|
+
this.#add(Date.now() - this.startTime);
|
|
48
|
+
this.requestId = null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Calculate and return the average of the request durations.
|
|
54
|
+
* @return {number} The average request duration
|
|
55
|
+
*/
|
|
56
|
+
getAverage() {
|
|
57
|
+
// If no duration is stored, return 0
|
|
58
|
+
if (!this.durations.length) {
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Calculate the sum of the durations and divide by the number of durations to get the average
|
|
63
|
+
return this.durations.reduce((a, b) => a + b) / this.durations.length;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Calculate the percentage completion of the current request based on the average of past durations.
|
|
68
|
+
* @return {number} The estimated percent completion of the ongoing request
|
|
69
|
+
*/
|
|
70
|
+
calculatePercentComplete() {
|
|
71
|
+
// If no duration is stored, return 0
|
|
72
|
+
if (!this.durations.length) {
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
// Calculate the duration of the current request
|
|
78
|
+
const duration = Date.now() - this.startTime;
|
|
79
|
+
// Get the average of the durations
|
|
80
|
+
const average = this.getAverage();
|
|
81
|
+
// Calculate the percentage completion
|
|
82
|
+
let percentComplete = duration / average;
|
|
83
|
+
|
|
84
|
+
if (percentComplete > .8) {
|
|
85
|
+
percentComplete = 0.8;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return percentComplete;
|
|
89
|
+
}
|
|
90
|
+
}
|
package/package.json
CHANGED
package/pathways/basePathway.js
CHANGED
|
@@ -26,5 +26,7 @@ export default {
|
|
|
26
26
|
// args: the input arguments to the pathway
|
|
27
27
|
// runAllPrompts: a function that runs all prompts in the pathway and returns the result
|
|
28
28
|
executePathway: undefined,
|
|
29
|
+
// Set the temperature to 0 to favor more deterministic output when generating entity extraction.
|
|
30
|
+
temperature: undefined,
|
|
29
31
|
};
|
|
30
32
|
|
package/server/graphql.js
CHANGED
|
@@ -12,16 +12,20 @@ import CohereGeneratePlugin from './plugins/cohereGeneratePlugin.js';
|
|
|
12
12
|
import CohereSummarizePlugin from './plugins/cohereSummarizePlugin.js';
|
|
13
13
|
import AzureCognitivePlugin from './plugins/azureCognitivePlugin.js';
|
|
14
14
|
import OpenAiEmbeddingsPlugin from './plugins/openAiEmbeddingsPlugin.js';
|
|
15
|
+
import OpenAIImagePlugin from './plugins/openAiImagePlugin.js';
|
|
15
16
|
|
|
16
17
|
class PathwayPrompter {
|
|
17
18
|
constructor(config, pathway, modelName, model) {
|
|
18
|
-
|
|
19
|
+
|
|
19
20
|
let plugin;
|
|
20
21
|
|
|
21
22
|
switch (model.type) {
|
|
22
23
|
case 'OPENAI-CHAT':
|
|
23
24
|
plugin = new OpenAIChatPlugin(config, pathway, modelName, model);
|
|
24
25
|
break;
|
|
26
|
+
case 'OPENAI-IMAGE':
|
|
27
|
+
plugin = new OpenAIImagePlugin(config, pathway, modelName, model);
|
|
28
|
+
break;
|
|
25
29
|
case 'OPENAI-CHAT-EXTENSION':
|
|
26
30
|
plugin = new OpenAIChatExtensionPlugin(config, pathway, modelName, model);
|
|
27
31
|
break;
|
|
@@ -10,6 +10,8 @@ import { getv, setv } from '../lib/keyValueStorageClient.js';
|
|
|
10
10
|
import { requestState } from './requestState.js';
|
|
11
11
|
import { callPathway } from '../lib/pathwayTools.js';
|
|
12
12
|
|
|
13
|
+
const modelTypesExcludedFromProgressUpdates = ['OPENAI-IMAGE'];
|
|
14
|
+
|
|
13
15
|
class PathwayResolver {
|
|
14
16
|
constructor({ config, pathway, args }) {
|
|
15
17
|
this.config = config;
|
|
@@ -78,13 +80,17 @@ class PathwayResolver {
|
|
|
78
80
|
if (args.async || typeof responseData === 'string') {
|
|
79
81
|
const { completedCount, totalCount } = requestState[this.requestId];
|
|
80
82
|
requestState[this.requestId].data = responseData;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
|
|
84
|
+
// if model type is OPENAI-IMAGE
|
|
85
|
+
if (!modelTypesExcludedFromProgressUpdates.includes(this.model.type)) {
|
|
86
|
+
pubsub.publish('REQUEST_PROGRESS', {
|
|
87
|
+
requestProgress: {
|
|
88
|
+
requestId: this.requestId,
|
|
89
|
+
progress: completedCount / totalCount,
|
|
90
|
+
data: JSON.stringify(responseData),
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
88
94
|
} else {
|
|
89
95
|
try {
|
|
90
96
|
const incomingMessage = responseData;
|
|
@@ -290,7 +296,7 @@ class PathwayResolver {
|
|
|
290
296
|
text = await this.summarizeIfEnabled({ text, ...parameters }); // summarize if flag enabled
|
|
291
297
|
const chunks = this.processInputText(text);
|
|
292
298
|
|
|
293
|
-
|
|
299
|
+
let anticipatedRequestCount = chunks.length * this.prompts.length
|
|
294
300
|
|
|
295
301
|
if ((requestState[this.requestId] || {}).canceled) {
|
|
296
302
|
throw new Error('Request canceled');
|
|
@@ -155,11 +155,12 @@ class AzureCognitivePlugin extends ModelPlugin {
|
|
|
155
155
|
const extension = path.extname(file).toLowerCase();
|
|
156
156
|
if (!DIRECT_FILE_EXTENSIONS.includes(extension)) {
|
|
157
157
|
try {
|
|
158
|
-
const {data}
|
|
159
|
-
url = data[0]
|
|
158
|
+
const { data } = await axios.get(API_URL, { params: { uri: file, requestId, save: true } });
|
|
159
|
+
url = data[0];
|
|
160
160
|
} catch (error) {
|
|
161
|
-
console.
|
|
162
|
-
|
|
161
|
+
console.error(`Error converting file ${file} to txt:`, error);
|
|
162
|
+
await this.markCompletedForCleanUp(requestId);
|
|
163
|
+
throw Error(error?.response?.data || error?.message || error);
|
|
163
164
|
}
|
|
164
165
|
}
|
|
165
166
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// OpenAIImagePlugin.js
|
|
2
|
+
import FormData from 'form-data';
|
|
3
|
+
import { config } from '../../config.js';
|
|
4
|
+
import ModelPlugin from './modelPlugin.js';
|
|
5
|
+
import pubsub from '../pubsub.js';
|
|
6
|
+
import axios from 'axios';
|
|
7
|
+
import RequestDurationEstimator from '../../lib/requestDurationEstimator.js';
|
|
8
|
+
|
|
9
|
+
const API_URL = config.get('dalleImageApiUrl'); // URL for the DALL-E API
|
|
10
|
+
const requestDurationEstimator = new RequestDurationEstimator(10);
|
|
11
|
+
|
|
12
|
+
class OpenAIImagePlugin extends ModelPlugin {
|
|
13
|
+
constructor(config, pathway, modelName, model) {
|
|
14
|
+
super(config, pathway, modelName, model);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Implement the method to call the DALL-E API
|
|
18
|
+
async execute(text, parameters, _, pathwayResolver) {
|
|
19
|
+
const url = this.requestUrl(text);
|
|
20
|
+
const data = JSON.stringify({ prompt: text });
|
|
21
|
+
|
|
22
|
+
let id;
|
|
23
|
+
const { requestId, pathway } = pathwayResolver;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
requestDurationEstimator.startRequest(requestId);
|
|
27
|
+
id = (await this.executeRequest(url, data, {}, { ...this.model.headers }, {}, requestId, pathway))?.id;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
const errMsg = `Error generating image: ${error?.message || JSON.stringify(error)}`;
|
|
30
|
+
console.error(errMsg);
|
|
31
|
+
return errMsg;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!parameters.async) {
|
|
35
|
+
return await this.getStatus(text, id, requestId);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
this.getStatus(text, id, requestId);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async getStatus(text, id, requestId) {
|
|
43
|
+
// get the post URL which is used to send the request
|
|
44
|
+
const url = this.requestUrl(text);
|
|
45
|
+
|
|
46
|
+
// conver it to the GET URL which is used to check the status
|
|
47
|
+
const statusUrl = url.replace("images/generations:submit", `operations/images/${id}`);
|
|
48
|
+
let status;
|
|
49
|
+
let attemptCount = 0;
|
|
50
|
+
let data = null;
|
|
51
|
+
|
|
52
|
+
do {
|
|
53
|
+
const response = (await axios.get(statusUrl, { cache: false, headers: { ...this.model.headers } })).data;
|
|
54
|
+
status = response.status;
|
|
55
|
+
let progress =
|
|
56
|
+
requestDurationEstimator.calculatePercentComplete();
|
|
57
|
+
|
|
58
|
+
if (status === "succeeded") {
|
|
59
|
+
progress = 1;
|
|
60
|
+
data = JSON.stringify(response);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
pubsub.publish('REQUEST_PROGRESS', {
|
|
64
|
+
requestProgress: {
|
|
65
|
+
requestId,
|
|
66
|
+
status,
|
|
67
|
+
progress,
|
|
68
|
+
data,
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (status === "succeeded") {
|
|
73
|
+
requestDurationEstimator.endRequest();
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
// sleep for 5 seconds
|
|
77
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
78
|
+
}
|
|
79
|
+
while (status !== "succeeded" && attemptCount++ < 30);
|
|
80
|
+
|
|
81
|
+
return data;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export default OpenAIImagePlugin;
|
|
@@ -21,7 +21,7 @@ const pipeline = promisify(stream.pipeline);
|
|
|
21
21
|
const API_URL = config.get('whisperMediaApiUrl');
|
|
22
22
|
const WHISPER_TS_API_URL = config.get('whisperTSApiUrl');
|
|
23
23
|
|
|
24
|
-
function alignSubtitles(subtitles) {
|
|
24
|
+
function alignSubtitles(subtitles, format) {
|
|
25
25
|
const result = [];
|
|
26
26
|
const offset = 1000 * 60 * 10; // 10 minutes for each chunk
|
|
27
27
|
|
|
@@ -39,7 +39,7 @@ function alignSubtitles(subtitles) {
|
|
|
39
39
|
const subtitle = subtitles[i];
|
|
40
40
|
result.push(...shiftSubtitles(subtitle, i * offset));
|
|
41
41
|
}
|
|
42
|
-
return subsrt.build(result);
|
|
42
|
+
return subsrt.build(result, { format: format === 'vtt' ? 'vtt' : 'srt' });
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
function generateUniqueFilename(extension) {
|
|
@@ -234,7 +234,7 @@ class OpenAIWhisperPlugin extends ModelPlugin {
|
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
if (['srt','vtt'].includes(responseFormat) || wordTimestamped) { // align subtitles for formats
|
|
237
|
-
return alignSubtitles(result);
|
|
237
|
+
return alignSubtitles(result, responseFormat);
|
|
238
238
|
}
|
|
239
239
|
return result.join(` `);
|
|
240
240
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import test from 'ava';
|
|
2
|
+
import RequestDurationEstimator from '../lib/requestDurationEstimator.js';
|
|
3
|
+
|
|
4
|
+
test('add and get average request duration', async (t) => {
|
|
5
|
+
const estimator = new RequestDurationEstimator(5);
|
|
6
|
+
|
|
7
|
+
estimator.startRequest('req1');
|
|
8
|
+
await new Promise(resolve => setTimeout(() => {
|
|
9
|
+
estimator.endRequest();
|
|
10
|
+
|
|
11
|
+
const average = estimator.calculatePercentComplete();
|
|
12
|
+
|
|
13
|
+
// An average should be calculated after the first completed request
|
|
14
|
+
t.not(average, 0);
|
|
15
|
+
resolve();
|
|
16
|
+
}, 1000));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('add more requests than size of durations array', (t) => {
|
|
20
|
+
const estimator = new RequestDurationEstimator(5);
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i < 10; i++) {
|
|
23
|
+
estimator.startRequest(`req${i}`);
|
|
24
|
+
estimator.endRequest();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Array size should not exceed maximum length (5 in this case)
|
|
28
|
+
t.is(estimator.durations.length, 5);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('calculate percent complete of current request based on average of past durations', async (t) => {
|
|
32
|
+
const estimator = new RequestDurationEstimator(5);
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i < 4; i++) {
|
|
35
|
+
estimator.startRequest(`req${i}`);
|
|
36
|
+
// wait 1 second
|
|
37
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
38
|
+
estimator.endRequest();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
estimator.startRequest('req5');
|
|
42
|
+
|
|
43
|
+
await new Promise(resolve => setTimeout(() => {
|
|
44
|
+
const percentComplete = estimator.calculatePercentComplete();
|
|
45
|
+
|
|
46
|
+
// Depending on how fast the operations are,
|
|
47
|
+
// the percentage may not be exactly 50%, but
|
|
48
|
+
// we'll affirm it should be at least partially complete.
|
|
49
|
+
t.true(percentComplete > 0);
|
|
50
|
+
resolve();
|
|
51
|
+
}, 500));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('calculate percent complete based on average of past durations', async (t) => {
|
|
55
|
+
const estimator = new RequestDurationEstimator(5);
|
|
56
|
+
estimator.durations = [1000, 2000, 3000];
|
|
57
|
+
const average = estimator.getAverage();
|
|
58
|
+
t.is(average, 2000);
|
|
59
|
+
});
|