mbd-studio-sdk 4.0.0 → 4.1.1

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/StudioConfig.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const defaultBaseUrl = 'https://api.mbd.xyz/v3/studio';
2
2
 
3
-
3
+ /** API config: apiKey required; commonUrl (single base) or servicesUrl (per-service) for endpoints. */
4
4
  export class StudioConfig {
5
5
  constructor(options) {
6
6
  if (!options || typeof options !== 'object') {
package/V1/Studio.js CHANGED
@@ -5,6 +5,7 @@ import { Scoring } from './scoring/Scoring.js';
5
5
  import { Ranking } from './ranking/Ranking.js';
6
6
  import { findIndex } from './utils/indexUtils.js';
7
7
 
8
+ /** Main client: search, features, scoring, ranking. Use addCandidates() then addFeatures/addScores/addRanking to enrich. */
8
9
  export class Studio {
9
10
  constructor(options) {
10
11
  if (!options || typeof options !== 'object') {
@@ -56,6 +57,7 @@ export class Studio {
56
57
  origin: this._origin,
57
58
  });
58
59
  }
60
+ /** Merges featuresResult (features, scores, info) into hits. Keys use canonical index names (findIndex). */
59
61
  addFeatures(featuresResult) {
60
62
  const hits = this._candidates || [];
61
63
  const features = featuresResult.features;
@@ -118,6 +120,7 @@ export class Studio {
118
120
  origin: this._origin,
119
121
  });
120
122
  }
123
+ /** Maps ranked item IDs to scores (1.0 for rank 0, decreasing by rank). Merges into hit._scores[scoringKey]. */
121
124
  addScores(scoringResult, scoringKey) {
122
125
  const rankedItemIds = scoringResult;
123
126
  if (!this._candidates || !rankedItemIds || !Array.isArray(rankedItemIds)) return;
@@ -144,6 +147,7 @@ export class Studio {
144
147
  origin: this._origin,
145
148
  });
146
149
  }
150
+ /** Sets hit._ranking_score from ranking items, then sorts candidates by score descending. */
147
151
  addRanking(rankingResult) {
148
152
  const rankedItems = rankingResult?.items;
149
153
  if (!this._candidates || !rankedItems || !Array.isArray(rankedItems)) return;
@@ -1,3 +1,4 @@
1
+ /** Feature columns shown first when listing available features (relevance, affinity, clusters, semantic). */
1
2
  export const PREFERRED_FEATURE_COLUMNS = [
2
3
  'found', 'original_rank', 'sem_sim_fuzzy', 'sem_sim_closest',
3
4
  'usr_primary_labels', 'usr_secondary_labels', 'usr_primary_tags', 'usr_secondary_tags',
@@ -110,7 +111,7 @@ export class Features {
110
111
  item_embed_rate_pct: ((res.item_embed_rate ?? 0) * 100).toFixed(1),
111
112
  };
112
113
  this._log('Features result:');
113
- this._log(` took_frontend_ms: ${infos.took_frontend_ms}`);
114
+ this._log(` took_sdk_ms: ${infos.took_sdk_ms}`);
114
115
  this._log(` took_backend_ms: ${infos.took_backend_ms}`);
115
116
  this._log(` took_dynamo_user_ms: ${infos.took_dynamo_user_ms}`);
116
117
  this._log(` took_dynamo_items_ms: ${infos.took_dynamo_items_ms}`);
@@ -30,6 +30,7 @@ export class Ranking {
30
30
  getEndpoint() {
31
31
  return '/ranking/feed';
32
32
  }
33
+ /** Collects fields referenced by sort/diversity/limits; sets needEmbedding for semantic diversity. */
33
34
  _getUsefulFieldsAndNeedEmbedding() {
34
35
  const useful = new Set();
35
36
  let needEmbedding = false;
@@ -53,6 +54,7 @@ export class Ranking {
53
54
  }
54
55
  return { usefulFields: useful, needEmbedding };
55
56
  }
57
+ /** Builds items from hit._features, hit._scores; adds embed (item_sem_embed2 or text_vector) if semantic diversity. */
56
58
  getPayload() {
57
59
  const { usefulFields, needEmbedding } = this._getUsefulFieldsAndNeedEmbedding();
58
60
  const hits = this._candidates || [];
@@ -3,6 +3,7 @@ import {
3
3
  IsNullFilter, NotNullFilter, CustomFilter, GroupBoostFilter, TermsLookupFilter, ConsoleAccountFilter,
4
4
  } from './filters/index.js';
5
5
 
6
+ /** Search builder: index(), include()/exclude()/boost(), filters, execute(). Call include/exclude/boost before adding filters. */
6
7
  export class Search {
7
8
  _index = null;
8
9
  _es_query = null;
@@ -16,6 +17,7 @@ export class Search {
16
17
  _include = [];
17
18
  _exclude = [];
18
19
  _boost = [];
20
+ /** Set by include()/exclude()/boost(); filters are pushed to whichever array is active. */
19
21
  _active_array = null;
20
22
  lastCall = null;
21
23
  lastResult = null;
@@ -36,6 +38,7 @@ export class Search {
36
38
  this._log = typeof log === 'function' ? log : console.log.bind(console);
37
39
  this._show = typeof show === 'function' ? show : console.log.bind(console);
38
40
  }
41
+ /** Endpoint selection: es_query > semantic (text/vector) > boost > filter_and_sort. */
39
42
  getEndpoint() {
40
43
  if (this._es_query != null) return '/search/es_query';
41
44
  const hasTextOrVector = (typeof this._text === 'string' && this._text.length > 0) || (Array.isArray(this._vector) && this._vector.length > 0);
@@ -76,7 +79,40 @@ export class Search {
76
79
  if (this._es_query != null && (typeof this._es_query !== 'object' || Array.isArray(this._es_query))) {
77
80
  throw new Error('Search.execute: esQuery() must be called with a plain object (e.g. { query: {...}, sort: [...] })');
78
81
  }
82
+ const hasOnlyIds = this._only_ids === true;
83
+ const hasSelectFields = Array.isArray(this._select_fields) && this._select_fields.length > 0;
84
+ const hasIncludeVector = this._include_vector === true;
85
+ const exclusiveCount = (hasOnlyIds ? 1 : 0) + (hasSelectFields ? 1 : 0) + (hasIncludeVector ? 1 : 0);
86
+ if (exclusiveCount > 1) {
87
+ throw new Error(
88
+ 'Search: onlyIds, selectFields, and includeVectors are mutually exclusive; only one may be set at a time.'
89
+ );
90
+ }
79
91
  const endpoint = this.getEndpoint();
92
+ if (endpoint === '/search/es_query') {
93
+ if (this._include.length > 0 || this._exclude.length > 0 || this._boost.length > 0) {
94
+ throw new Error(
95
+ 'Search: esQuery() does not support include(), exclude(), or boost() filters. Add filters directly in your Elasticsearch query.'
96
+ );
97
+ }
98
+ } else if (endpoint === '/search/semantic') {
99
+ if (this._include.length > 0 || this._exclude.length > 0 || this._boost.length > 0) {
100
+ throw new Error(
101
+ 'Search: semantic search does not support include(), exclude(), or boost() filters. Use filter_and_sort or boost endpoints for filtering.'
102
+ );
103
+ }
104
+ if (this._sort_by != null) {
105
+ throw new Error(
106
+ 'Search: semantic search does not support sortBy(). Results are ranked by similarity.'
107
+ );
108
+ }
109
+ } else if (endpoint === '/search/boost') {
110
+ if (this._sort_by != null) {
111
+ throw new Error(
112
+ 'Search: boost endpoint does not support sortBy(). Use filter_and_sort for sorting.'
113
+ );
114
+ }
115
+ }
80
116
  const payload = this.getPayload();
81
117
  const url = `${this._url}${endpoint}`;
82
118
  this.log(`Sending request to ${url}`);
@@ -1,4 +1,6 @@
1
1
  import { Filter } from './Filter.js';
2
+
3
+ /** Filters by console account data. path: dot-notation field in the account doc. */
2
4
  export class ConsoleAccountFilter extends Filter {
3
5
  constructor(field, value, path, boost = null) {
4
6
  super('console_account', field, boost);
@@ -1,3 +1,18 @@
1
+ /**
2
+ * Ensures the value is an array. Backend expects array for terms/match filters.
3
+ * - Arrays are returned as-is
4
+ * - Strings are converted: comma-separated strings are split; single values wrapped in array
5
+ */
6
+ export function normalizeToArray(value) {
7
+ if (Array.isArray(value)) return value;
8
+ if (typeof value === 'string') {
9
+ return value.includes(',')
10
+ ? value.split(',').map((s) => s.trim()).filter(Boolean)
11
+ : [value];
12
+ }
13
+ return value != null ? [value] : [];
14
+ }
15
+
1
16
  export class Filter {
2
17
  constructor(filterType, field, boost = null) {
3
18
  if (new.target === Filter) throw new Error('Filter is abstract and cannot be instantiated directly');
@@ -1,4 +1,6 @@
1
1
  import { Filter } from './Filter.js';
2
+
3
+ /** Boosts items by group membership. lookup_index/field/value identify the group; min_boost/max_boost/n control boost range. */
2
4
  export class GroupBoostFilter extends Filter {
3
5
  constructor(lookup_index, field, value, group, min_boost = null, max_boost = null, n = null) {
4
6
  super('group_boost', field, null);
@@ -1,7 +1,7 @@
1
- import { Filter } from './Filter.js';
1
+ import { Filter, normalizeToArray } from './Filter.js';
2
2
  export class MatchFilter extends Filter {
3
3
  constructor(field, value, boost = null) {
4
4
  super('match', field, boost);
5
- this.value = value;
5
+ this.value = normalizeToArray(value);
6
6
  }
7
7
  }
@@ -1,7 +1,7 @@
1
- import { Filter } from './Filter.js';
1
+ import { Filter, normalizeToArray } from './Filter.js';
2
2
  export class TermsFilter extends Filter {
3
3
  constructor(field, value, boost = null) {
4
4
  super('terms', field, boost);
5
- this.value = value;
5
+ this.value = normalizeToArray(value);
6
6
  }
7
7
  }
@@ -1,4 +1,6 @@
1
1
  import { Filter } from './Filter.js';
2
+
3
+ /** Filters by terms fetched from another index. path: dot-notation field in lookup doc (e.g. "followers.ids"). */
2
4
  export class TermsLookupFilter extends Filter {
3
5
  constructor(lookup_index, field, value, path, boost = null) {
4
6
  super('terms_lookup', field, boost);
@@ -1,3 +1,4 @@
1
+ /** Maps index names to canonical base (e.g. farcaster-items-v2 → farcaster-items). Used for feature/score keys. */
1
2
  export function findIndex(index) {
2
3
  const indexOptions = ['farcaster-items', 'zora-coins', 'polymarket-items', 'polymarket-wallets'];
3
4
  for (const option of indexOptions) {
package/index.d.ts ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * MBD Studio SDK – search, features, scoring, and ranking for personalized feeds.
3
+ * @example
4
+ * const config = new StudioConfig({ apiKey, commonUrl });
5
+ * const studio = new StudioV1({ config });
6
+ * const hits = await studio.search().index('farcaster-items').include().term('type', 'cast').execute();
7
+ */
8
+
9
+ // --- StudioConfig ---
10
+
11
+ export interface StudioConfigOptions {
12
+ apiKey: string;
13
+ commonUrl?: string;
14
+ servicesUrl?: {
15
+ searchService: string;
16
+ storiesService: string;
17
+ featuresService: string;
18
+ scoringService: string;
19
+ rankingService: string;
20
+ };
21
+ log?: (msg: string) => void;
22
+ show?: (results?: unknown) => void;
23
+ }
24
+
25
+ export class StudioConfig {
26
+ constructor(options: StudioConfigOptions);
27
+ }
28
+
29
+ // --- Filter (abstract base, used by Search.filter()) ---
30
+
31
+ export class Filter {
32
+ constructor(filterType: string, field: string, boost?: number | null);
33
+ }
34
+
35
+ // --- Search ---
36
+
37
+ export interface SearchHit {
38
+ _index?: string;
39
+ _id?: string;
40
+ _source?: Record<string, unknown>;
41
+ _features?: Record<string, number>;
42
+ _scores?: Record<string, number>;
43
+ _info?: Record<string, unknown>;
44
+ _ranking_score?: number;
45
+ }
46
+
47
+ export interface SearchResult {
48
+ total_hits?: number;
49
+ hits?: SearchHit[];
50
+ took_es?: number;
51
+ took_backend?: number;
52
+ max_score?: number;
53
+ }
54
+
55
+ export interface FrequentValuesResult {
56
+ [key: string]: unknown;
57
+ }
58
+
59
+ export class Search {
60
+ lastCall: { endpoint: string; payload: unknown } | null;
61
+ lastResult: unknown;
62
+
63
+ index(selected_index: string): this;
64
+ size(size: number): this;
65
+ onlyIds(value?: boolean): this;
66
+ includeVectors(value?: boolean): this;
67
+ selectFields(fields: string[] | null): this;
68
+ text(text: string): this;
69
+ vector(vector: number[]): this;
70
+ esQuery(rawQuery: Record<string, unknown>): this;
71
+ sortBy(field: string, direction?: 'asc' | 'desc', field2?: string, direction2?: 'asc' | 'desc'): this;
72
+ include(): this;
73
+ exclude(): this;
74
+ boost(): this;
75
+ filter(filterInstance: Filter): this;
76
+ term(field: string, value: string | number | boolean, boost?: number | null): this;
77
+ terms(field: string, values: (string | number | boolean)[], boost?: number | null): this;
78
+ numeric(field: string, operator: string, value: number, boost?: number | null): this;
79
+ date(field: string, dateFrom?: string | null, dateTo?: string | null, boost?: number | null): this;
80
+ geo(field: string, value: unknown, boost?: number | null): this;
81
+ match(field: string, value: string, boost?: number | null): this;
82
+ isNull(field: string, boost?: number | null): this;
83
+ notNull(field: string, boost?: number | null): this;
84
+ custom(field: string, value: unknown, boost?: number | null): this;
85
+ groupBoost(
86
+ lookup_index: string,
87
+ field: string,
88
+ value: unknown,
89
+ group: string,
90
+ min_boost?: number | null,
91
+ max_boost?: number | null,
92
+ n?: number | null
93
+ ): this;
94
+ termsLookup(
95
+ lookup_index: string,
96
+ field: string,
97
+ value: unknown,
98
+ path: string,
99
+ boost?: number | null
100
+ ): this;
101
+ consoleAccount(field: string, value: unknown, path: string, boost?: number | null): this;
102
+ execute(): Promise<SearchHit[]>;
103
+ frequentValues(field: string, size?: number): Promise<FrequentValuesResult>;
104
+ lookup(docId: string): Promise<unknown>;
105
+ log(string: string): void;
106
+ show(results?: unknown): void;
107
+ }
108
+
109
+ // --- Features ---
110
+
111
+ export interface FeaturesResult {
112
+ features?: Record<string, Record<string, Record<string, number>>>;
113
+ scores?: Record<string, Record<string, Record<string, number>>>;
114
+ info?: Record<string, Record<string, Record<string, unknown>>>;
115
+ took_backend?: number;
116
+ hit_rate?: number;
117
+ item_embed_rate?: number;
118
+ [key: string]: unknown;
119
+ }
120
+
121
+ export class Features {
122
+ lastCall: { endpoint: string; payload: unknown } | null;
123
+ lastResult: unknown;
124
+
125
+ version(v: string): this;
126
+ items(items: Array<{ index: string; id: string }>): this;
127
+ user(index: string, userId: string): this;
128
+ execute(): Promise<FeaturesResult>;
129
+ log(string: string): void;
130
+ show(results?: unknown): void;
131
+ }
132
+
133
+ // --- Scoring ---
134
+
135
+ export class Scoring {
136
+ lastCall: { endpoint: string; payload: unknown } | null;
137
+ lastResult: unknown;
138
+
139
+ model(endpoint: string): this;
140
+ userId(userId: string): this;
141
+ itemIds(itemIds: string[]): this;
142
+ execute(): Promise<string[]>;
143
+ log(string: string): void;
144
+ show(results?: unknown): void;
145
+ }
146
+
147
+ // --- Ranking ---
148
+
149
+ export interface RankingItem {
150
+ item_id: string;
151
+ score: number;
152
+ }
153
+
154
+ export interface RankingResult {
155
+ items: RankingItem[];
156
+ [key: string]: unknown;
157
+ }
158
+
159
+ export class Ranking {
160
+ lastCall: { endpoint: string; payload: unknown } | null;
161
+ lastResult: unknown;
162
+
163
+ sortingMethod(x: 'sort' | 'linear' | 'mix'): this;
164
+ sortBy(
165
+ field: string,
166
+ direction?: 'asc' | 'desc',
167
+ field2?: string,
168
+ direction2?: 'asc' | 'desc'
169
+ ): this;
170
+ weight(field: string, w: number): this;
171
+ mix(field: string, direction: 'asc' | 'desc', percentage: number): this;
172
+ diversity(method: 'fields' | 'semantic'): this;
173
+ fields(arrayOrItem: string | string[]): this;
174
+ horizon(n: number): this;
175
+ lambda(value: number): this;
176
+ limitByField(): this;
177
+ every(n: number): this;
178
+ limit(field: string, max: number): this;
179
+ candidates(candidates: SearchHit[]): this;
180
+ execute(): Promise<RankingResult>;
181
+ log(string: string): void;
182
+ show(results?: unknown): void;
183
+ }
184
+
185
+ // --- Studio (main client) ---
186
+
187
+ export interface StudioOptions {
188
+ config?: StudioConfig;
189
+ apiKey?: string;
190
+ commonUrl?: string;
191
+ servicesUrl?: StudioConfigOptions['servicesUrl'];
192
+ log?: (msg: string) => void;
193
+ show?: (results?: unknown) => void;
194
+ origin?: string;
195
+ }
196
+
197
+ export class Studio {
198
+ constructor(options: StudioOptions);
199
+
200
+ version(): string;
201
+ forUser(index: string, userId: string): void;
202
+ search(): Search;
203
+ frequentValues(index: string, field: string, size?: number): Promise<FrequentValuesResult>;
204
+ addCandidates(array: SearchHit[]): void;
205
+ features(version?: string): Features;
206
+ addFeatures(featuresResult: FeaturesResult): void;
207
+ scoring(): Scoring;
208
+ addScores(scoringResult: string[], scoringKey: string): void;
209
+ ranking(): Ranking;
210
+ addRanking(rankingResult: { items?: RankingItem[] }): void;
211
+ log(string: string): void;
212
+ show(results?: SearchHit[]): void;
213
+ getFeed(): SearchHit[];
214
+ }
215
+
216
+ // --- Main export ---
217
+
218
+ export const StudioV1: typeof Studio;
package/index.js CHANGED
@@ -1,3 +1,10 @@
1
+ /**
2
+ * MBD Studio SDK – search, features, scoring, and ranking for personalized feeds.
3
+ * @example
4
+ * const config = new StudioConfig({ apiKey, commonUrl });
5
+ * const studio = new StudioV1({ config });
6
+ * const hits = await studio.search().index('farcaster-items').include().term('type', 'cast').execute();
7
+ */
1
8
  import * as V1 from './V1/index.js';
2
9
  export { StudioConfig } from './StudioConfig.js';
3
10
  export const StudioV1 = V1.Studio;
package/llms.txt ADDED
@@ -0,0 +1,581 @@
1
+ # MBD Studio SDK – Reference for AI Coding Agents
2
+
3
+ ## Overview
4
+
5
+ SDK for MBD Studio backend services: **search** (candidate retrieval), **features** (signals/metadata), **scoring** (ML models for relevance/reranking), and **ranking** (final ranked feed with diversity).
6
+
7
+ ## Entry Points
8
+
9
+ - `StudioConfig` – API config (apiKey required; commonUrl or servicesUrl for endpoints)
10
+ - `StudioV1` – Main client. Import: `import { StudioConfig, StudioV1 } from 'mbd-studio-sdk'`
11
+
12
+ ## Typical Flow
13
+
14
+ 1. `new StudioConfig({ apiKey })` → `new StudioV1({ config })`
15
+ 2. `mbd.forUser(index, userId)` – set user for personalization (optional)
16
+ 3. `mbd.search().index("...").include()/exclude()/boost().term(...).execute()` → hits
17
+ 4. `mbd.addCandidates(hits)` – attach hits to context
18
+ 5. `mbd.features("v1").execute()` → `mbd.addFeatures(result)` – enrich with signals
19
+ 6. `mbd.scoring().model("/scoring/...").execute()` → `mbd.addScores(result, "key")` – ML scores
20
+ 7. `mbd.ranking().sortingMethod(...).mix(...).execute()` → `mbd.addRanking(result)`
21
+ 8. `mbd.getFeed()` – final ranked candidates
22
+
23
+ **Tip:** Search endpoints are chosen by payload: `es_query` > `semantic` (text/vector) > `boost` > `filter_and_sort`. Call `include()`/`exclude()`/`boost()` before adding filters. `findIndex()` maps index names (e.g. `farcaster-items-v2`) to canonical keys for feature/score lookups.
24
+
25
+ ---
26
+
27
+ ## index.js
28
+
29
+ ```javascript
30
+ import * as V1 from './V1/index.js';
31
+ export { StudioConfig } from './StudioConfig.js';
32
+ export const StudioV1 = V1.Studio;
33
+ ```
34
+
35
+ ---
36
+
37
+ ## StudioConfig.js
38
+
39
+ ```javascript
40
+ const defaultBaseUrl = 'https://api.mbd.xyz/v3/studio';
41
+ export class StudioConfig {
42
+ constructor(options) {
43
+ if (!options || typeof options !== 'object') throw new Error('StudioConfig: options object is required');
44
+ const { apiKey, commonUrl, servicesUrl, log, show } = options;
45
+ if (typeof apiKey !== 'string' || !apiKey.trim()) throw new Error('StudioConfig: apiKey is required and must be a non-empty string');
46
+ const hasCommonUrl = typeof commonUrl === 'string' && commonUrl.trim().length > 0;
47
+ if (hasCommonUrl) {
48
+ const url = commonUrl.trim().replace(/\/$/, '');
49
+ this.searchService = this.storiesService = this.featuresService = this.scoringService = this.rankingService = url;
50
+ } else if (servicesUrl && typeof servicesUrl === 'object') {
51
+ const { searchService, storiesService, featuresService, scoringService, rankingService } = servicesUrl;
52
+ const services = { searchService, storiesService, featuresService, scoringService, rankingService };
53
+ const missing = Object.entries(services).filter(([, v]) => typeof v !== 'string' || !v.trim()).map(([k]) => k);
54
+ if (missing.length > 0) throw new Error(`StudioConfig: when using servicesUrl, all service URLs are required. Missing: ${missing.join(', ')}`);
55
+ [this.searchService, this.storiesService, this.featuresService, this.scoringService, this.rankingService] =
56
+ [searchService, storiesService, featuresService, scoringService, rankingService].map((u) => u.trim().replace(/\/$/, ''));
57
+ } else {
58
+ this.searchService = this.storiesService = this.featuresService = this.scoringService = this.rankingService = defaultBaseUrl;
59
+ }
60
+ this.apiKey = apiKey.trim();
61
+ this.log = typeof log === 'function' ? log : console.log.bind(console);
62
+ this.show = typeof show === 'function' ? show : console.log.bind(console);
63
+ }
64
+ }
65
+ ```
66
+
67
+ ---
68
+
69
+ ## V1/index.js
70
+
71
+ ```javascript
72
+ export { Studio } from './Studio.js';
73
+ ```
74
+
75
+ ---
76
+
77
+ ## V1/utils/indexUtils.js
78
+
79
+ ```javascript
80
+ // Maps index names to canonical base (e.g. farcaster-items-v2 → farcaster-items). Used for feature/score keys.
81
+ export function findIndex(index) {
82
+ const indexOptions = ['farcaster-items', 'zora-coins', 'polymarket-items', 'polymarket-wallets'];
83
+ for (const option of indexOptions) if (index.startsWith(option)) return option;
84
+ return null;
85
+ }
86
+ ```
87
+
88
+ ---
89
+
90
+ ## V1/Studio.js
91
+
92
+ ```javascript
93
+ import { StudioConfig } from '../StudioConfig.js';
94
+ import { Search } from './search/Search.js';
95
+ import { Features, sortAvailableFeatures } from './features/Features.js';
96
+ import { Scoring } from './scoring/Scoring.js';
97
+ import { Ranking } from './ranking/Ranking.js';
98
+ import { findIndex } from './utils/indexUtils.js';
99
+
100
+ export class Studio {
101
+ constructor(options) {
102
+ if (!options || typeof options !== 'object') throw new Error('Studio: options object is required');
103
+ const { config, apiKey, commonUrl, servicesUrl, log, show, origin } = options;
104
+ this._config = config instanceof StudioConfig ? config : new StudioConfig({ commonUrl, servicesUrl, apiKey });
105
+ this._log = typeof log === 'function' ? log : this._config.log;
106
+ this._show = typeof show === 'function' ? show : this._config.show;
107
+ this._origin = typeof origin === 'string' && origin.trim() ? origin.trim() : 'sdk';
108
+ this._forUser = null;
109
+ this._candidates = [];
110
+ }
111
+ version() { return 'V1'; }
112
+ forUser(index, userId) { this._forUser = { index, id: userId }; }
113
+ search() { return new Search({ url: this._config.searchService, apiKey: this._config.apiKey, origin: this._origin, log: this._log, show: this._show }); }
114
+ async frequentValues(index, field, size = 25) { return this.search().index(index).frequentValues(field, size); }
115
+ addCandidates(array) { this._candidates.push(...array); }
116
+ features(version = 'v1') {
117
+ let items = [];
118
+ if (this._candidates?.length > 0) items = this._candidates.map((hit) => ({ index: hit._index, id: hit._id }));
119
+ return new Features({ url: this._config.featuresService, apiKey: this._config.apiKey, log: this._log, show: this._show, version, items, userIndex: this._forUser?.index, userId: this._forUser?.id, origin: this._origin });
120
+ }
121
+ addFeatures(featuresResult) {
122
+ const hits = this._candidates || [];
123
+ const { features, scores, info } = featuresResult;
124
+ if (!features && !scores && !info) { this._log('No features, scores, or info found'); return; }
125
+ let availableFeatures = {}, availableScores = {};
126
+ for (const hit of hits) {
127
+ const hitIndex = findIndex(hit._index);
128
+ const hitFeatures = features?.[hitIndex]?.[hit._id], hitScores = scores?.[hitIndex]?.[hit._id], hitInfo = info?.[hitIndex]?.[hit._id];
129
+ hit._features = hit._features ? { ...hit._features, ...hitFeatures } : hitFeatures;
130
+ hit._scores = hit._scores ? { ...hit._scores, ...hitScores } : hitScores;
131
+ hit._info = hit._info ? { ...hit._info, ...hitInfo } : hitInfo;
132
+ if (hit._features) for (const [key, value] of Object.entries(hit._features)) if (typeof value === 'number' && !Number.isNaN(value)) availableFeatures[key] = true;
133
+ if (hit._scores) for (const [key, value] of Object.entries(hit._scores)) if (typeof value === 'number' && !Number.isNaN(value)) availableScores[key] = true;
134
+ }
135
+ this._log(`Available features: ${sortAvailableFeatures(Object.keys(availableFeatures))}`);
136
+ this._log(`Available scores: ${sortAvailableFeatures(Object.keys(availableScores))}`);
137
+ }
138
+ scoring() {
139
+ const userId = this._forUser?.id ?? null;
140
+ const itemIds = Array.isArray(this._candidates) && this._candidates.length > 0 ? this._candidates.map((c) => (c?._id != null ? String(c._id) : null)).filter(Boolean) : [];
141
+ return new Scoring({ url: this._config.scoringService, apiKey: this._config.apiKey, log: this._log, show: this._show, userId, itemIds, origin: this._origin });
142
+ }
143
+ addScores(scoringResult, scoringKey) {
144
+ const rankedItemIds = scoringResult;
145
+ if (!this._candidates || !Array.isArray(rankedItemIds)) return;
146
+ const rankToScore = {}; rankedItemIds.forEach((itemId, i) => { rankToScore[itemId] = 1.0 - (i / rankedItemIds.length); });
147
+ for (const hit of this._candidates) if (rankToScore[hit._id] != null) { if (!hit._scores) hit._scores = {}; hit._scores[scoringKey] = rankToScore[hit._id]; }
148
+ }
149
+ ranking() { return new Ranking({ url: this._config.rankingService, apiKey: this._config.apiKey, log: this._log, show: this._show, candidates: this._candidates, origin: this._origin }); }
150
+ addRanking(rankingResult) {
151
+ const rankedItems = rankingResult?.items;
152
+ if (!this._candidates || !Array.isArray(rankedItems)) return;
153
+ const scoreByItemId = {}; rankedItems.forEach(({ item_id, score }) => { scoreByItemId[item_id] = score; });
154
+ for (const hit of this._candidates) hit._ranking_score = scoreByItemId[hit._id];
155
+ this._candidates.sort((a, b) => (b._ranking_score ?? -Infinity) - (a._ranking_score ?? -Infinity));
156
+ }
157
+ log(string) { this._log(string); }
158
+ show(results) { this._show(results === undefined ? this._candidates : results); }
159
+ getFeed() { return this._candidates; }
160
+ }
161
+ ```
162
+
163
+ ---
164
+
165
+ ## V1/search/Search.js
166
+
167
+ ```javascript
168
+ import { Filter, TermFilter, TermsFilter, NumericFilter, MatchFilter, GeoFilter, DateFilter, IsNullFilter, NotNullFilter, CustomFilter, GroupBoostFilter, TermsLookupFilter, ConsoleAccountFilter } from './filters/index.js';
169
+
170
+ export class Search {
171
+ _index = null; _es_query = null; _size = 100; _only_ids = false; _include_vector = false; _select_fields = null;
172
+ _text = null; _vector = null; _sort_by = null; _include = []; _exclude = []; _boost = [];
173
+ _active_array = null; lastCall = null; lastResult = null;
174
+ constructor(options) {
175
+ if (!options || typeof options !== 'object') throw new Error('Search: options object is required');
176
+ const { url, apiKey, origin = 'sdk', log, show } = options;
177
+ if (typeof url !== 'string' || !url.trim()) throw new Error('Search: options.url is required');
178
+ if (typeof apiKey !== 'string' || !apiKey.trim()) throw new Error('Search: options.apiKey is required');
179
+ this._url = url.trim().replace(/\/$/, ''); this._apiKey = apiKey.trim();
180
+ this._origin = typeof origin === 'string' && origin.trim() ? origin.trim() : 'sdk';
181
+ this._log = typeof log === 'function' ? log : console.log.bind(console);
182
+ this._show = typeof show === 'function' ? show : console.log.bind(console);
183
+ }
184
+ getEndpoint() {
185
+ if (this._es_query != null) return '/search/es_query';
186
+ const hasTextOrVector = (typeof this._text === 'string' && this._text.length > 0) || (Array.isArray(this._vector) && this._vector.length > 0);
187
+ if (hasTextOrVector) return '/search/semantic';
188
+ if (this._boost.length > 0) return '/search/boost';
189
+ return '/search/filter_and_sort';
190
+ }
191
+ getPayload() {
192
+ const endpoint = this.getEndpoint();
193
+ if (endpoint === '/search/es_query') return { index: this._index, origin: this._origin, feed_type: 'es_query', query: this._es_query };
194
+ const feedType = endpoint === '/search/semantic' ? 'semantic' : endpoint === '/search/boost' ? 'boost' : 'filter_and_sort';
195
+ const serializeFilters = (arr) => arr.map((f) => ({ ...f }));
196
+ const payload = { index: this._index, origin: this._origin, feed_type: feedType, include_vector: this._include_vector, size: this._size, include: serializeFilters(this._include), exclude: serializeFilters(this._exclude) };
197
+ if (feedType === 'boost') payload.boost = serializeFilters(this._boost);
198
+ if (feedType === 'filter_and_sort' && this._sort_by) payload.sort_by = this._sort_by;
199
+ if (feedType === 'semantic') {
200
+ if (typeof this._text === 'string' && this._text.length > 0) payload.text = this._text;
201
+ if (Array.isArray(this._vector) && this._vector.length > 0) payload.vector = this._vector;
202
+ }
203
+ if (this._only_ids) payload.only_ids = true;
204
+ if (Array.isArray(this._select_fields) && this._select_fields.length > 0) payload.select_fields = this._select_fields;
205
+ return payload;
206
+ }
207
+ async execute() {
208
+ if (!this._index || typeof this._index !== 'string' || !this._index.trim()) throw new Error('Search.execute: index must be set');
209
+ if (this._es_query != null && (typeof this._es_query !== 'object' || Array.isArray(this._es_query))) throw new Error('Search.execute: esQuery() must be called with a plain object');
210
+ const hasOnlyIds = this._only_ids === true, hasSelectFields = Array.isArray(this._select_fields) && this._select_fields.length > 0, hasIncludeVector = this._include_vector === true;
211
+ if ((hasOnlyIds ? 1 : 0) + (hasSelectFields ? 1 : 0) + (hasIncludeVector ? 1 : 0) > 1) throw new Error('Search: onlyIds, selectFields, includeVectors are mutually exclusive');
212
+ const endpoint = this.getEndpoint();
213
+ if (endpoint === '/search/es_query' && (this._include.length || this._exclude.length || this._boost.length)) throw new Error('Search: esQuery() does not support include/exclude/boost');
214
+ if (endpoint === '/search/semantic') {
215
+ if (this._include.length || this._exclude.length || this._boost.length) throw new Error('Search: semantic does not support include/exclude/boost');
216
+ if (this._sort_by != null) throw new Error('Search: semantic does not support sortBy()');
217
+ }
218
+ if (endpoint === '/search/boost' && this._sort_by != null) throw new Error('Search: boost does not support sortBy()');
219
+ const payload = this.getPayload(), url = `${this._url}${endpoint}`;
220
+ this.log(`Sending request to ${url}`);
221
+ const startTime = performance.now();
222
+ const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this._apiKey}` }, body: JSON.stringify(payload) });
223
+ if (!response.ok) { const text = await response.text(); throw new Error(`Search API error: ${response.status} — ${text}`); }
224
+ const result = await response.json();
225
+ if (result?.error != null) throw new Error(typeof result.error === 'string' ? result.error : String(result.error));
226
+ result.took_sdk = Math.round(performance.now() - startTime);
227
+ this.lastCall = { endpoint, payload }; this.lastResult = result;
228
+ if (!result.result) throw new Error('Search.execute: result.result is undefined');
229
+ const res = result.result; return res.hits;
230
+ }
231
+ async frequentValues(field, size = 25) {
232
+ if (!this._index || typeof this._index !== 'string' || !this._index.trim()) throw new Error('Search.frequentValues: index must be set');
233
+ if (typeof field !== 'string' || !field.trim()) throw new Error('Search.frequentValues: field must be non-empty');
234
+ const n = Number(size); if (!Number.isInteger(n) || n <= 0) throw new Error('Search.frequentValues: size must be positive integer');
235
+ const endpoint = `/search/frequent_values/${encodeURIComponent(this._index)}/${encodeURIComponent(field.trim())}?size=${n}`;
236
+ const response = await fetch(`${this._url}${endpoint}`, { method: 'GET', headers: { Authorization: `Bearer ${this._apiKey}` } });
237
+ if (!response.ok) throw new Error(`frequentValues error: ${response.status}`);
238
+ const result = await response.json(); if (result?.error != null) throw new Error(typeof result.error === 'string' ? result.error : String(result.error)); return result;
239
+ }
240
+ async lookup(docId) {
241
+ if (!this._index || typeof this._index !== 'string' || !this._index.trim()) throw new Error('Search.lookup: index must be set');
242
+ if (typeof docId !== 'string' || !docId.trim()) throw new Error('Search.lookup: docId must be non-empty');
243
+ const endpoint = `/search/document/${encodeURIComponent(this._index)}/${encodeURIComponent(docId.trim())}`;
244
+ const response = await fetch(`${this._url}${endpoint}`, { method: 'GET', headers: { Authorization: `Bearer ${this._apiKey}` } });
245
+ if (!response.ok) throw new Error(`lookup error: ${response.status}`);
246
+ const result = await response.json(); if (result?.error != null) throw new Error(typeof result.error === 'string' ? result.error : String(result.error)); return result;
247
+ }
248
+ index(selected_index) { if (typeof selected_index !== 'string' || !selected_index.trim()) throw new Error('Search.index: selected_index required'); this._index = selected_index.trim(); return this; }
249
+ size(size) { const n = Number(size); if (!Number.isInteger(n) || n <= 0 || n >= 2000) throw new Error('Search.size: 0 < size < 2000'); this._size = n; return this; }
250
+ onlyIds(value) { this._only_ids = value == null ? true : Boolean(value); return this; }
251
+ includeVectors(value) { this._include_vector = value == null ? true : Boolean(value); return this; }
252
+ selectFields(fields) { if (fields === null) { this._select_fields = null; return this; } if (!Array.isArray(fields)) throw new Error('Search.selectFields: array or null'); this._select_fields = fields.map((f) => (typeof f === 'string' ? f.trim() : String(f))); return this; }
253
+ text(text) { if (typeof text !== 'string' || !text.trim()) throw new Error('Search.text: non-empty string'); this._text = text.trim(); return this; }
254
+ vector(vector) { if (!Array.isArray(vector)) throw new Error('Search.vector: array required'); this._vector = vector; return this; }
255
+ esQuery(rawQuery) { if (rawQuery == null || typeof rawQuery !== 'object' || Array.isArray(rawQuery)) throw new Error('Search.esQuery: plain object'); this._es_query = rawQuery; return this; }
256
+ sortBy(field, direction = 'desc') { if (typeof field !== 'string' || !field.trim()) throw new Error('Search.sortBy: field required'); if (direction !== 'asc' && direction !== 'desc') throw new Error('Search.sortBy: asc|desc'); this._sort_by = { field: field.trim(), order: direction }; return this; }
257
+ include() { this._active_array = this._include; return this; }
258
+ exclude() { this._active_array = this._exclude; return this; }
259
+ boost() { this._active_array = this._boost; return this; }
260
+ _requireActiveArray() { if (this._active_array === null) throw new Error('Search: call include(), exclude(), or boost() before filters'); }
261
+ _requireBoostForBoostArray(boost) { if (this._active_array === this._boost && boost == null) throw new Error('Search: boost array requires non-null boost'); }
262
+ filter(filterInstance) {
263
+ this._requireActiveArray();
264
+ if (filterInstance == null || !(filterInstance instanceof Filter)) throw new Error('Search.filter: Filter instance required');
265
+ if (this._active_array === this._boost && filterInstance.filter !== 'group_boost' && filterInstance.boost == null) throw new Error('Search: boost array requires non-null boost (group_boost exempt)');
266
+ this._active_array.push(filterInstance); return this;
267
+ }
268
+ term(field, value, boost = null) { this._requireActiveArray(); this._requireBoostForBoostArray(boost); this._active_array.push(new TermFilter(field, value, boost)); return this; }
269
+ terms(field, values, boost = null) { this._requireActiveArray(); this._requireBoostForBoostArray(boost); this._active_array.push(new TermsFilter(field, values, boost)); return this; }
270
+ numeric(field, operator, value, boost = null) { this._requireActiveArray(); this._requireBoostForBoostArray(boost); this._active_array.push(new NumericFilter(field, operator, value, boost)); return this; }
271
+ date(field, dateFrom = null, dateTo = null, boost = null) { this._requireActiveArray(); this._requireBoostForBoostArray(boost); this._active_array.push(new DateFilter(field, dateFrom, dateTo, boost)); return this; }
272
+ geo(field, value, boost = null) { this._requireActiveArray(); this._requireBoostForBoostArray(boost); this._active_array.push(new GeoFilter(field, value, boost)); return this; }
273
+ match(field, value, boost = null) { this._requireActiveArray(); this._requireBoostForBoostArray(boost); this._active_array.push(new MatchFilter(field, value, boost)); return this; }
274
+ isNull(field, boost = null) { this._requireActiveArray(); this._requireBoostForBoostArray(boost); this._active_array.push(new IsNullFilter(field, boost)); return this; }
275
+ notNull(field, boost = null) { this._requireActiveArray(); this._requireBoostForBoostArray(boost); this._active_array.push(new NotNullFilter(field, boost)); return this; }
276
+ custom(field, value, boost = null) { this._requireActiveArray(); this._requireBoostForBoostArray(boost); this._active_array.push(new CustomFilter(field, value, boost)); return this; }
277
+ groupBoost(lookup_index, field, value, group, min_boost = null, max_boost = null, n = null) { this._requireActiveArray(); this._active_array.push(new GroupBoostFilter(lookup_index, field, value, group, min_boost, max_boost, n)); return this; }
278
+ termsLookup(lookup_index, field, value, path, boost = null) { this._requireActiveArray(); this._requireBoostForBoostArray(boost); this._active_array.push(new TermsLookupFilter(lookup_index, field, value, path, boost)); return this; }
279
+ consoleAccount(field, value, path, boost = null) { this._requireActiveArray(); this._requireBoostForBoostArray(boost); this._active_array.push(new ConsoleAccountFilter(field, value, path, boost)); return this; }
280
+ log(string) { this._log(string); }
281
+ show(results) { this._show(results); }
282
+ }
283
+ ```
284
+
285
+ ---
286
+
287
+ ## V1/search/filters/Filter.js
288
+
289
+ ```javascript
290
+ export function normalizeToArray(value) {
291
+ if (Array.isArray(value)) return value;
292
+ if (typeof value === 'string') return value.includes(',') ? value.split(',').map((s) => s.trim()).filter(Boolean) : [value];
293
+ return value != null ? [value] : [];
294
+ }
295
+ export class Filter {
296
+ constructor(filterType, field, boost = null) {
297
+ if (new.target === Filter) throw new Error('Filter is abstract');
298
+ this.filter = filterType; this.field = field; this.boost = boost;
299
+ }
300
+ }
301
+ ```
302
+
303
+ ---
304
+
305
+ ## V1/search/filters/index.js
306
+
307
+ ```javascript
308
+ export { Filter } from './Filter.js';
309
+ export { TermFilter } from './TermFilter.js';
310
+ export { TermsFilter } from './TermsFilter.js';
311
+ export { NumericFilter } from './NumericFilter.js';
312
+ export { MatchFilter } from './MatchFilter.js';
313
+ export { GeoFilter } from './GeoFilter.js';
314
+ export { DateFilter } from './DateFilter.js';
315
+ export { IsNullFilter } from './IsNullFilter.js';
316
+ export { NotNullFilter } from './NotNullFilter.js';
317
+ export { CustomFilter } from './CustomFilter.js';
318
+ export { GroupBoostFilter } from './GroupBoostFilter.js';
319
+ export { TermsLookupFilter } from './TermsLookupFilter.js';
320
+ export { ConsoleAccountFilter } from './ConsoleAccountFilter.js';
321
+ ```
322
+
323
+ ---
324
+
325
+ ## V1/search/filters (implementations)
326
+
327
+ ```javascript
328
+ // TermFilter.js
329
+ import { Filter } from './Filter.js';
330
+ export class TermFilter extends Filter { constructor(field, value, boost = null) { super('term', field, boost); this.value = value; } }
331
+
332
+ // TermsFilter.js
333
+ import { Filter, normalizeToArray } from './Filter.js';
334
+ export class TermsFilter extends Filter { constructor(field, value, boost = null) { super('terms', field, boost); this.value = normalizeToArray(value); } }
335
+
336
+ // NumericFilter.js
337
+ import { Filter } from './Filter.js';
338
+ export class NumericFilter extends Filter { constructor(field, operator, value, boost = null) { super('numeric', field, boost); this.operator = operator; this.value = value; } }
339
+
340
+ // MatchFilter.js
341
+ import { Filter, normalizeToArray } from './Filter.js';
342
+ export class MatchFilter extends Filter { constructor(field, value, boost = null) { super('match', field, boost); this.value = normalizeToArray(value); } }
343
+
344
+ // DateFilter.js (dateFrom or dateTo required)
345
+ import { Filter } from './Filter.js';
346
+ export class DateFilter extends Filter { constructor(field, dateFrom = null, dateTo = null, boost = null) { super('date', field, boost); if (dateFrom == null && dateTo == null) throw new Error('DateFilter: dateFrom or dateTo required'); const value = {}; if (dateFrom != null) value.date_from = dateFrom; if (dateTo != null) value.date_to = dateTo; this.value = value; } }
347
+
348
+ // GeoFilter.js
349
+ import { Filter } from './Filter.js';
350
+ export class GeoFilter extends Filter { constructor(field, value, boost = null) { super('geo', field, boost); this.value = value; } }
351
+
352
+ // IsNullFilter.js
353
+ import { Filter } from './Filter.js';
354
+ export class IsNullFilter extends Filter { constructor(field, boost = null) { super('is_null', field, boost); } }
355
+
356
+ // NotNullFilter.js
357
+ import { Filter } from './Filter.js';
358
+ export class NotNullFilter extends Filter { constructor(field, boost = null) { super('not_null', field, boost); } }
359
+
360
+ // CustomFilter.js
361
+ import { Filter } from './Filter.js';
362
+ export class CustomFilter extends Filter { constructor(field, value, boost = null) { super('custom', field, boost); this.value = value; } }
363
+
364
+ // GroupBoostFilter.js (lookup_index, field, value, group, min_boost, max_boost, n)
365
+ import { Filter } from './Filter.js';
366
+ export class GroupBoostFilter extends Filter { constructor(lookup_index, field, value, group, min_boost = null, max_boost = null, n = null) { super('group_boost', field, null); this.lookup_index = lookup_index; this.value = value; this.group = group; this.min_boost = min_boost; this.max_boost = max_boost; this.n = n; } }
367
+
368
+ // TermsLookupFilter.js (path: dot-notation in lookup doc, e.g. "followers.ids")
369
+ import { Filter } from './Filter.js';
370
+ export class TermsLookupFilter extends Filter { constructor(lookup_index, field, value, path, boost = null) { super('terms_lookup', field, boost); this.lookup_index = lookup_index; this.value = value; this.path = path; } }
371
+
372
+ // ConsoleAccountFilter.js (path: dot-notation in account doc)
373
+ import { Filter } from './Filter.js';
374
+ export class ConsoleAccountFilter extends Filter { constructor(field, value, path, boost = null) { super('console_account', field, boost); this.value = value; this.path = path; } }
375
+ ```
376
+
377
+ ---
378
+
379
+ ## V1/features/Features.js
380
+
381
+ ```javascript
382
+ export const PREFERRED_FEATURE_COLUMNS = ['found', 'original_rank', 'sem_sim_fuzzy', 'sem_sim_closest', 'usr_primary_labels', 'usr_secondary_labels', 'usr_primary_tags', 'usr_secondary_tags', 'user_affinity_avg', 'user_affinity_usdc', 'user_affinity_count', 'cluster_1', 'cluster_2', 'cluster_3', 'cluster_4', 'cluster_5', 'cluster_6', 'cluster_7', 'cluster_8', 'cluster_9', 'cluster_10', 'sem_sim_cluster1', 'sem_sim_cluster2', 'sem_sim_cluster3', 'sem_sim_cluster4', 'sem_sim_cluster5'];
383
+ export function sortAvailableFeatures(available) {
384
+ const preferred = PREFERRED_FEATURE_COLUMNS.filter((col) => available.includes(col));
385
+ const nonPreferred = available.filter((col) => !PREFERRED_FEATURE_COLUMNS.includes(col));
386
+ const regular = nonPreferred.filter((col) => !col.startsWith('AI:') && !col.startsWith('TAG:')).sort();
387
+ const aiColumns = nonPreferred.filter((col) => col.startsWith('AI:')).sort();
388
+ const tagColumns = nonPreferred.filter((col) => col.startsWith('TAG:')).sort();
389
+ return [...preferred, ...regular, ...aiColumns, ...tagColumns];
390
+ }
391
+ export class Features {
392
+ _version = 'v1'; _user = null; _items = []; lastCall = null; lastResult = null;
393
+ constructor(options) {
394
+ if (!options || typeof options !== 'object') throw new Error('Features: options required');
395
+ const { url, apiKey, version = 'v1', items = [], userIndex, userId, origin = 'sdk', log, show } = options;
396
+ if (typeof url !== 'string' || !url.trim()) throw new Error('Features: url required');
397
+ if (typeof apiKey !== 'string' || !apiKey.trim()) throw new Error('Features: apiKey required');
398
+ this._url = url.trim().replace(/\/$/, ''); this._apiKey = apiKey.trim(); this._version = version;
399
+ this._origin = typeof origin === 'string' && origin.trim() ? origin.trim() : 'sdk';
400
+ this._log = typeof log === 'function' ? log : console.log.bind(console);
401
+ this._show = typeof show === 'function' ? show : console.log.bind(console);
402
+ if (Array.isArray(items) && items.length > 0) this._items = items;
403
+ if (userIndex != null && userId != null) this._user = { index: userIndex, id: userId };
404
+ }
405
+ getEndpoint() { return `/features/${this._version}`; }
406
+ getPayload() { return { origin: this._origin, user: this._user, items: this._items.map((item) => ({ ...item })) }; }
407
+ version(v) { this._version = v; return this; }
408
+ items(items) { this._items = [...items]; return this; }
409
+ user(index, userId) { this._user = { index, id: userId }; return this; }
410
+ async execute() {
411
+ if (!this._user?.index || !this._user?.id) throw new Error('Features.execute: user must be set');
412
+ if (!Array.isArray(this._items) || this._items.length === 0) throw new Error('Features.execute: items must be non-empty');
413
+ const url = `${this._url}${this.getEndpoint()}`;
414
+ const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this._apiKey}` }, body: JSON.stringify(this.getPayload()) });
415
+ if (!response.ok) throw new Error(`Features API error: ${response.status}`);
416
+ const result = await response.json();
417
+ if (result?.error != null) throw new Error(typeof result.error === 'string' ? result.error : String(result.error));
418
+ this.lastCall = { endpoint: this.getEndpoint(), payload: this.getPayload() }; this.lastResult = result;
419
+ if (!result.result) throw new Error('Features.execute: result.result undefined'); return result.result;
420
+ }
421
+ log(string) { this._log(string); }
422
+ show(results) { this._show(results); }
423
+ }
424
+ ```
425
+
426
+ ---
427
+
428
+ ## V1/scoring/Scoring.js
429
+
430
+ ```javascript
431
+ export class Scoring {
432
+ _userId = null; _itemIds = []; _modelEndpoint = null; lastCall = null; lastResult = null;
433
+ constructor(options) {
434
+ if (!options || typeof options !== 'object') throw new Error('Scoring: options required');
435
+ const { url, apiKey, userId = null, itemIds = [], origin = 'sdk', log, show } = options;
436
+ if (typeof url !== 'string' || !url.trim()) throw new Error('Scoring: url required');
437
+ if (typeof apiKey !== 'string' || !apiKey.trim()) throw new Error('Scoring: apiKey required');
438
+ this._url = url.trim().replace(/\/$/, ''); this._apiKey = apiKey.trim();
439
+ this._origin = typeof origin === 'string' && origin.trim() ? origin.trim() : 'sdk';
440
+ this._log = typeof log === 'function' ? log : console.log.bind(console);
441
+ this._show = typeof show === 'function' ? show : console.log.bind(console);
442
+ if (userId != null && typeof userId === 'string' && userId.trim()) this._userId = userId.trim();
443
+ if (Array.isArray(itemIds) && itemIds.length > 0) this._itemIds = itemIds.map((id) => (typeof id === 'string' ? id : String(id)));
444
+ }
445
+ getEndpoint() { if (!this._modelEndpoint?.trim()) throw new Error('Scoring: model(endpoint) required'); return this._modelEndpoint.startsWith('/') ? this._modelEndpoint : `/${this._modelEndpoint}`; }
446
+ getPayload() { return { origin: this._origin, user_id: this._userId, item_ids: [...this._itemIds] }; }
447
+ model(endpoint) { this._modelEndpoint = endpoint; return this; }
448
+ userId(userId) { this._userId = userId; return this; }
449
+ itemIds(itemIds) { this._itemIds = itemIds; return this; }
450
+ async execute() {
451
+ if (!this._modelEndpoint?.trim()) throw new Error('Scoring: model(endpoint) required');
452
+ if (!this._userId?.trim()) throw new Error('Scoring: user_id required');
453
+ if (!Array.isArray(this._itemIds) || this._itemIds.length === 0) throw new Error('Scoring: item_ids required');
454
+ const url = `${this._url}${this.getEndpoint()}`;
455
+ const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this._apiKey}` }, body: JSON.stringify(this.getPayload()) });
456
+ if (!response.ok) throw new Error(`Scoring API error: ${response.status}`);
457
+ const result = await response.json();
458
+ if (result?.error != null) throw new Error(typeof result.error === 'string' ? result.error : String(result.error));
459
+ this.lastCall = { endpoint: this.getEndpoint(), payload: this.getPayload() }; this.lastResult = result;
460
+ if (result.result === undefined) throw new Error('Scoring: result.result undefined'); return result.result;
461
+ }
462
+ log(string) { this._log(string); }
463
+ show(results) { this._show(results); }
464
+ }
465
+ ```
466
+
467
+ ---
468
+
469
+ ## V1/ranking/Ranking.js
470
+
471
+ ```javascript
472
+ export class Ranking {
473
+ _candidates = []; _sortMethod = 'sort'; _sortParams = null; _diversityMethod = null; _diversityParams = null;
474
+ _limitsByFieldEnabled = false; _everyN = 10; _limitRules = []; lastCall = null; lastResult = null;
475
+ constructor(options) {
476
+ if (!options || typeof options !== 'object') throw new Error('Ranking: options required');
477
+ const { url, apiKey, candidates = [], origin = 'sdk', log, show } = options;
478
+ if (typeof url !== 'string' || !url.trim()) throw new Error('Ranking: url required');
479
+ if (typeof apiKey !== 'string' || !apiKey.trim()) throw new Error('Ranking: apiKey required');
480
+ this._url = url.trim().replace(/\/$/, ''); this._apiKey = apiKey.trim();
481
+ this._origin = typeof origin === 'string' && origin.trim() ? origin.trim() : 'sdk';
482
+ this._log = typeof log === 'function' ? log : console.log.bind(console);
483
+ this._show = typeof show === 'function' ? show : console.log.bind(console);
484
+ this._candidates = candidates;
485
+ }
486
+ getEndpoint() { return '/ranking/feed'; }
487
+ _getUsefulFieldsAndNeedEmbedding() {
488
+ const useful = new Set(); let needEmbedding = false;
489
+ if (this._sortParams) {
490
+ if (this._sortMethod === 'sort' && Array.isArray(this._sortParams.fields)) this._sortParams.fields.forEach((f) => useful.add(f));
491
+ if ((this._sortMethod === 'linear' || this._sortMethod === 'mix') && Array.isArray(this._sortParams)) this._sortParams.forEach((p) => p.field && useful.add(p.field));
492
+ }
493
+ if (this._diversityMethod === 'fields' && this._diversityParams?.fields) this._diversityParams.fields.forEach((f) => useful.add(f));
494
+ if (this._diversityMethod === 'semantic') needEmbedding = true;
495
+ if (this._limitsByFieldEnabled && this._limitRules.length) this._limitRules.forEach((r) => r.field && useful.add(r.field));
496
+ return { usefulFields: useful, needEmbedding };
497
+ }
498
+ getPayload() {
499
+ const { usefulFields, needEmbedding } = this._getUsefulFieldsAndNeedEmbedding();
500
+ const hits = this._candidates || [];
501
+ const items = hits.map((hit) => {
502
+ const item = { item_id: hit._id };
503
+ for (const key of usefulFields) { const v = hit._features?.[key] ?? hit._scores?.[key]; if (v !== undefined) item[key] = v; }
504
+ if (needEmbedding) { let embed = hit._source?.item_sem_embed2; if (!embed?.length) embed = hit._source?.text_vector; if (embed) item.embed = embed; }
505
+ return item;
506
+ });
507
+ const payload = { origin: this._origin, items };
508
+ const sortConfig = this._buildSortConfig(); if (sortConfig) payload.sort = sortConfig;
509
+ const diversityConfig = this._buildDiversityConfig(); if (diversityConfig) payload.diversity = diversityConfig;
510
+ const limitsByFieldConfig = this._buildLimitsByFieldConfig(); if (limitsByFieldConfig) payload.limits_by_field = limitsByFieldConfig;
511
+ return payload;
512
+ }
513
+ _buildSortConfig() {
514
+ if (!this._sortParams) return undefined;
515
+ if (this._sortMethod === 'sort' && this._sortParams.fields?.length) return { method: 'sort', params: { ...this._sortParams } };
516
+ if (this._sortMethod === 'linear' && Array.isArray(this._sortParams) && this._sortParams.length) return { method: 'linear', params: this._sortParams.map((p) => ({ field: p.field, weight: p.weight })) };
517
+ if (this._sortMethod === 'mix' && Array.isArray(this._sortParams) && this._sortParams.length) return { method: 'mix', params: this._sortParams.map((p) => ({ field: p.field, direction: p.direction, percentage: p.percentage })) };
518
+ return undefined;
519
+ }
520
+ _buildDiversityConfig() {
521
+ if (!this._diversityMethod) return undefined;
522
+ if (this._diversityMethod === 'fields' && this._diversityParams?.fields?.length) return { method: 'fields', params: { fields: [...this._diversityParams.fields] } };
523
+ if (this._diversityMethod === 'semantic') return { method: 'semantic', params: { lambda: Number(this._diversityParams?.lambda ?? 0.5), horizon: Number(this._diversityParams?.horizon ?? 20) } };
524
+ return undefined;
525
+ }
526
+ _buildLimitsByFieldConfig() {
527
+ if (!this._limitsByFieldEnabled || !this._limitRules.length) return undefined;
528
+ const everyN = Number(this._everyN); if (!Number.isInteger(everyN) || everyN < 2) return undefined;
529
+ return { every_n: everyN, rules: this._limitRules.map((r) => ({ field: r.field, limit: Number(r.limit) || 0 })) };
530
+ }
531
+ sortingMethod(x) {
532
+ if (x !== 'sort' && x !== 'linear' && x !== 'mix') throw new Error('Ranking.sortingMethod: sort|linear|mix');
533
+ this._sortMethod = x;
534
+ if (x === 'sort') this._sortParams = { fields: [], direction: [] };
535
+ if (x === 'linear') this._sortParams = [];
536
+ if (x === 'mix') this._sortParams = [];
537
+ return this;
538
+ }
539
+ sortBy(field, direction = 'desc', field2, direction2 = 'desc') {
540
+ if (this._sortMethod === 'linear' || this._sortMethod === 'mix') throw new Error('Ranking.sortBy: only for sortingMethod("sort")');
541
+ this._sortMethod = 'sort';
542
+ if (!this._sortParams?.fields) this._sortParams = { fields: [], direction: [] };
543
+ const f = typeof field === 'string' && field.trim() ? field.trim() : null;
544
+ if (!f) throw new Error('Ranking.sortBy: field required'); if (direction !== 'asc' && direction !== 'desc') throw new Error('Ranking.sortBy: asc|desc');
545
+ this._sortParams = { fields: [f], direction: [direction] };
546
+ if (typeof field2 === 'string' && field2.trim()) { this._sortParams.fields.push(field2.trim()); this._sortParams.direction.push(direction2 === 'asc' ? 'asc' : 'desc'); }
547
+ return this;
548
+ }
549
+ weight(field, w) { if (this._sortMethod !== 'linear') throw new Error('Ranking.weight: only for sortingMethod("linear")'); const f = typeof field === 'string' && field.trim() ? field.trim() : null; if (!f) throw new Error('field required'); if (!Array.isArray(this._sortParams)) this._sortParams = []; this._sortParams.push({ field: f, weight: Number(w) }); return this; }
550
+ mix(field, direction, percentage) { if (this._sortMethod === 'linear') throw new Error('Ranking.mix: only for sortingMethod("mix")'); this._sortMethod = 'mix'; if (!Array.isArray(this._sortParams)) this._sortParams = []; const f = typeof field === 'string' && field.trim() ? field.trim() : null; if (!f) throw new Error('field required'); if (direction !== 'asc' && direction !== 'desc') throw new Error('direction asc|desc'); this._sortParams.push({ field: f, direction, percentage: Number(percentage) || 0 }); return this; }
551
+ diversity(method) { if (method !== 'fields' && method !== 'semantic') throw new Error('Ranking.diversity: fields|semantic'); this._diversityMethod = method; if (method === 'fields') this._diversityParams = { fields: [] }; if (method === 'semantic') this._diversityParams = { lambda: 0.5, horizon: 20 }; return this; }
552
+ fields(arrayOrItem) { if (this._diversityMethod !== 'fields') throw new Error('Ranking.fields: only for diversity("fields")'); if (!Array.isArray(this._diversityParams?.fields)) this._diversityParams = { fields: [] }; const add = (name) => { const s = typeof name === 'string' && name.trim() ? name.trim() : null; if (s && !this._diversityParams.fields.includes(s)) this._diversityParams.fields.push(s); }; Array.isArray(arrayOrItem) ? arrayOrItem.forEach(add) : add(arrayOrItem); return this; }
553
+ horizon(n) { if (this._diversityMethod !== 'semantic') throw new Error('Ranking.horizon: only for diversity("semantic")'); if (!this._diversityParams) this._diversityParams = { lambda: 0.5, horizon: 20 }; this._diversityParams.horizon = Number(n); return this; }
554
+ lambda(value) { if (this._diversityMethod !== 'semantic') throw new Error('Ranking.lambda: only for diversity("semantic")'); if (!this._diversityParams) this._diversityParams = { lambda: 0.5, horizon: 20 }; this._diversityParams.lambda = Number(value); return this; }
555
+ limitByField() { this._limitsByFieldEnabled = true; return this; }
556
+ every(n) { this._everyN = Number(n); return this; }
557
+ limit(field, max) { const f = typeof field === 'string' && field.trim() ? field.trim() : null; if (!f) throw new Error('limit: field required'); const existing = this._limitRules.find((r) => r.field === f); if (existing) existing.limit = Number(max) || 0; else this._limitRules.push({ field: f, limit: Number(max) || 0 }); return this; }
558
+ candidates(candidates) { this._candidates = candidates; return this; }
559
+ async execute() {
560
+ if (!Array.isArray(this._candidates) || this._candidates.length === 0) throw new Error('Ranking: candidates required');
561
+ if (!this._buildSortConfig()) throw new Error('Ranking: sort config required');
562
+ const url = `${this._url}${this.getEndpoint()}`, payload = this.getPayload();
563
+ const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this._apiKey}` }, body: JSON.stringify(payload) });
564
+ if (!response.ok) throw new Error(`Ranking API error: ${response.status}`);
565
+ const result = await response.json();
566
+ if (result?.error != null) throw new Error(typeof result.error === 'string' ? result.error : String(result.error));
567
+ if (!result.result) throw new Error('Ranking: result undefined'); return result.result;
568
+ }
569
+ log(string) { this._log(string); }
570
+ show(results) { this._show(results); }
571
+ }
572
+ ```
573
+
574
+ ---
575
+
576
+ ## Debugging Tips
577
+
578
+ - **lastCall / lastResult**: `Search`, `Features`, `Scoring`, and `Ranking` expose `lastCall` (endpoint + payload) and `lastResult` for debugging failed requests.
579
+ - **Semantic diversity**: Ranking with `diversity('semantic')` needs `item_sem_embed2` or `text_vector` on hits; ensure `includeVectors(true)` when searching if you use semantic diversity.
580
+ - **Index canonicalization**: `addFeatures`/addScores use `findIndex(hit._index)`; if your index variant (e.g. `polymarket-items-v2`) is not in `indexOptions`, features/scores may not merge.
581
+ - **Filter boost**: When adding to `boost()` array, all filters except `groupBoost` require a non-null `boost` argument.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mbd-studio-sdk",
3
- "version": "4.0.0",
3
+ "version": "4.1.1",
4
4
  "description": "SDK for Embed Recommendation Engine APIs",
5
5
  "keywords": [
6
6
  "web3",
@@ -12,12 +12,19 @@
12
12
  ],
13
13
  "type": "module",
14
14
  "main": "index.js",
15
+ "types": "index.d.ts",
15
16
  "exports": {
16
- ".": "./index.js"
17
+ ".": {
18
+ "types": "./index.d.ts",
19
+ "import": "./index.js",
20
+ "default": "./index.js"
21
+ }
17
22
  },
18
23
  "files": [
19
24
  "index.js",
25
+ "index.d.ts",
20
26
  "StudioConfig.js",
21
- "V1"
27
+ "V1",
28
+ "llms.txt"
22
29
  ]
23
30
  }