@aicontextgateway/search-react 0.0.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/README.md +98 -0
- package/dist/aicontextgateway-search.iife.js +14 -0
- package/dist/search-react.css +2 -0
- package/dist/search-react.js +561 -0
- package/dist/search-react.umd.cjs +11 -0
- package/loader/loader.js +58 -0
- package/package.json +32 -0
- package/src/searchCore.js +269 -0
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aicontextgateway/search-react",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Embeddable AiContextGateway smart search bar: deterministic (no-AI) fuzzy/facet search as a React component and a self-contained <aicontextgateway-search> web component, with an optional AI answer layer.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"loader",
|
|
10
|
+
"src/searchCore.js"
|
|
11
|
+
],
|
|
12
|
+
"main": "./dist/search-react.umd.cjs",
|
|
13
|
+
"module": "./dist/search-react.js",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"import": "./dist/search-react.js",
|
|
17
|
+
"require": "./dist/search-react.umd.cjs"
|
|
18
|
+
},
|
|
19
|
+
"./styles.css": "./dist/search-react.css",
|
|
20
|
+
"./core": "./src/searchCore.js",
|
|
21
|
+
"./element": "./dist/aicontextgateway-search.iife.js",
|
|
22
|
+
"./loader": "./loader/loader.js"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "vite build -c vite.config.react.js && vite build -c vite.config.element.js",
|
|
26
|
+
"test": "node src/searchCore.test.mjs"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
30
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic search core for @aicontextgateway/search.
|
|
3
|
+
*
|
|
4
|
+
* Zero dependencies, no DOM, no AI, no network — pure functions over
|
|
5
|
+
* caller-provided data. Runs client-side. Importable on its own (Node too),
|
|
6
|
+
* which is why the node smoke test can exercise it without a bundler.
|
|
7
|
+
*
|
|
8
|
+
* Capabilities: tokenization, prefix + fuzzy (bounded Levenshtein) matching,
|
|
9
|
+
* synonym expansion, field weighting, deterministic ranking, facet counts +
|
|
10
|
+
* filtering, and highlight segmentation.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Split arbitrary text into lowercase alphanumeric tokens. */
|
|
14
|
+
export function tokenize(text) {
|
|
15
|
+
if (text == null) return [];
|
|
16
|
+
|
|
17
|
+
return String(text)
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.split(/[^a-z0-9]+/i)
|
|
20
|
+
.filter(Boolean);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Bounded Damerau-Levenshtein (optimal string alignment): like Levenshtein but
|
|
25
|
+
* an adjacent transposition (the most common search typo, e.g. "shose"->"shoes")
|
|
26
|
+
* costs 1, not 2. Returns max+1 as soon as the distance provably exceeds max.
|
|
27
|
+
*/
|
|
28
|
+
export function editDistance(a, b, max) {
|
|
29
|
+
if (a === b) return 0;
|
|
30
|
+
const al = a.length;
|
|
31
|
+
const bl = b.length;
|
|
32
|
+
if (Math.abs(al - bl) > max) return max + 1;
|
|
33
|
+
|
|
34
|
+
let prevPrev = null;
|
|
35
|
+
let prev = new Array(bl + 1);
|
|
36
|
+
for (let j = 0; j <= bl; j++) prev[j] = j;
|
|
37
|
+
|
|
38
|
+
for (let i = 1; i <= al; i++) {
|
|
39
|
+
const cur = new Array(bl + 1);
|
|
40
|
+
cur[0] = i;
|
|
41
|
+
let rowMin = cur[0];
|
|
42
|
+
|
|
43
|
+
for (let j = 1; j <= bl; j++) {
|
|
44
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
45
|
+
let val = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
|
|
46
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
47
|
+
val = Math.min(val, prevPrev[j - 2] + 1);
|
|
48
|
+
}
|
|
49
|
+
cur[j] = val;
|
|
50
|
+
if (val < rowMin) rowMin = val;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (rowMin > max) return max + 1;
|
|
54
|
+
prevPrev = prev;
|
|
55
|
+
prev = cur;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return prev[bl];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Max edits allowed for a token: 0 for short tokens, 1–2 as they get longer (unless disabled). */
|
|
62
|
+
function maxEditsFor(token, fuzzy) {
|
|
63
|
+
if (fuzzy === false) return 0;
|
|
64
|
+
if (typeof fuzzy === 'number') return Math.max(0, fuzzy);
|
|
65
|
+
if (token.length >= 8) return 2;
|
|
66
|
+
if (token.length >= 4) return 1;
|
|
67
|
+
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build a token -> Set(group) map from a synonyms object like
|
|
73
|
+
* { shoes: ['sneaker', 'trainer'] }. Grouping is bidirectional.
|
|
74
|
+
*/
|
|
75
|
+
export function buildSynonyms(synonyms) {
|
|
76
|
+
const map = new Map();
|
|
77
|
+
if (!synonyms) return map;
|
|
78
|
+
|
|
79
|
+
for (const [key, alts] of Object.entries(synonyms)) {
|
|
80
|
+
const group = new Set([key.toLowerCase(), ...(alts || []).map((a) => String(a).toLowerCase())]);
|
|
81
|
+
for (const term of group) {
|
|
82
|
+
const existing = map.get(term) || new Set();
|
|
83
|
+
for (const g of group) existing.add(g);
|
|
84
|
+
map.set(term, existing);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return map;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Expand one query token to its synonym group (itself included). */
|
|
92
|
+
function expand(token, synonymMap) {
|
|
93
|
+
const group = synonymMap.get(token);
|
|
94
|
+
|
|
95
|
+
return group ? Array.from(group) : [token];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Collect the searchable string tokens of an item across the chosen fields. */
|
|
99
|
+
function itemTokens(item, fields) {
|
|
100
|
+
const keys = fields && fields.length ? fields : Object.keys(item || {});
|
|
101
|
+
const tokens = [];
|
|
102
|
+
|
|
103
|
+
for (const key of keys) {
|
|
104
|
+
const value = item ? item[key] : undefined;
|
|
105
|
+
if (value == null) continue;
|
|
106
|
+
|
|
107
|
+
if (Array.isArray(value)) {
|
|
108
|
+
for (const v of value) tokens.push(...tokenize(v).map((t) => ({ field: key, token: t })));
|
|
109
|
+
} else if (typeof value === 'object') {
|
|
110
|
+
continue;
|
|
111
|
+
} else {
|
|
112
|
+
tokens.push(...tokenize(value).map((t) => ({ field: key, token: t })));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return tokens;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Best score for one query variant-set against one item's field tokens. 3 exact, 2 prefix, 1 fuzzy. */
|
|
120
|
+
function scoreToken(variants, fieldTokens, fieldWeights, fuzzy) {
|
|
121
|
+
let best = 0;
|
|
122
|
+
|
|
123
|
+
for (const variant of variants) {
|
|
124
|
+
const edits = maxEditsFor(variant, fuzzy);
|
|
125
|
+
|
|
126
|
+
for (const { field, token } of fieldTokens) {
|
|
127
|
+
const weight = fieldWeights[field] ?? 1;
|
|
128
|
+
let raw = 0;
|
|
129
|
+
|
|
130
|
+
if (token === variant) raw = 3;
|
|
131
|
+
else if (variant.length >= 2 && token.startsWith(variant)) raw = 2;
|
|
132
|
+
else if (edits > 0 && editDistance(variant, token, edits) <= edits) raw = 1;
|
|
133
|
+
|
|
134
|
+
const scored = raw * weight;
|
|
135
|
+
if (scored > best) best = scored;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return best;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Search `items` for `query`. Deterministic and stable (ties keep input order).
|
|
144
|
+
*
|
|
145
|
+
* options:
|
|
146
|
+
* fields string[] fields to search (default: all scalar/array fields)
|
|
147
|
+
* fieldWeights object per-field weight, e.g. { title: 3, tags: 2 }
|
|
148
|
+
* synonyms object { canonical: [alts] }
|
|
149
|
+
* fuzzy bool|num typo tolerance (default true; number = max edits)
|
|
150
|
+
* facetKeys string[] fields to compute facet counts over
|
|
151
|
+
* activeFacets object { field: value } filters applied to results
|
|
152
|
+
* limit number cap results (default 20)
|
|
153
|
+
*
|
|
154
|
+
* returns { results: [{ item, score, matched: string[] }], facets: { key: [{ value, count }] }, total }
|
|
155
|
+
*/
|
|
156
|
+
export function search(items, query, options = {}) {
|
|
157
|
+
const list = Array.isArray(items) ? items : [];
|
|
158
|
+
const {
|
|
159
|
+
fields,
|
|
160
|
+
fieldWeights = {},
|
|
161
|
+
synonyms,
|
|
162
|
+
fuzzy = true,
|
|
163
|
+
facetKeys = [],
|
|
164
|
+
activeFacets = {},
|
|
165
|
+
limit = 20,
|
|
166
|
+
} = options;
|
|
167
|
+
|
|
168
|
+
const synonymMap = buildSynonyms(synonyms);
|
|
169
|
+
const queryTokens = tokenize(query);
|
|
170
|
+
|
|
171
|
+
let matched;
|
|
172
|
+
|
|
173
|
+
if (queryTokens.length === 0) {
|
|
174
|
+
// Empty query: everything matches with score 0, input order preserved.
|
|
175
|
+
matched = list.map((item, index) => ({ item, index, score: 0, matched: [] }));
|
|
176
|
+
} else {
|
|
177
|
+
const variantSets = queryTokens.map((t) => expand(t, synonymMap));
|
|
178
|
+
matched = [];
|
|
179
|
+
|
|
180
|
+
list.forEach((item, index) => {
|
|
181
|
+
const fieldTokens = itemTokens(item, fields);
|
|
182
|
+
let score = 0;
|
|
183
|
+
const hitTokens = [];
|
|
184
|
+
|
|
185
|
+
queryTokens.forEach((token, i) => {
|
|
186
|
+
const s = scoreToken(variantSets[i], fieldTokens, fieldWeights, fuzzy);
|
|
187
|
+
if (s > 0) {
|
|
188
|
+
score += s;
|
|
189
|
+
hitTokens.push(token);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (hitTokens.length === 0) return;
|
|
194
|
+
|
|
195
|
+
// Reward covering more of the query; big bonus when every token hits.
|
|
196
|
+
score += hitTokens.length;
|
|
197
|
+
if (hitTokens.length === queryTokens.length) score += 5;
|
|
198
|
+
|
|
199
|
+
matched.push({ item, index, score, matched: hitTokens });
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Facet counts are computed over the query-matched set (before active-facet filtering).
|
|
204
|
+
const facets = {};
|
|
205
|
+
for (const key of facetKeys) {
|
|
206
|
+
const counts = new Map();
|
|
207
|
+
for (const { item } of matched) {
|
|
208
|
+
const raw = item ? item[key] : undefined;
|
|
209
|
+
const values = Array.isArray(raw) ? raw : raw == null ? [] : [raw];
|
|
210
|
+
for (const v of values) counts.set(v, (counts.get(v) || 0) + 1);
|
|
211
|
+
}
|
|
212
|
+
facets[key] = Array.from(counts, ([value, count]) => ({ value, count })).sort(
|
|
213
|
+
(a, b) => b.count - a.count || String(a.value).localeCompare(String(b.value)),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Apply active facet filters to the returned results.
|
|
218
|
+
let filtered = matched;
|
|
219
|
+
for (const [key, wanted] of Object.entries(activeFacets)) {
|
|
220
|
+
if (wanted == null || wanted === '') continue;
|
|
221
|
+
filtered = filtered.filter(({ item }) => {
|
|
222
|
+
const raw = item ? item[key] : undefined;
|
|
223
|
+
if (Array.isArray(raw)) return raw.includes(wanted);
|
|
224
|
+
|
|
225
|
+
return raw === wanted;
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
filtered.sort((a, b) => b.score - a.score || a.index - b.index);
|
|
230
|
+
const total = filtered.length;
|
|
231
|
+
const results = filtered.slice(0, limit).map(({ item, score, matched: m }) => ({ item, score, matched: m }));
|
|
232
|
+
|
|
233
|
+
return { results, facets, total };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Segment `text` into [{ text, match }] runs so a UI can highlight matches.
|
|
238
|
+
* A word is a match when it exactly/prefix/fuzzy-matches a query variant.
|
|
239
|
+
*/
|
|
240
|
+
export function highlightSegments(text, query, options = {}) {
|
|
241
|
+
const raw = text == null ? '' : String(text);
|
|
242
|
+
const synonymMap = buildSynonyms(options.synonyms);
|
|
243
|
+
const variants = new Set();
|
|
244
|
+
for (const t of tokenize(query)) for (const v of expand(t, synonymMap)) variants.add(v);
|
|
245
|
+
if (variants.size === 0 || raw === '') return [{ text: raw, match: false }];
|
|
246
|
+
|
|
247
|
+
const variantList = Array.from(variants);
|
|
248
|
+
const isMatch = (word) => {
|
|
249
|
+
const w = word.toLowerCase();
|
|
250
|
+
|
|
251
|
+
return variantList.some((v) => {
|
|
252
|
+
const edits = maxEditsFor(v, options.fuzzy ?? true);
|
|
253
|
+
|
|
254
|
+
return w === v || (v.length >= 2 && w.startsWith(v)) || (edits > 0 && editDistance(v, w, edits) <= edits);
|
|
255
|
+
});
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const segments = [];
|
|
259
|
+
const parts = raw.split(/(\s+)/);
|
|
260
|
+
for (const part of parts) {
|
|
261
|
+
if (part === '') continue;
|
|
262
|
+
const match = /\s/.test(part) ? false : isMatch(part.replace(/[^a-z0-9]/gi, ''));
|
|
263
|
+
const last = segments[segments.length - 1];
|
|
264
|
+
if (last && last.match === match) last.text += part;
|
|
265
|
+
else segments.push({ text: part, match });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return segments;
|
|
269
|
+
}
|