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.
- package/V1/Studio.js +262 -0
- package/V1/features/Features.js +249 -0
- package/V1/index.js +1 -0
- package/V1/ranking/Ranking.js +477 -0
- package/V1/scoring/Scoring.js +187 -0
- package/V1/search/Search.js +673 -0
- package/V1/search/filters/ConsoleAccountFilter.js +18 -0
- package/V1/search/filters/CustomFilter.js +16 -0
- package/V1/search/filters/DateFilter.js +23 -0
- package/V1/search/filters/Filter.js +20 -0
- package/V1/search/filters/GeoFilter.js +16 -0
- package/V1/search/filters/GroupBoostFilter.js +26 -0
- package/V1/search/filters/IsNullFilter.js +14 -0
- package/V1/search/filters/MatchFilter.js +16 -0
- package/V1/search/filters/NotNullFilter.js +14 -0
- package/V1/search/filters/NumericFilter.js +18 -0
- package/V1/search/filters/TermFilter.js +16 -0
- package/V1/search/filters/TermsFilter.js +16 -0
- package/V1/search/filters/TermsLookupFilter.js +20 -0
- package/V1/search/filters/index.js +13 -0
- package/V1/utils/indexUtils.js +12 -0
- package/index.js +4 -0
- package/package.json +14 -0
|
@@ -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
|
+
}
|