mbd-studio-sdk 2.0.3

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.
@@ -0,0 +1,477 @@
1
+ /**
2
+ * Ranking client for MBD Studio SDK.
3
+ * Fluent API: methods return the instance for chaining.
4
+ * Rank items (candidates) with sort, diversity, and limits-by-field.
5
+ */
6
+
7
+ export class Ranking {
8
+ /** @type {string} */
9
+ _url;
10
+
11
+ /** @type {string} */
12
+ _apiKey;
13
+
14
+ /** @type {Array} */
15
+ _candidates = [];
16
+
17
+ /** @type {string} */
18
+ _sortMethod = 'sort';
19
+
20
+ /** @type {{ fields?: string[], direction?: string[] } | Array<{ field: string, weight: number }> | Array<{ field: string, direction: string, percentage: number }> | null} */
21
+ _sortParams = null;
22
+
23
+ /** @type {string | null} */
24
+ _diversityMethod = null;
25
+
26
+ /** @type {{ fields?: string[] } | { lambda?: number, horizon?: number } | null} */
27
+ _diversityParams = null;
28
+
29
+ /** @type {boolean} */
30
+ _limitsByFieldEnabled = false;
31
+
32
+ /** @type {number} */
33
+ _everyN = 10;
34
+
35
+ /** @type {Array<{ field: string, limit: number }>} */
36
+ _limitRules = [];
37
+
38
+ /** @type {{ endpoint: string, payload: object } | null} */
39
+ lastCall = null;
40
+
41
+ /** @type {object | null} */
42
+ lastResult = null;
43
+
44
+ /**
45
+ * @param {Object} options
46
+ * @param {string} options.url - Ranking service base URL
47
+ * @param {string} options.apiKey - API key for authentication
48
+ * @param {Array} [options.candidates] - Items to rank (hits with _id, _features, _scores, _source)
49
+ * @param {string} [options.origin='sdk'] - origin sent in backend call payloads
50
+ * @param {function(string): void} [options.log] - Optional override for log()
51
+ * @param {function(*): void} [options.show] - Optional override for show()
52
+ */
53
+ constructor(options) {
54
+ if (!options || typeof options !== 'object') {
55
+ throw new Error('Ranking: options object is required');
56
+ }
57
+ const { url, apiKey, candidates = [], origin = 'sdk', log, show } = options;
58
+ if (typeof url !== 'string' || !url.trim()) {
59
+ throw new Error('Ranking: options.url is required and must be a non-empty string');
60
+ }
61
+ if (typeof apiKey !== 'string' || !apiKey.trim()) {
62
+ throw new Error('Ranking: options.apiKey is required and must be a non-empty string');
63
+ }
64
+ this._url = url.trim().replace(/\/$/, '');
65
+ this._apiKey = apiKey.trim();
66
+ this._origin = typeof origin === 'string' && origin.trim() ? origin.trim() : 'sdk';
67
+ this._log = typeof log === 'function' ? log : console.log.bind(console);
68
+ this._show = typeof show === 'function' ? show : console.log.bind(console);
69
+ this._candidates = candidates;
70
+ }
71
+
72
+ /**
73
+ * Return the endpoint path for the ranking feed.
74
+ * @returns {string}
75
+ */
76
+ getEndpoint() {
77
+ return '/ranking/feed';
78
+ }
79
+
80
+ /**
81
+ * Collect useful field names from sort, diversity, and limits config.
82
+ * @private
83
+ */
84
+ _getUsefulFieldsAndNeedEmbedding() {
85
+ const useful = new Set();
86
+ let needEmbedding = false;
87
+
88
+ if (this._sortParams) {
89
+ if (this._sortMethod === 'sort' && Array.isArray(this._sortParams.fields)) {
90
+ this._sortParams.fields.forEach((f) => useful.add(f));
91
+ }
92
+ if (this._sortMethod === 'linear' && Array.isArray(this._sortParams)) {
93
+ this._sortParams.forEach((p) => p.field && useful.add(p.field));
94
+ }
95
+ if (this._sortMethod === 'mix' && Array.isArray(this._sortParams)) {
96
+ this._sortParams.forEach((p) => p.field && useful.add(p.field));
97
+ }
98
+ }
99
+ if (this._diversityMethod === 'fields' && this._diversityParams?.fields) {
100
+ this._diversityParams.fields.forEach((f) => useful.add(f));
101
+ }
102
+ if (this._diversityMethod === 'semantic') {
103
+ needEmbedding = true;
104
+ }
105
+ if (this._limitsByFieldEnabled && this._limitRules.length > 0) {
106
+ this._limitRules.forEach((r) => r.field && useful.add(r.field));
107
+ }
108
+
109
+ return { usefulFields: useful, needEmbedding };
110
+ }
111
+
112
+ /**
113
+ * Build the request payload: items from candidates plus sort, diversity, limits_by_field.
114
+ * @returns {object}
115
+ */
116
+ getPayload() {
117
+ const { usefulFields, needEmbedding } = this._getUsefulFieldsAndNeedEmbedding();
118
+ const hits = this._candidates || [];
119
+
120
+ const items = hits.map((hit) => {
121
+ const item = { item_id: hit._id };
122
+ for (const key of usefulFields) {
123
+ const v = hit._features?.[key] ?? hit._scores?.[key];
124
+ if (v !== undefined) item[key] = v;
125
+ }
126
+ if (needEmbedding) {
127
+ let embed = hit._source?.item_sem_embed2;
128
+ if (!embed || !Array.isArray(embed)) {
129
+ embed = hit._source?.text_vector;
130
+ }
131
+ if (embed) item.embed = embed;
132
+ }
133
+ return item;
134
+ });
135
+
136
+ const payload = { origin: this._origin, items };
137
+
138
+ const sortConfig = this._buildSortConfig();
139
+ if (sortConfig) payload.sort = sortConfig;
140
+
141
+ const diversityConfig = this._buildDiversityConfig();
142
+ if (diversityConfig) payload.diversity = diversityConfig;
143
+
144
+ const limitsByFieldConfig = this._buildLimitsByFieldConfig();
145
+ if (limitsByFieldConfig) payload.limits_by_field = limitsByFieldConfig;
146
+
147
+ return payload;
148
+ }
149
+
150
+ /**
151
+ * @private
152
+ */
153
+ _buildSortConfig() {
154
+ if (!this._sortParams) return undefined;
155
+ if (this._sortMethod === 'sort' && this._sortParams.fields?.length > 0) {
156
+ return { method: 'sort', params: { ...this._sortParams } };
157
+ }
158
+ if (this._sortMethod === 'linear' && Array.isArray(this._sortParams) && this._sortParams.length > 0) {
159
+ return { method: 'linear', params: this._sortParams.map((p) => ({ field: p.field, weight: p.weight })) };
160
+ }
161
+ if (this._sortMethod === 'mix' && Array.isArray(this._sortParams) && this._sortParams.length > 0) {
162
+ return {
163
+ method: 'mix',
164
+ params: this._sortParams.map((p) => ({ field: p.field, direction: p.direction, percentage: p.percentage })),
165
+ };
166
+ }
167
+ return undefined;
168
+ }
169
+
170
+ /**
171
+ * @private
172
+ */
173
+ _buildDiversityConfig() {
174
+ if (!this._diversityMethod) return undefined;
175
+ if (this._diversityMethod === 'fields' && this._diversityParams?.fields?.length > 0) {
176
+ return { method: 'fields', params: { fields: [...this._diversityParams.fields] } };
177
+ }
178
+ if (this._diversityMethod === 'semantic') {
179
+ const lambda = this._diversityParams?.lambda ?? 0.5;
180
+ const horizon = this._diversityParams?.horizon ?? 20;
181
+ return { method: 'semantic', params: { lambda: Number(lambda), horizon: Number(horizon) } };
182
+ }
183
+ return undefined;
184
+ }
185
+
186
+ /**
187
+ * @private
188
+ */
189
+ _buildLimitsByFieldConfig() {
190
+ if (!this._limitsByFieldEnabled || !this._limitRules.length) return undefined;
191
+ const everyN = Number(this._everyN);
192
+ if (!Number.isInteger(everyN) || everyN < 2) return undefined;
193
+ return {
194
+ every_n: everyN,
195
+ rules: this._limitRules.map((r) => ({ field: r.field, limit: Number(r.limit) || 0 })),
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Set the sorting method. Applies to subsequent sortBy / weight / mix calls.
201
+ * @param {'sort' | 'linear' | 'mix'} x - Sorting method
202
+ * @returns {this}
203
+ */
204
+ sortingMethod(x) {
205
+ if (x !== 'sort' && x !== 'linear' && x !== 'mix') {
206
+ throw new Error('Ranking.sortingMethod: must be "sort", "linear", or "mix"');
207
+ }
208
+ this._sortMethod = x;
209
+ if (x === 'sort') this._sortParams = { fields: [], direction: [] };
210
+ if (x === 'linear') this._sortParams = [];
211
+ if (x === 'mix') this._sortParams = [];
212
+ return this;
213
+ }
214
+
215
+ /**
216
+ * Set sort by field(s) and direction(s). Applies when sortingMethod is 'sort'.
217
+ * If sorting method was not set yet, it is set to 'sort' for convenience.
218
+ * @param {string} field - Primary sort field
219
+ * @param {'asc' | 'desc'} [direction='desc'] - Primary direction
220
+ * @param {string} [field2] - Optional second sort field
221
+ * @param {'asc' | 'desc'} [direction2='desc'] - Optional second direction
222
+ * @returns {this}
223
+ */
224
+ sortBy(field, direction = 'desc', field2, direction2 = 'desc') {
225
+ if (this._sortMethod === 'linear' || this._sortMethod === 'mix') {
226
+ throw new Error('Ranking.sortBy: only applies when sortingMethod is "sort" (already set to something else)');
227
+ }
228
+ this._sortMethod = 'sort';
229
+ if (!this._sortParams || !this._sortParams.fields) {
230
+ this._sortParams = { fields: [], direction: [] };
231
+ }
232
+ const f = typeof field === 'string' && field.trim() ? field.trim() : null;
233
+ if (!f) {
234
+ throw new Error('Ranking.sortBy: field must be a non-empty string');
235
+ }
236
+ if (direction !== 'asc' && direction !== 'desc') {
237
+ throw new Error('Ranking.sortBy: direction must be "asc" or "desc"');
238
+ }
239
+ this._sortParams = { fields: [f], direction: [direction] };
240
+ if (typeof field2 === 'string' && field2.trim()) {
241
+ this._sortParams.fields.push(field2.trim());
242
+ this._sortParams.direction.push(direction2 === 'asc' ? 'asc' : 'desc');
243
+ }
244
+ return this;
245
+ }
246
+
247
+ /**
248
+ * Add a weighted field for linear combination. Applies when sortingMethod is 'linear'.
249
+ * @param {string} field - Field name
250
+ * @param {number} w - Weight
251
+ * @returns {this}
252
+ */
253
+ weight(field, w) {
254
+ if (this._sortMethod !== 'linear') {
255
+ throw new Error('Ranking.weight: only applies when sortingMethod is "linear"');
256
+ }
257
+ const f = typeof field === 'string' && field.trim() ? field.trim() : null;
258
+ if (!f) {
259
+ throw new Error('Ranking.weight: field must be a non-empty string');
260
+ }
261
+ if (!Array.isArray(this._sortParams)) this._sortParams = [];
262
+ this._sortParams.push({ field: f, weight: Number(w) });
263
+ return this;
264
+ }
265
+
266
+ /**
267
+ * Add a mix row (field, direction, percentage). Applies when sortingMethod is 'mix'.
268
+ * If sorting method was not set yet, it is set to 'mix' for convenience.
269
+ * @param {string} field - Field name
270
+ * @param {'asc' | 'desc'} direction - Sort direction
271
+ * @param {number} percentage - Percentage weight (0–100)
272
+ * @returns {this}
273
+ */
274
+ mix(field, direction, percentage) {
275
+ if (this._sortMethod === 'linear') {
276
+ throw new Error('Ranking.mix: only applies when sortingMethod is "mix" (already set to "linear")');
277
+ }
278
+ this._sortMethod = 'mix';
279
+ if (!Array.isArray(this._sortParams)) this._sortParams = [];
280
+ const f = typeof field === 'string' && field.trim() ? field.trim() : null;
281
+ if (!f) {
282
+ throw new Error('Ranking.mix: field must be a non-empty string');
283
+ }
284
+ if (direction !== 'asc' && direction !== 'desc') {
285
+ throw new Error('Ranking.mix: direction must be "asc" or "desc"');
286
+ }
287
+ this._sortParams.push({ field: f, direction, percentage: Number(percentage) || 0 });
288
+ return this;
289
+ }
290
+
291
+ /**
292
+ * Set the diversity method.
293
+ * @param {'fields' | 'semantic'} method - Diversity method
294
+ * @returns {this}
295
+ */
296
+ diversity(method) {
297
+ if (method !== 'fields' && method !== 'semantic') {
298
+ throw new Error('Ranking.diversity: method must be "fields" or "semantic"');
299
+ }
300
+ this._diversityMethod = method;
301
+ if (method === 'fields') this._diversityParams = { fields: [] };
302
+ if (method === 'semantic') this._diversityParams = { lambda: 0.5, horizon: 20 };
303
+ return this;
304
+ }
305
+
306
+ /**
307
+ * Add field(s) for diversity when diversityMethod is 'fields'. Pass array or single field name.
308
+ * @param {string[] | string} arrayOrItem - Field name(s)
309
+ * @returns {this}
310
+ */
311
+ fields(arrayOrItem) {
312
+ if (this._diversityMethod !== 'fields') {
313
+ throw new Error('Ranking.fields: only applies when diversity(method) is "fields"');
314
+ }
315
+ if (!this._diversityParams || !Array.isArray(this._diversityParams.fields)) {
316
+ this._diversityParams = { fields: [] };
317
+ }
318
+ const add = (name) => {
319
+ const s = typeof name === 'string' && name.trim() ? name.trim() : null;
320
+ if (s && !this._diversityParams.fields.includes(s)) this._diversityParams.fields.push(s);
321
+ };
322
+ if (Array.isArray(arrayOrItem)) {
323
+ arrayOrItem.forEach(add);
324
+ } else {
325
+ add(arrayOrItem);
326
+ }
327
+ return this;
328
+ }
329
+
330
+ /**
331
+ * Set horizon for diversity method 'semantic'. Default 20.
332
+ * @param {number} n - Horizon value
333
+ * @returns {this}
334
+ */
335
+ horizon(n) {
336
+ if (this._diversityMethod !== 'semantic') {
337
+ throw new Error('Ranking.horizon: only applies when diversity(method) is "semantic"');
338
+ }
339
+ if (!this._diversityParams) this._diversityParams = { lambda: 0.5, horizon: 20 };
340
+ this._diversityParams.horizon = Number(n);
341
+ return this;
342
+ }
343
+
344
+ /**
345
+ * Set lambda for diversity method 'semantic'. Default 0.5.
346
+ * @param {number} value - Lambda value (0–1)
347
+ * @returns {this}
348
+ */
349
+ lambda(value) {
350
+ if (this._diversityMethod !== 'semantic') {
351
+ throw new Error('Ranking.lambda: only applies when diversity(method) is "semantic"');
352
+ }
353
+ if (!this._diversityParams) this._diversityParams = { lambda: 0.5, horizon: 20 };
354
+ this._diversityParams.lambda = Number(value);
355
+ return this;
356
+ }
357
+
358
+ /**
359
+ * Enable limits-by-field. Use every(n) and limit(field, max) to configure.
360
+ * @returns {this}
361
+ */
362
+ limitByField() {
363
+ this._limitsByFieldEnabled = true;
364
+ return this;
365
+ }
366
+
367
+ /**
368
+ * Set window size (every_n) for limits by field. Default 10.
369
+ * @param {number} n - Window size (e.g. every 10 items)
370
+ * @returns {this}
371
+ */
372
+ every(n) {
373
+ this._everyN = Number(n);
374
+ return this;
375
+ }
376
+
377
+ /**
378
+ * Add a limit rule: max occurrences of field value per window.
379
+ * @param {string} field - Field name
380
+ * @param {number} max - Maximum count per window
381
+ * @returns {this}
382
+ */
383
+ limit(field, max) {
384
+ const f = typeof field === 'string' && field.trim() ? field.trim() : null;
385
+ if (!f) {
386
+ throw new Error('Ranking.limit: field must be a non-empty string');
387
+ }
388
+ const existing = this._limitRules.find((r) => r.field === f);
389
+ if (existing) existing.limit = Number(max) || 0;
390
+ else this._limitRules.push({ field: f, limit: Number(max) || 0 });
391
+ return this;
392
+ }
393
+
394
+ /**
395
+ * Set the candidates (hits) to rank. Each should have _id, and optionally _features, _scores, _source.
396
+ * @param {Array} candidates - Array of hit objects
397
+ * @returns {this}
398
+ */
399
+ candidates(candidates) {
400
+ this._candidates = candidates;
401
+ return this;
402
+ }
403
+
404
+ /**
405
+ * Execute the ranking request. Sets lastCall and lastResult on success; throws on error.
406
+ * @returns {Promise<object>} The result object (result.result.items with item_id and score)
407
+ */
408
+ async execute() {
409
+ if (!Array.isArray(this._candidates) || this._candidates.length === 0) {
410
+ throw new Error('Ranking.execute: candidates must be set and non-empty (pass to constructor or call candidates([...]))');
411
+ }
412
+ const sortConfig = this._buildSortConfig();
413
+ if (!sortConfig) {
414
+ throw new Error('Ranking.execute: at least one sort configuration is required (e.g. sortingMethod("sort").sortBy("field", "desc"))');
415
+ }
416
+ const endpoint = this.getEndpoint();
417
+ const payload = this.getPayload();
418
+ const url = `${this._url}${endpoint}`;
419
+ this.log(`Sending request to ${url}`);
420
+ const { items, ...rest } = payload;
421
+ const logPayload = { ...rest, items_length: Array.isArray(items) ? items.length : 0 };
422
+ this.log(`Payload:\n${JSON.stringify(logPayload, null, 2)}`);
423
+ const startTime = performance.now();
424
+ const response = await fetch(url, {
425
+ method: 'POST',
426
+ headers: {
427
+ 'Content-Type': 'application/json',
428
+ Authorization: `Bearer ${this._apiKey}`,
429
+ },
430
+ body: JSON.stringify(payload),
431
+ });
432
+ const frontendTime = Math.round(performance.now() - startTime);
433
+ if (!response.ok) {
434
+ const text = await response.text();
435
+ let message = `Ranking API error: ${response.status} ${response.statusText}`;
436
+ if (text) message += ` — ${text}`;
437
+ this.log(message);
438
+ throw new Error(message);
439
+ }
440
+ const result = await response.json();
441
+ if (result && typeof result.error !== 'undefined' && result.error !== null) {
442
+ const msg = typeof result.error === 'string' ? result.error : String(result.error);
443
+ this.log(msg);
444
+ throw new Error(msg);
445
+ }
446
+ result.took_frontend = frontendTime;
447
+ this.lastCall = { endpoint, payload };
448
+ this.lastResult = result;
449
+
450
+ const res = result.result;
451
+ if (!res) {
452
+ throw new Error('Ranking.execute: response result is undefined');
453
+ }
454
+
455
+ const resultItems = res.items || [];
456
+ this._log('Ranking result:');
457
+ this._log(` took_frontend_ms: ${result.took_frontend}`);
458
+ this._log(` items: ${resultItems.length}`);
459
+ return res;
460
+ }
461
+
462
+ /**
463
+ * Log a string.
464
+ * @param {string} string
465
+ */
466
+ log(string) {
467
+ this._log(string);
468
+ }
469
+
470
+ /**
471
+ * Show results.
472
+ * @param {*} results
473
+ */
474
+ show(results) {
475
+ this._show(results);
476
+ }
477
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Scoring client for MBD Studio SDK.
3
+ * Fluent API: methods return the instance for chaining.
4
+ * Score or rerank items for a given user using a selected model.
5
+ */
6
+
7
+ export class Scoring {
8
+ /** @type {string} */
9
+ _url;
10
+
11
+ /** @type {string} */
12
+ _apiKey;
13
+
14
+ /** @type {string | null} */
15
+ _userId = null;
16
+
17
+ /** @type {string[]} */
18
+ _itemIds = [];
19
+
20
+ /** @type {string | null} */
21
+ _modelEndpoint = null;
22
+
23
+ /** @type {{ endpoint: string, payload: object } | null} */
24
+ lastCall = null;
25
+
26
+ /** @type {object | null} */
27
+ lastResult = null;
28
+
29
+ /**
30
+ * @param {Object} options
31
+ * @param {string} options.url - Scoring service base URL
32
+ * @param {string} options.apiKey - API key for authentication
33
+ * @param {string} [options.userId] - User id for scoring context
34
+ * @param {string[]} [options.itemIds] - Item ids to score/rerank
35
+ * @param {string} [options.origin='sdk'] - origin sent in backend call payloads
36
+ * @param {function(string): void} [options.log] - Optional override for log()
37
+ * @param {function(*): void} [options.show] - Optional override for show()
38
+ */
39
+ constructor(options) {
40
+ if (!options || typeof options !== 'object') {
41
+ throw new Error('Scoring: options object is required');
42
+ }
43
+ const { url, apiKey, userId = null, itemIds = [], origin = 'sdk', log, show } = options;
44
+ if (typeof url !== 'string' || !url.trim()) {
45
+ throw new Error('Scoring: options.url is required and must be a non-empty string');
46
+ }
47
+ if (typeof apiKey !== 'string' || !apiKey.trim()) {
48
+ throw new Error('Scoring: options.apiKey is required and must be a non-empty string');
49
+ }
50
+ this._url = url.trim().replace(/\/$/, '');
51
+ this._apiKey = apiKey.trim();
52
+ this._origin = typeof origin === 'string' && origin.trim() ? origin.trim() : 'sdk';
53
+ this._log = typeof log === 'function' ? log : console.log.bind(console);
54
+ this._show = typeof show === 'function' ? show : console.log.bind(console);
55
+ if (userId != null && typeof userId === 'string' && userId.trim()) {
56
+ this._userId = userId.trim();
57
+ }
58
+ if (Array.isArray(itemIds) && itemIds.length > 0) {
59
+ this._itemIds = itemIds.map((id) => (typeof id === 'string' ? id : String(id)));
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Return the endpoint path for the current model (must be set via model(endpoint)).
65
+ * @returns {string}
66
+ */
67
+ getEndpoint() {
68
+ if (!this._modelEndpoint || !this._modelEndpoint.trim()) {
69
+ throw new Error('Scoring.getEndpoint: model endpoint must be set (call model(endpoint) first)');
70
+ }
71
+ return this._modelEndpoint.startsWith('/') ? this._modelEndpoint : `/${this._modelEndpoint}`;
72
+ }
73
+
74
+ /**
75
+ * Build the request payload (origin + user_id + item_ids).
76
+ * @returns {object}
77
+ */
78
+ getPayload() {
79
+ return {
80
+ origin: this._origin,
81
+ user_id: this._userId,
82
+ item_ids: [...this._itemIds],
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Set the model endpoint for scoring/reranking (required).
88
+ * @param {string} endpoint - Endpoint path (e.g. '/scoring/ranking_model/polymarket-rerank-v1')
89
+ * @returns {this}
90
+ */
91
+ model(endpoint) {
92
+ this._modelEndpoint = endpoint;
93
+ return this;
94
+ }
95
+
96
+ /**
97
+ * Set the user id for scoring context.
98
+ * @param {string} userId - User identifier
99
+ * @returns {this}
100
+ */
101
+ userId(userId) {
102
+ this._userId = userId;
103
+ return this;
104
+ }
105
+
106
+ /**
107
+ * Set the item ids to score/rerank.
108
+ * @param {string[]} itemIds - Array of item id strings
109
+ * @returns {this}
110
+ */
111
+ itemIds(itemIds) {
112
+ this._itemIds = itemIds;
113
+ return this;
114
+ }
115
+
116
+ /**
117
+ * Execute the scoring request. Sets lastCall and lastResult on success; throws on error.
118
+ * @returns {Promise<object>} The result object (e.g. result.result with personalizedRanking, etc.)
119
+ */
120
+ async execute() {
121
+ if (!this._modelEndpoint || !this._modelEndpoint.trim()) {
122
+ throw new Error('Scoring.execute: model endpoint must be set (call model(endpoint) first)');
123
+ }
124
+ if (!this._userId || typeof this._userId !== 'string' || !this._userId.trim()) {
125
+ throw new Error('Scoring.execute: user_id must be set (pass to constructor or call userId(id))');
126
+ }
127
+ if (!Array.isArray(this._itemIds) || this._itemIds.length === 0) {
128
+ throw new Error('Scoring.execute: item_ids must be set and non-empty (pass to constructor or call itemIds([...]))');
129
+ }
130
+ const endpoint = this.getEndpoint();
131
+ const payload = this.getPayload();
132
+ const url = `${this._url}${endpoint}`;
133
+ this.log(`Sending request to ${url}`);
134
+ const startTime = performance.now();
135
+ const response = await fetch(url, {
136
+ method: 'POST',
137
+ headers: {
138
+ 'Content-Type': 'application/json',
139
+ Authorization: `Bearer ${this._apiKey}`,
140
+ },
141
+ body: JSON.stringify(payload),
142
+ });
143
+ const frontendTime = Math.round(performance.now() - startTime);
144
+ if (!response.ok) {
145
+ const text = await response.text();
146
+ let message = `Scoring API error: ${response.status} ${response.statusText}`;
147
+ if (text) message += ` — ${text}`;
148
+ this.log(message);
149
+ throw new Error(message);
150
+ }
151
+ const result = await response.json();
152
+ if (result && typeof result.error !== 'undefined' && result.error !== null) {
153
+ const msg = typeof result.error === 'string' ? result.error : String(result.error);
154
+ this.log(msg);
155
+ throw new Error(msg);
156
+ }
157
+ result.took_frontend = frontendTime;
158
+ this.lastCall = { endpoint, payload };
159
+ this.lastResult = result;
160
+
161
+ const res = result.result;
162
+ if (res === undefined) {
163
+ throw new Error('Scoring.execute: result.result is undefined');
164
+ }
165
+
166
+ this._log('Scoring result:');
167
+ this._log(` took_frontend_ms: ${result.took_frontend}`);
168
+ this._log(` Array length: ${res.length}`);
169
+ return res;
170
+ }
171
+
172
+ /**
173
+ * Log a string.
174
+ * @param {string} string
175
+ */
176
+ log(string) {
177
+ this._log(string);
178
+ }
179
+
180
+ /**
181
+ * Show results.
182
+ * @param {*} results
183
+ */
184
+ show(results) {
185
+ this._show(results);
186
+ }
187
+ }