@86d-app/search 0.0.3 → 0.0.6
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/.turbo/turbo-build.log +1 -0
- package/AGENTS.md +72 -0
- package/README.md +172 -30
- package/dist/__tests__/controllers.test.d.ts +2 -0
- package/dist/__tests__/controllers.test.d.ts.map +1 -0
- package/dist/__tests__/embedding-provider.test.d.ts +2 -0
- package/dist/__tests__/embedding-provider.test.d.ts.map +1 -0
- package/dist/__tests__/endpoint-security.test.d.ts +2 -0
- package/dist/__tests__/endpoint-security.test.d.ts.map +1 -0
- package/dist/__tests__/meilisearch-provider.test.d.ts +2 -0
- package/dist/__tests__/meilisearch-provider.test.d.ts.map +1 -0
- package/dist/__tests__/service-impl.test.d.ts +2 -0
- package/dist/__tests__/service-impl.test.d.ts.map +1 -0
- package/dist/admin/components/index.d.ts +2 -0
- package/dist/admin/components/index.d.ts.map +1 -0
- package/dist/admin/components/search-analytics.d.ts +2 -0
- package/dist/admin/components/search-analytics.d.ts.map +1 -0
- package/dist/admin/endpoints/analytics.d.ts +15 -0
- package/dist/admin/endpoints/analytics.d.ts.map +1 -0
- package/dist/admin/endpoints/bulk-index.d.ts +20 -0
- package/dist/admin/endpoints/bulk-index.d.ts.map +1 -0
- package/dist/admin/endpoints/click-analytics.d.ts +7 -0
- package/dist/admin/endpoints/click-analytics.d.ts.map +1 -0
- package/dist/admin/endpoints/get-settings.d.ts +17 -0
- package/dist/admin/endpoints/get-settings.d.ts.map +1 -0
- package/dist/admin/endpoints/index-manage.d.ts +26 -0
- package/dist/admin/endpoints/index-manage.d.ts.map +1 -0
- package/dist/admin/endpoints/index.d.ts +125 -0
- package/dist/admin/endpoints/index.d.ts.map +1 -0
- package/dist/admin/endpoints/popular.d.ts +10 -0
- package/dist/admin/endpoints/popular.d.ts.map +1 -0
- package/dist/admin/endpoints/synonyms.d.ts +30 -0
- package/dist/admin/endpoints/synonyms.d.ts.map +1 -0
- package/dist/admin/endpoints/zero-results.d.ts +10 -0
- package/dist/admin/endpoints/zero-results.d.ts.map +1 -0
- package/dist/embedding-provider.d.ts +28 -0
- package/dist/embedding-provider.d.ts.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/meilisearch-provider.d.ts +104 -0
- package/dist/meilisearch-provider.d.ts.map +1 -0
- package/dist/schema.d.ts +133 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/service-impl.d.ts +6 -0
- package/dist/service-impl.d.ts.map +1 -0
- package/dist/service.d.ts +127 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/store/components/_hooks.d.ts +6 -0
- package/dist/store/components/_hooks.d.ts.map +1 -0
- package/dist/store/components/index.d.ts +10 -0
- package/dist/store/components/index.d.ts.map +1 -0
- package/dist/store/components/search-bar.d.ts +7 -0
- package/dist/store/components/search-bar.d.ts.map +1 -0
- package/dist/store/components/search-page.d.ts +4 -0
- package/dist/store/components/search-page.d.ts.map +1 -0
- package/dist/store/components/search-results.d.ts +9 -0
- package/dist/store/components/search-results.d.ts.map +1 -0
- package/dist/store/endpoints/click.d.ts +14 -0
- package/dist/store/endpoints/click.d.ts.map +1 -0
- package/dist/store/endpoints/index.d.ts +85 -0
- package/dist/store/endpoints/index.d.ts.map +1 -0
- package/dist/store/endpoints/recent.d.ts +15 -0
- package/dist/store/endpoints/recent.d.ts.map +1 -0
- package/dist/store/endpoints/search.d.ts +36 -0
- package/dist/store/endpoints/search.d.ts.map +1 -0
- package/dist/store/endpoints/store-search.d.ts +16 -0
- package/dist/store/endpoints/store-search.d.ts.map +1 -0
- package/dist/store/endpoints/suggest.d.ts +11 -0
- package/dist/store/endpoints/suggest.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/controllers.test.ts +1026 -0
- package/src/__tests__/embedding-provider.test.ts +195 -0
- package/src/__tests__/endpoint-security.test.ts +300 -0
- package/src/__tests__/meilisearch-provider.test.ts +400 -0
- package/src/__tests__/service-impl.test.ts +341 -8
- package/src/admin/components/search-analytics.tsx +120 -0
- package/src/admin/endpoints/bulk-index.ts +34 -0
- package/src/admin/endpoints/click-analytics.ts +16 -0
- package/src/admin/endpoints/get-settings.ts +56 -0
- package/src/admin/endpoints/index-manage.ts +4 -1
- package/src/admin/endpoints/index.ts +6 -0
- package/src/admin/endpoints/synonyms.ts +1 -1
- package/src/embedding-provider.ts +99 -0
- package/src/index.ts +60 -4
- package/src/meilisearch-provider.ts +239 -0
- package/src/schema.ts +15 -0
- package/src/service-impl.ts +605 -34
- package/src/service.ts +60 -1
- package/src/store/endpoints/click.ts +21 -0
- package/src/store/endpoints/index.ts +2 -0
- package/src/store/endpoints/search.ts +38 -10
- package/src/store/endpoints/suggest.ts +2 -2
- package/vitest.config.ts +2 -0
|
@@ -63,6 +63,64 @@ describe("createSearchController", () => {
|
|
|
63
63
|
});
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
// ── bulkIndex ────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
describe("bulkIndex", () => {
|
|
69
|
+
it("indexes multiple items at once", async () => {
|
|
70
|
+
const result = await controller.bulkIndex([
|
|
71
|
+
{
|
|
72
|
+
entityType: "product",
|
|
73
|
+
entityId: "p1",
|
|
74
|
+
title: "Item 1",
|
|
75
|
+
url: "/p1",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
entityType: "product",
|
|
79
|
+
entityId: "p2",
|
|
80
|
+
title: "Item 2",
|
|
81
|
+
url: "/p2",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
entityType: "product",
|
|
85
|
+
entityId: "p3",
|
|
86
|
+
title: "Item 3",
|
|
87
|
+
url: "/p3",
|
|
88
|
+
},
|
|
89
|
+
]);
|
|
90
|
+
expect(result.indexed).toBe(3);
|
|
91
|
+
expect(result.errors).toBe(0);
|
|
92
|
+
const count = await controller.getIndexCount();
|
|
93
|
+
expect(count).toBe(3);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("updates existing items in bulk", async () => {
|
|
97
|
+
await controller.indexItem({
|
|
98
|
+
entityType: "product",
|
|
99
|
+
entityId: "p1",
|
|
100
|
+
title: "Old Title",
|
|
101
|
+
url: "/p1",
|
|
102
|
+
});
|
|
103
|
+
const result = await controller.bulkIndex([
|
|
104
|
+
{
|
|
105
|
+
entityType: "product",
|
|
106
|
+
entityId: "p1",
|
|
107
|
+
title: "New Title",
|
|
108
|
+
url: "/p1",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
entityType: "product",
|
|
112
|
+
entityId: "p2",
|
|
113
|
+
title: "Item 2",
|
|
114
|
+
url: "/p2",
|
|
115
|
+
},
|
|
116
|
+
]);
|
|
117
|
+
expect(result.indexed).toBe(2);
|
|
118
|
+
expect(result.errors).toBe(0);
|
|
119
|
+
const count = await controller.getIndexCount();
|
|
120
|
+
expect(count).toBe(2);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
66
124
|
// ── removeFromIndex ─────────────────────────────────────────────────
|
|
67
125
|
|
|
68
126
|
describe("removeFromIndex", () => {
|
|
@@ -123,7 +181,9 @@ describe("createSearchController", () => {
|
|
|
123
181
|
});
|
|
124
182
|
|
|
125
183
|
it("returns empty results for non-matching query", async () => {
|
|
126
|
-
const { results, total } = await controller.search("nonexistent"
|
|
184
|
+
const { results, total } = await controller.search("nonexistent", {
|
|
185
|
+
fuzzy: false,
|
|
186
|
+
});
|
|
127
187
|
expect(results).toHaveLength(0);
|
|
128
188
|
expect(total).toBe(0);
|
|
129
189
|
});
|
|
@@ -171,11 +231,215 @@ describe("createSearchController", () => {
|
|
|
171
231
|
});
|
|
172
232
|
|
|
173
233
|
it("ranks title matches higher than body matches", async () => {
|
|
174
|
-
// "Red T-Shirt" has "red" in title; "Blue Jeans" does not
|
|
175
234
|
const { results } = await controller.search("red");
|
|
176
235
|
expect(results.length).toBeGreaterThan(0);
|
|
177
236
|
expect(results[0].item.entityId).toBe("prod_1");
|
|
178
237
|
});
|
|
238
|
+
|
|
239
|
+
it("returns facets with results", async () => {
|
|
240
|
+
const { facets } = await controller.search("clothing");
|
|
241
|
+
expect(facets.entityTypes.length).toBeGreaterThan(0);
|
|
242
|
+
expect(facets.entityTypes[0].type).toBe("product");
|
|
243
|
+
expect(facets.tags.length).toBeGreaterThan(0);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("returns highlights in results", async () => {
|
|
247
|
+
const { results } = await controller.search("red");
|
|
248
|
+
expect(results.length).toBeGreaterThan(0);
|
|
249
|
+
const highlight = results[0].highlights;
|
|
250
|
+
expect(highlight?.title).toContain("<mark>");
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ── fuzzy search ───────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
describe("fuzzy search", () => {
|
|
257
|
+
beforeEach(async () => {
|
|
258
|
+
await controller.indexItem({
|
|
259
|
+
entityType: "product",
|
|
260
|
+
entityId: "prod_1",
|
|
261
|
+
title: "Running Shoes",
|
|
262
|
+
tags: ["footwear", "athletic"],
|
|
263
|
+
url: "/products/running-shoes",
|
|
264
|
+
});
|
|
265
|
+
await controller.indexItem({
|
|
266
|
+
entityType: "product",
|
|
267
|
+
entityId: "prod_2",
|
|
268
|
+
title: "Leather Boots",
|
|
269
|
+
tags: ["footwear", "winter"],
|
|
270
|
+
url: "/products/leather-boots",
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("finds results with typos when fuzzy is enabled", async () => {
|
|
275
|
+
const { results } = await controller.search("runnign", {
|
|
276
|
+
fuzzy: true,
|
|
277
|
+
});
|
|
278
|
+
expect(results.length).toBeGreaterThan(0);
|
|
279
|
+
expect(results[0].item.entityId).toBe("prod_1");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("does not find typo results when fuzzy is disabled", async () => {
|
|
283
|
+
const { results } = await controller.search("runnign", {
|
|
284
|
+
fuzzy: false,
|
|
285
|
+
});
|
|
286
|
+
expect(results).toHaveLength(0);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("fuzzy matches short words only with exact match", async () => {
|
|
290
|
+
// Words <= 3 chars get no fuzzy tolerance
|
|
291
|
+
const { results } = await controller.search("ren", {
|
|
292
|
+
fuzzy: true,
|
|
293
|
+
});
|
|
294
|
+
// "ren" is too short for fuzzy to match "run" (distance 2)
|
|
295
|
+
expect(results).toHaveLength(0);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("fuzzy matches medium words with 1 edit distance", async () => {
|
|
299
|
+
const { results } = await controller.search("shoez", {
|
|
300
|
+
fuzzy: true,
|
|
301
|
+
});
|
|
302
|
+
expect(results.length).toBeGreaterThan(0);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("fuzzy matches longer words with 2 edit distance", async () => {
|
|
306
|
+
const { results } = await controller.search("leathor", {
|
|
307
|
+
fuzzy: true,
|
|
308
|
+
});
|
|
309
|
+
expect(results.length).toBeGreaterThan(0);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// ── tag filtering ──────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
describe("tag filtering", () => {
|
|
316
|
+
beforeEach(async () => {
|
|
317
|
+
await controller.indexItem({
|
|
318
|
+
entityType: "product",
|
|
319
|
+
entityId: "p1",
|
|
320
|
+
title: "Red Sneakers",
|
|
321
|
+
tags: ["shoes", "red", "sport"],
|
|
322
|
+
url: "/p1",
|
|
323
|
+
});
|
|
324
|
+
await controller.indexItem({
|
|
325
|
+
entityType: "product",
|
|
326
|
+
entityId: "p2",
|
|
327
|
+
title: "Red Jacket",
|
|
328
|
+
tags: ["outerwear", "red", "winter"],
|
|
329
|
+
url: "/p2",
|
|
330
|
+
});
|
|
331
|
+
await controller.indexItem({
|
|
332
|
+
entityType: "product",
|
|
333
|
+
entityId: "p3",
|
|
334
|
+
title: "Blue Sneakers",
|
|
335
|
+
tags: ["shoes", "blue", "sport"],
|
|
336
|
+
url: "/p3",
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("filters results by tags", async () => {
|
|
341
|
+
const { results } = await controller.search("sneakers", {
|
|
342
|
+
tags: ["red"],
|
|
343
|
+
});
|
|
344
|
+
expect(results).toHaveLength(1);
|
|
345
|
+
expect(results[0].item.entityId).toBe("p1");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("returns all matching results without tag filter", async () => {
|
|
349
|
+
const { results } = await controller.search("red");
|
|
350
|
+
expect(results.length).toBeGreaterThanOrEqual(2);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("returns empty when no items match tag filter", async () => {
|
|
354
|
+
const { results } = await controller.search("sneakers", {
|
|
355
|
+
tags: ["winter"],
|
|
356
|
+
});
|
|
357
|
+
expect(results).toHaveLength(0);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// ── sorting ────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
describe("sorting", () => {
|
|
364
|
+
beforeEach(async () => {
|
|
365
|
+
await controller.indexItem({
|
|
366
|
+
entityType: "product",
|
|
367
|
+
entityId: "p1",
|
|
368
|
+
title: "Alpha Widget",
|
|
369
|
+
tags: ["widget"],
|
|
370
|
+
url: "/p1",
|
|
371
|
+
});
|
|
372
|
+
// Add a small delay to ensure different timestamps
|
|
373
|
+
await controller.indexItem({
|
|
374
|
+
entityType: "product",
|
|
375
|
+
entityId: "p2",
|
|
376
|
+
title: "Beta Widget",
|
|
377
|
+
tags: ["widget"],
|
|
378
|
+
url: "/p2",
|
|
379
|
+
});
|
|
380
|
+
await controller.indexItem({
|
|
381
|
+
entityType: "product",
|
|
382
|
+
entityId: "p3",
|
|
383
|
+
title: "Charlie Widget",
|
|
384
|
+
tags: ["widget"],
|
|
385
|
+
url: "/p3",
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("sorts by title ascending", async () => {
|
|
390
|
+
const { results } = await controller.search("widget", {
|
|
391
|
+
sort: "title_asc",
|
|
392
|
+
});
|
|
393
|
+
expect(results[0].item.title).toBe("Alpha Widget");
|
|
394
|
+
expect(results[results.length - 1].item.title).toBe("Charlie Widget");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("sorts by title descending", async () => {
|
|
398
|
+
const { results } = await controller.search("widget", {
|
|
399
|
+
sort: "title_desc",
|
|
400
|
+
});
|
|
401
|
+
expect(results[0].item.title).toBe("Charlie Widget");
|
|
402
|
+
expect(results[results.length - 1].item.title).toBe("Alpha Widget");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("defaults to relevance sorting", async () => {
|
|
406
|
+
const { results } = await controller.search("widget");
|
|
407
|
+
// All have same relevance, so order is stable
|
|
408
|
+
expect(results.length).toBe(3);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// ── did-you-mean ───────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
describe("did-you-mean", () => {
|
|
415
|
+
beforeEach(async () => {
|
|
416
|
+
await controller.indexItem({
|
|
417
|
+
entityType: "product",
|
|
418
|
+
entityId: "p1",
|
|
419
|
+
title: "Running Shoes",
|
|
420
|
+
url: "/p1",
|
|
421
|
+
});
|
|
422
|
+
await controller.indexItem({
|
|
423
|
+
entityType: "product",
|
|
424
|
+
entityId: "p2",
|
|
425
|
+
title: "Leather Boots",
|
|
426
|
+
url: "/p2",
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("suggests correction for misspelled query with zero results", async () => {
|
|
431
|
+
// "bootes" is close to "boots" (dist 1), won't substring-match "Running Shoes"
|
|
432
|
+
const { didYouMean } = await controller.search("bootes", {
|
|
433
|
+
fuzzy: false,
|
|
434
|
+
});
|
|
435
|
+
expect(didYouMean).toBeDefined();
|
|
436
|
+
expect(didYouMean).toBe("boots");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("does not suggest correction when results are found", async () => {
|
|
440
|
+
const { didYouMean } = await controller.search("running");
|
|
441
|
+
expect(didYouMean).toBeUndefined();
|
|
442
|
+
});
|
|
179
443
|
});
|
|
180
444
|
|
|
181
445
|
// ── synonym expansion ───────────────────────────────────────────────
|
|
@@ -205,7 +469,6 @@ describe("createSearchController", () => {
|
|
|
205
469
|
url: "/products/tee-collection",
|
|
206
470
|
});
|
|
207
471
|
const { results } = await controller.search("tshirt");
|
|
208
|
-
// "tshirt" is a synonym of "tee", so "tee" should also be searched
|
|
209
472
|
expect(results.length).toBeGreaterThan(0);
|
|
210
473
|
});
|
|
211
474
|
});
|
|
@@ -226,7 +489,6 @@ describe("createSearchController", () => {
|
|
|
226
489
|
title: "Red Sneakers",
|
|
227
490
|
url: "/products/red-sneakers",
|
|
228
491
|
});
|
|
229
|
-
// Record some queries
|
|
230
492
|
await controller.recordQuery("red t-shirt", 5);
|
|
231
493
|
await controller.recordQuery("red t-shirt", 3);
|
|
232
494
|
await controller.recordQuery("red sneakers", 2);
|
|
@@ -242,7 +504,6 @@ describe("createSearchController", () => {
|
|
|
242
504
|
|
|
243
505
|
it("prioritizes popular queries over title matches", async () => {
|
|
244
506
|
const suggestions = await controller.suggest("red");
|
|
245
|
-
// "red t-shirt" was searched twice, should appear before "red sneakers"
|
|
246
507
|
expect(suggestions[0].toLowerCase()).toContain("red t-shirt");
|
|
247
508
|
});
|
|
248
509
|
|
|
@@ -276,6 +537,47 @@ describe("createSearchController", () => {
|
|
|
276
537
|
});
|
|
277
538
|
});
|
|
278
539
|
|
|
540
|
+
// ── recordClick ─────────────────────────────────────────────────────
|
|
541
|
+
|
|
542
|
+
describe("recordClick", () => {
|
|
543
|
+
it("records a search result click", async () => {
|
|
544
|
+
const query = await controller.recordQuery("shoes", 10, "sess_1");
|
|
545
|
+
const click = await controller.recordClick({
|
|
546
|
+
queryId: query.id,
|
|
547
|
+
term: "shoes",
|
|
548
|
+
entityType: "product",
|
|
549
|
+
entityId: "prod_1",
|
|
550
|
+
position: 0,
|
|
551
|
+
});
|
|
552
|
+
expect(click.id).toBeDefined();
|
|
553
|
+
expect(click.queryId).toBe(query.id);
|
|
554
|
+
expect(click.term).toBe("shoes");
|
|
555
|
+
expect(click.position).toBe(0);
|
|
556
|
+
expect(click.clickedAt).toBeInstanceOf(Date);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("records multiple clicks for different positions", async () => {
|
|
560
|
+
const query = await controller.recordQuery("shoes", 10);
|
|
561
|
+
const click1 = await controller.recordClick({
|
|
562
|
+
queryId: query.id,
|
|
563
|
+
term: "shoes",
|
|
564
|
+
entityType: "product",
|
|
565
|
+
entityId: "prod_1",
|
|
566
|
+
position: 0,
|
|
567
|
+
});
|
|
568
|
+
const click2 = await controller.recordClick({
|
|
569
|
+
queryId: query.id,
|
|
570
|
+
term: "shoes",
|
|
571
|
+
entityType: "product",
|
|
572
|
+
entityId: "prod_2",
|
|
573
|
+
position: 1,
|
|
574
|
+
});
|
|
575
|
+
expect(click1.id).not.toBe(click2.id);
|
|
576
|
+
expect(click1.position).toBe(0);
|
|
577
|
+
expect(click2.position).toBe(1);
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
|
|
279
581
|
// ── getRecentQueries ────────────────────────────────────────────────
|
|
280
582
|
|
|
281
583
|
describe("getRecentQueries", () => {
|
|
@@ -324,7 +626,7 @@ describe("createSearchController", () => {
|
|
|
324
626
|
const popular = await controller.getPopularTerms();
|
|
325
627
|
expect(popular[0].term).toBe("shoes");
|
|
326
628
|
expect(popular[0].count).toBe(3);
|
|
327
|
-
expect(popular[0].avgResultCount).toBe(10);
|
|
629
|
+
expect(popular[0].avgResultCount).toBe(10);
|
|
328
630
|
expect(popular[1].term).toBe("hats");
|
|
329
631
|
expect(popular[1].count).toBe(2);
|
|
330
632
|
});
|
|
@@ -377,9 +679,11 @@ describe("createSearchController", () => {
|
|
|
377
679
|
const analytics = await controller.getAnalytics();
|
|
378
680
|
expect(analytics.totalQueries).toBe(3);
|
|
379
681
|
expect(analytics.uniqueTerms).toBe(2);
|
|
380
|
-
expect(analytics.avgResultCount).toBe(6);
|
|
682
|
+
expect(analytics.avgResultCount).toBe(6);
|
|
381
683
|
expect(analytics.zeroResultCount).toBe(1);
|
|
382
|
-
expect(analytics.zeroResultRate).toBe(33);
|
|
684
|
+
expect(analytics.zeroResultRate).toBe(33);
|
|
685
|
+
expect(analytics.clickThroughRate).toBe(0);
|
|
686
|
+
expect(analytics.avgClickPosition).toBe(0);
|
|
383
687
|
});
|
|
384
688
|
|
|
385
689
|
it("returns zeros when no queries", async () => {
|
|
@@ -389,6 +693,35 @@ describe("createSearchController", () => {
|
|
|
389
693
|
expect(analytics.avgResultCount).toBe(0);
|
|
390
694
|
expect(analytics.zeroResultCount).toBe(0);
|
|
391
695
|
expect(analytics.zeroResultRate).toBe(0);
|
|
696
|
+
expect(analytics.clickThroughRate).toBe(0);
|
|
697
|
+
expect(analytics.avgClickPosition).toBe(0);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it("computes click-through rate and avg position", async () => {
|
|
701
|
+
const q1 = await controller.recordQuery("shoes", 10);
|
|
702
|
+
const q2 = await controller.recordQuery("hats", 5);
|
|
703
|
+
await controller.recordQuery("bags", 3);
|
|
704
|
+
|
|
705
|
+
await controller.recordClick({
|
|
706
|
+
queryId: q1.id,
|
|
707
|
+
term: "shoes",
|
|
708
|
+
entityType: "product",
|
|
709
|
+
entityId: "p1",
|
|
710
|
+
position: 0,
|
|
711
|
+
});
|
|
712
|
+
await controller.recordClick({
|
|
713
|
+
queryId: q2.id,
|
|
714
|
+
term: "hats",
|
|
715
|
+
entityType: "product",
|
|
716
|
+
entityId: "p2",
|
|
717
|
+
position: 2,
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
const analytics = await controller.getAnalytics();
|
|
721
|
+
// 2 queries with clicks out of 3 queries with results = 67%
|
|
722
|
+
expect(analytics.clickThroughRate).toBe(67);
|
|
723
|
+
// avg position: (0 + 2) / 2 = 1
|
|
724
|
+
expect(analytics.avgClickPosition).toBe(1);
|
|
392
725
|
});
|
|
393
726
|
});
|
|
394
727
|
|
|
@@ -25,9 +25,25 @@ interface Synonym {
|
|
|
25
25
|
createdAt: string;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
interface SearchSettings {
|
|
29
|
+
meilisearch: {
|
|
30
|
+
configured: boolean;
|
|
31
|
+
host: string | null;
|
|
32
|
+
apiKey: string | null;
|
|
33
|
+
indexUid: string;
|
|
34
|
+
};
|
|
35
|
+
embeddings: {
|
|
36
|
+
configured: boolean;
|
|
37
|
+
provider: "openai" | "openrouter" | null;
|
|
38
|
+
model: string;
|
|
39
|
+
};
|
|
40
|
+
indexCount: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
function useSearchAdminApi() {
|
|
29
44
|
const client = useModuleClient();
|
|
30
45
|
return {
|
|
46
|
+
settings: client.module("search").admin["/admin/search/settings"],
|
|
31
47
|
analytics: client.module("search").admin["/admin/search/analytics"],
|
|
32
48
|
popular: client.module("search").admin["/admin/search/popular"],
|
|
33
49
|
zeroResults: client.module("search").admin["/admin/search/zero-results"],
|
|
@@ -49,12 +65,41 @@ function StatCard({ label, value }: { label: string; value: string | number }) {
|
|
|
49
65
|
);
|
|
50
66
|
}
|
|
51
67
|
|
|
68
|
+
function ConfigBadge({
|
|
69
|
+
configured,
|
|
70
|
+
label,
|
|
71
|
+
}: {
|
|
72
|
+
configured: boolean;
|
|
73
|
+
label: string;
|
|
74
|
+
}) {
|
|
75
|
+
return (
|
|
76
|
+
<span
|
|
77
|
+
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 font-medium text-xs ${
|
|
78
|
+
configured
|
|
79
|
+
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
|
|
80
|
+
: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
|
81
|
+
}`}
|
|
82
|
+
>
|
|
83
|
+
<span
|
|
84
|
+
className={`inline-block size-1.5 rounded-full ${
|
|
85
|
+
configured ? "bg-emerald-500" : "bg-amber-500"
|
|
86
|
+
}`}
|
|
87
|
+
/>
|
|
88
|
+
{label}
|
|
89
|
+
</span>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
52
93
|
export function SearchAnalytics() {
|
|
53
94
|
const api = useSearchAdminApi();
|
|
54
95
|
const [newTerm, setNewTerm] = useState("");
|
|
55
96
|
const [newSynonyms, setNewSynonyms] = useState("");
|
|
56
97
|
const [error, setError] = useState("");
|
|
57
98
|
|
|
99
|
+
const { data: settingsData } = api.settings.useQuery({}) as {
|
|
100
|
+
data: SearchSettings | undefined;
|
|
101
|
+
};
|
|
102
|
+
|
|
58
103
|
const { data: analyticsData, isLoading: analyticsLoading } =
|
|
59
104
|
api.analytics.useQuery({}) as {
|
|
60
105
|
data: { analytics: AnalyticsData } | undefined;
|
|
@@ -132,6 +177,81 @@ export function SearchAnalytics() {
|
|
|
132
177
|
|
|
133
178
|
return (
|
|
134
179
|
<div className="space-y-8">
|
|
180
|
+
{/* Search engine configuration */}
|
|
181
|
+
{settingsData && (
|
|
182
|
+
<div className="rounded-lg border border-border bg-background p-5">
|
|
183
|
+
<h3 className="mb-4 font-medium text-foreground text-sm">
|
|
184
|
+
Search Configuration
|
|
185
|
+
</h3>
|
|
186
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
187
|
+
<div className="flex flex-col gap-2">
|
|
188
|
+
<div className="flex items-center justify-between">
|
|
189
|
+
<span className="text-muted-foreground text-sm">
|
|
190
|
+
MeiliSearch
|
|
191
|
+
</span>
|
|
192
|
+
<ConfigBadge
|
|
193
|
+
configured={settingsData.meilisearch.configured}
|
|
194
|
+
label={
|
|
195
|
+
settingsData.meilisearch.configured
|
|
196
|
+
? "Connected"
|
|
197
|
+
: "Not configured"
|
|
198
|
+
}
|
|
199
|
+
/>
|
|
200
|
+
</div>
|
|
201
|
+
{settingsData.meilisearch.configured ? (
|
|
202
|
+
<div className="text-muted-foreground text-xs">
|
|
203
|
+
<p>Host: {settingsData.meilisearch.host}</p>
|
|
204
|
+
<p>Index: {settingsData.meilisearch.indexUid}</p>
|
|
205
|
+
<p>Key: {settingsData.meilisearch.apiKey}</p>
|
|
206
|
+
</div>
|
|
207
|
+
) : (
|
|
208
|
+
<p className="text-muted-foreground text-xs">
|
|
209
|
+
Using local search engine. Configure{" "}
|
|
210
|
+
<code className="rounded bg-muted px-1 text-[11px]">
|
|
211
|
+
MEILISEARCH_HOST
|
|
212
|
+
</code>{" "}
|
|
213
|
+
and{" "}
|
|
214
|
+
<code className="rounded bg-muted px-1 text-[11px]">
|
|
215
|
+
MEILISEARCH_API_KEY
|
|
216
|
+
</code>{" "}
|
|
217
|
+
for dedicated search.
|
|
218
|
+
</p>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
<div className="flex flex-col gap-2">
|
|
222
|
+
<div className="flex items-center justify-between">
|
|
223
|
+
<span className="text-muted-foreground text-sm">
|
|
224
|
+
AI Embeddings
|
|
225
|
+
</span>
|
|
226
|
+
<ConfigBadge
|
|
227
|
+
configured={settingsData.embeddings.configured}
|
|
228
|
+
label={
|
|
229
|
+
settingsData.embeddings.configured
|
|
230
|
+
? settingsData.embeddings.provider === "openai"
|
|
231
|
+
? "OpenAI"
|
|
232
|
+
: "OpenRouter"
|
|
233
|
+
: "Not configured"
|
|
234
|
+
}
|
|
235
|
+
/>
|
|
236
|
+
</div>
|
|
237
|
+
{settingsData.embeddings.configured ? (
|
|
238
|
+
<p className="text-muted-foreground text-xs">
|
|
239
|
+
Model: {settingsData.embeddings.model}
|
|
240
|
+
</p>
|
|
241
|
+
) : (
|
|
242
|
+
<p className="text-muted-foreground text-xs">
|
|
243
|
+
Semantic search disabled. Configure{" "}
|
|
244
|
+
<code className="rounded bg-muted px-1 text-[11px]">
|
|
245
|
+
OPENAI_API_KEY
|
|
246
|
+
</code>{" "}
|
|
247
|
+
for AI-powered results.
|
|
248
|
+
</p>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
|
|
135
255
|
{/* Stats overview */}
|
|
136
256
|
{analytics && (
|
|
137
257
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createAdminEndpoint, z } from "@86d-app/core";
|
|
2
|
+
import type { SearchController } from "../../service";
|
|
3
|
+
|
|
4
|
+
export const bulkIndex = createAdminEndpoint(
|
|
5
|
+
"/admin/search/index/bulk",
|
|
6
|
+
{
|
|
7
|
+
method: "POST",
|
|
8
|
+
body: z.object({
|
|
9
|
+
items: z
|
|
10
|
+
.array(
|
|
11
|
+
z.object({
|
|
12
|
+
entityType: z.string().min(1).max(100),
|
|
13
|
+
entityId: z.string().min(1).max(200),
|
|
14
|
+
title: z.string().min(1).max(500),
|
|
15
|
+
body: z.string().max(10000).optional(),
|
|
16
|
+
tags: z.array(z.string().max(100)).max(50).optional(),
|
|
17
|
+
url: z.string().min(1).max(500),
|
|
18
|
+
image: z.string().max(500).optional(),
|
|
19
|
+
metadata: z
|
|
20
|
+
.record(z.string().max(100), z.unknown())
|
|
21
|
+
.refine((r) => Object.keys(r).length <= 50, "Too many keys")
|
|
22
|
+
.optional(),
|
|
23
|
+
}),
|
|
24
|
+
)
|
|
25
|
+
.min(1)
|
|
26
|
+
.max(500),
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
async (ctx) => {
|
|
30
|
+
const controller = ctx.context.controllers.search as SearchController;
|
|
31
|
+
const result = await controller.bulkIndex(ctx.body.items);
|
|
32
|
+
return result;
|
|
33
|
+
},
|
|
34
|
+
);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createAdminEndpoint } from "@86d-app/core";
|
|
2
|
+
import type { SearchController } from "../../service";
|
|
3
|
+
|
|
4
|
+
export const clickAnalyticsEndpoint = createAdminEndpoint(
|
|
5
|
+
"/admin/search/clicks",
|
|
6
|
+
{ method: "GET" },
|
|
7
|
+
async (ctx) => {
|
|
8
|
+
const controller = ctx.context.controllers.search as SearchController;
|
|
9
|
+
const analytics = await controller.getAnalytics();
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
clickThroughRate: analytics.clickThroughRate,
|
|
13
|
+
avgClickPosition: analytics.avgClickPosition,
|
|
14
|
+
};
|
|
15
|
+
},
|
|
16
|
+
);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createAdminEndpoint } from "@86d-app/core";
|
|
2
|
+
import type { SearchController } from "../../service";
|
|
3
|
+
|
|
4
|
+
function maskKey(key: string): string {
|
|
5
|
+
if (key.length <= 8) return `${key.slice(0, 2)}••••••`;
|
|
6
|
+
return `${key.slice(0, 4)}${"•".repeat(Math.min(key.length - 4, 20))}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface SearchModuleOptions {
|
|
10
|
+
meilisearchHost?: string;
|
|
11
|
+
meilisearchApiKey?: string;
|
|
12
|
+
meilisearchIndexUid?: string;
|
|
13
|
+
openaiApiKey?: string;
|
|
14
|
+
openrouterApiKey?: string;
|
|
15
|
+
embeddingModel?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const getSettings = createAdminEndpoint(
|
|
19
|
+
"/admin/search/settings",
|
|
20
|
+
{ method: "GET" },
|
|
21
|
+
async (ctx) => {
|
|
22
|
+
const controller = ctx.context.controllers.search as SearchController;
|
|
23
|
+
const options = ctx.context.options as SearchModuleOptions | undefined;
|
|
24
|
+
|
|
25
|
+
const meilisearchConfigured = Boolean(
|
|
26
|
+
options?.meilisearchHost && options?.meilisearchApiKey,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const embeddingConfigured = Boolean(
|
|
30
|
+
options?.openaiApiKey || options?.openrouterApiKey,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const indexCount = await controller.getIndexCount();
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
meilisearch: {
|
|
37
|
+
configured: meilisearchConfigured,
|
|
38
|
+
host: options?.meilisearchHost ?? null,
|
|
39
|
+
apiKey: options?.meilisearchApiKey
|
|
40
|
+
? maskKey(options.meilisearchApiKey)
|
|
41
|
+
: null,
|
|
42
|
+
indexUid: options?.meilisearchIndexUid ?? "search",
|
|
43
|
+
},
|
|
44
|
+
embeddings: {
|
|
45
|
+
configured: embeddingConfigured,
|
|
46
|
+
provider: options?.openaiApiKey
|
|
47
|
+
? "openai"
|
|
48
|
+
: options?.openrouterApiKey
|
|
49
|
+
? "openrouter"
|
|
50
|
+
: null,
|
|
51
|
+
model: options?.embeddingModel ?? "text-embedding-3-small",
|
|
52
|
+
},
|
|
53
|
+
indexCount,
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
);
|
|
@@ -13,7 +13,10 @@ export const indexItem = createAdminEndpoint(
|
|
|
13
13
|
tags: z.array(z.string().max(100)).max(50).optional(),
|
|
14
14
|
url: z.string().min(1).max(500),
|
|
15
15
|
image: z.string().max(500).optional(),
|
|
16
|
-
metadata: z
|
|
16
|
+
metadata: z
|
|
17
|
+
.record(z.string().max(100), z.unknown())
|
|
18
|
+
.refine((r) => Object.keys(r).length <= 50, "Too many keys")
|
|
19
|
+
.optional(),
|
|
17
20
|
}),
|
|
18
21
|
},
|
|
19
22
|
async (ctx) => {
|