@voyant-travel/catalog 0.117.2
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/LICENSE +201 -0
- package/README.md +190 -0
- package/dist/adapter/booking-forwarding.d.ts +2 -0
- package/dist/adapter/booking-forwarding.d.ts.map +1 -0
- package/dist/adapter/booking-forwarding.js +1 -0
- package/dist/adapter/channel-push-contracts.d.ts +2 -0
- package/dist/adapter/channel-push-contracts.d.ts.map +1 -0
- package/dist/adapter/channel-push-contracts.js +1 -0
- package/dist/adapter/contract.d.ts +2 -0
- package/dist/adapter/contract.d.ts.map +1 -0
- package/dist/adapter/contract.js +1 -0
- package/dist/adapter/contract.test.d.ts +2 -0
- package/dist/adapter/contract.test.d.ts.map +1 -0
- package/dist/adapter/contract.test.js +390 -0
- package/dist/adapter/provider-contracts.d.ts +2 -0
- package/dist/adapter/provider-contracts.d.ts.map +1 -0
- package/dist/adapter/provider-contracts.js +1 -0
- package/dist/adapter/provider-contracts.test.d.ts +2 -0
- package/dist/adapter/provider-contracts.test.d.ts.map +1 -0
- package/dist/adapter/provider-contracts.test.js +206 -0
- package/dist/adapter/schemas.d.ts +2 -0
- package/dist/adapter/schemas.d.ts.map +1 -0
- package/dist/adapter/schemas.js +1 -0
- package/dist/adapter/schemas.test.d.ts +2 -0
- package/dist/adapter/schemas.test.d.ts.map +1 -0
- package/dist/adapter/schemas.test.js +344 -0
- package/dist/booking-engine/book.d.ts +124 -0
- package/dist/booking-engine/book.d.ts.map +1 -0
- package/dist/booking-engine/book.js +311 -0
- package/dist/booking-engine/cancel.d.ts +40 -0
- package/dist/booking-engine/cancel.d.ts.map +1 -0
- package/dist/booking-engine/cancel.js +56 -0
- package/dist/booking-engine/checkout-finalize.d.ts +146 -0
- package/dist/booking-engine/checkout-finalize.d.ts.map +1 -0
- package/dist/booking-engine/checkout-finalize.js +132 -0
- package/dist/booking-engine/contracts.d.ts +9 -0
- package/dist/booking-engine/contracts.d.ts.map +1 -0
- package/dist/booking-engine/contracts.js +8 -0
- package/dist/booking-engine/contracts.test.d.ts +2 -0
- package/dist/booking-engine/contracts.test.d.ts.map +1 -0
- package/dist/booking-engine/contracts.test.js +116 -0
- package/dist/booking-engine/draft-shape.d.ts +10 -0
- package/dist/booking-engine/draft-shape.d.ts.map +1 -0
- package/dist/booking-engine/draft-shape.js +9 -0
- package/dist/booking-engine/draft-shape.test.d.ts +2 -0
- package/dist/booking-engine/draft-shape.test.d.ts.map +1 -0
- package/dist/booking-engine/draft-shape.test.js +74 -0
- package/dist/booking-engine/drafts-schema.d.ts +302 -0
- package/dist/booking-engine/drafts-schema.d.ts.map +1 -0
- package/dist/booking-engine/drafts-schema.js +53 -0
- package/dist/booking-engine/drafts-service.d.ts +41 -0
- package/dist/booking-engine/drafts-service.d.ts.map +1 -0
- package/dist/booking-engine/drafts-service.js +108 -0
- package/dist/booking-engine/errors.d.ts +81 -0
- package/dist/booking-engine/errors.d.ts.map +1 -0
- package/dist/booking-engine/errors.js +113 -0
- package/dist/booking-engine/index.d.ts +36 -0
- package/dist/booking-engine/index.d.ts.map +1 -0
- package/dist/booking-engine/index.js +34 -0
- package/dist/booking-engine/orders.d.ts +41 -0
- package/dist/booking-engine/orders.d.ts.map +1 -0
- package/dist/booking-engine/orders.js +49 -0
- package/dist/booking-engine/owned-handler.d.ts +166 -0
- package/dist/booking-engine/owned-handler.d.ts.map +1 -0
- package/dist/booking-engine/owned-handler.js +50 -0
- package/dist/booking-engine/owned-handler.test.d.ts +2 -0
- package/dist/booking-engine/owned-handler.test.d.ts.map +1 -0
- package/dist/booking-engine/owned-handler.test.js +63 -0
- package/dist/booking-engine/promotions-contract.d.ts +8 -0
- package/dist/booking-engine/promotions-contract.d.ts.map +1 -0
- package/dist/booking-engine/promotions-contract.js +7 -0
- package/dist/booking-engine/quote-enricher.test.d.ts +12 -0
- package/dist/booking-engine/quote-enricher.test.d.ts.map +1 -0
- package/dist/booking-engine/quote-enricher.test.js +138 -0
- package/dist/booking-engine/quote.d.ts +163 -0
- package/dist/booking-engine/quote.d.ts.map +1 -0
- package/dist/booking-engine/quote.js +259 -0
- package/dist/booking-engine/registry.d.ts +85 -0
- package/dist/booking-engine/registry.d.ts.map +1 -0
- package/dist/booking-engine/registry.js +118 -0
- package/dist/booking-engine/registry.test.d.ts +2 -0
- package/dist/booking-engine/registry.test.d.ts.map +1 -0
- package/dist/booking-engine/registry.test.js +132 -0
- package/dist/booking-engine/routes-contracts.d.ts +169 -0
- package/dist/booking-engine/routes-contracts.d.ts.map +1 -0
- package/dist/booking-engine/routes-contracts.js +63 -0
- package/dist/booking-engine/routes.d.ts +7 -0
- package/dist/booking-engine/routes.d.ts.map +1 -0
- package/dist/booking-engine/routes.js +443 -0
- package/dist/booking-engine/routes.test.d.ts +2 -0
- package/dist/booking-engine/routes.test.d.ts.map +1 -0
- package/dist/booking-engine/routes.test.js +304 -0
- package/dist/booking-engine/schema.d.ts +455 -0
- package/dist/booking-engine/schema.d.ts.map +1 -0
- package/dist/booking-engine/schema.js +75 -0
- package/dist/booking-engine/snapshot-content.d.ts +120 -0
- package/dist/booking-engine/snapshot-content.d.ts.map +1 -0
- package/dist/booking-engine/snapshot-content.js +110 -0
- package/dist/booking-engine/snapshot-content.test.d.ts +2 -0
- package/dist/booking-engine/snapshot-content.test.d.ts.map +1 -0
- package/dist/booking-engine/snapshot-content.test.js +213 -0
- package/dist/booking-engine/sync.d.ts +136 -0
- package/dist/booking-engine/sync.d.ts.map +1 -0
- package/dist/booking-engine/sync.js +177 -0
- package/dist/booking-engine/sync.test.d.ts +2 -0
- package/dist/booking-engine/sync.test.d.ts.map +1 -0
- package/dist/booking-engine/sync.test.js +377 -0
- package/dist/contract.d.ts +2 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +1 -0
- package/dist/contract.test.d.ts +2 -0
- package/dist/contract.test.d.ts.map +1 -0
- package/dist/contract.test.js +107 -0
- package/dist/drift/events.d.ts +2 -0
- package/dist/drift/events.d.ts.map +1 -0
- package/dist/drift/events.js +1 -0
- package/dist/drift/events.test.d.ts +2 -0
- package/dist/drift/events.test.d.ts.map +1 -0
- package/dist/drift/events.test.js +100 -0
- package/dist/embeddings/contract.d.ts +85 -0
- package/dist/embeddings/contract.d.ts.map +1 -0
- package/dist/embeddings/contract.js +42 -0
- package/dist/embeddings/contract.test.d.ts +2 -0
- package/dist/embeddings/contract.test.d.ts.map +1 -0
- package/dist/embeddings/contract.test.js +30 -0
- package/dist/embeddings/gemini.d.ts +110 -0
- package/dist/embeddings/gemini.d.ts.map +1 -0
- package/dist/embeddings/gemini.js +118 -0
- package/dist/embeddings/gemini.test.d.ts +2 -0
- package/dist/embeddings/gemini.test.d.ts.map +1 -0
- package/dist/embeddings/gemini.test.js +132 -0
- package/dist/embeddings/model-registry.d.ts +62 -0
- package/dist/embeddings/model-registry.d.ts.map +1 -0
- package/dist/embeddings/model-registry.js +78 -0
- package/dist/embeddings/model-registry.test.d.ts +2 -0
- package/dist/embeddings/model-registry.test.d.ts.map +1 -0
- package/dist/embeddings/model-registry.test.js +81 -0
- package/dist/embeddings/openai.d.ts +81 -0
- package/dist/embeddings/openai.d.ts.map +1 -0
- package/dist/embeddings/openai.js +123 -0
- package/dist/embeddings/openai.test.d.ts +2 -0
- package/dist/embeddings/openai.test.d.ts.map +1 -0
- package/dist/embeddings/openai.test.js +164 -0
- package/dist/events/taxonomy.d.ts +158 -0
- package/dist/events/taxonomy.d.ts.map +1 -0
- package/dist/events/taxonomy.js +99 -0
- package/dist/events/taxonomy.test.d.ts +2 -0
- package/dist/events/taxonomy.test.d.ts.map +1 -0
- package/dist/events/taxonomy.test.js +48 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/indexer/contract.d.ts +203 -0
- package/dist/indexer/contract.d.ts.map +1 -0
- package/dist/indexer/contract.js +16 -0
- package/dist/indexer/typesense-search-query.d.ts +31 -0
- package/dist/indexer/typesense-search-query.d.ts.map +1 -0
- package/dist/indexer/typesense-search-query.js +185 -0
- package/dist/indexer/typesense.d.ts +105 -0
- package/dist/indexer/typesense.d.ts.map +1 -0
- package/dist/indexer/typesense.js +394 -0
- package/dist/indexer/typesense.test.d.ts +2 -0
- package/dist/indexer/typesense.test.d.ts.map +1 -0
- package/dist/indexer/typesense.test.js +253 -0
- package/dist/overlay/resolver.d.ts +101 -0
- package/dist/overlay/resolver.d.ts.map +1 -0
- package/dist/overlay/resolver.js +167 -0
- package/dist/overlay/resolver.test.d.ts +2 -0
- package/dist/overlay/resolver.test.d.ts.map +1 -0
- package/dist/overlay/resolver.test.js +179 -0
- package/dist/overlay/schema.d.ts +266 -0
- package/dist/overlay/schema.d.ts.map +1 -0
- package/dist/overlay/schema.js +71 -0
- package/dist/provenance.d.ts +2 -0
- package/dist/provenance.d.ts.map +1 -0
- package/dist/provenance.js +1 -0
- package/dist/schema-sourced-entries.d.ts +344 -0
- package/dist/schema-sourced-entries.d.ts.map +1 -0
- package/dist/schema-sourced-entries.js +75 -0
- package/dist/schema.d.ts +21 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +20 -0
- package/dist/search/federate.d.ts +58 -0
- package/dist/search/federate.d.ts.map +1 -0
- package/dist/search/federate.js +103 -0
- package/dist/search/federate.test.d.ts +2 -0
- package/dist/search/federate.test.d.ts.map +1 -0
- package/dist/search/federate.test.js +146 -0
- package/dist/search/rerank.d.ts +77 -0
- package/dist/search/rerank.d.ts.map +1 -0
- package/dist/search/rerank.js +68 -0
- package/dist/search/rerank.test.d.ts +2 -0
- package/dist/search/rerank.test.d.ts.map +1 -0
- package/dist/search/rerank.test.js +60 -0
- package/dist/search/routes.d.ts +144 -0
- package/dist/search/routes.d.ts.map +1 -0
- package/dist/search/routes.js +288 -0
- package/dist/search/routes.test.d.ts +2 -0
- package/dist/search/routes.test.d.ts.map +1 -0
- package/dist/search/routes.test.js +322 -0
- package/dist/search/semantic.d.ts +63 -0
- package/dist/search/semantic.d.ts.map +1 -0
- package/dist/search/semantic.js +75 -0
- package/dist/search/semantic.test.d.ts +2 -0
- package/dist/search/semantic.test.d.ts.map +1 -0
- package/dist/search/semantic.test.js +143 -0
- package/dist/services/build-indexer-document.test.d.ts +2 -0
- package/dist/services/build-indexer-document.test.d.ts.map +1 -0
- package/dist/services/build-indexer-document.test.js +102 -0
- package/dist/services/content-service.d.ts +125 -0
- package/dist/services/content-service.d.ts.map +1 -0
- package/dist/services/content-service.js +139 -0
- package/dist/services/content-service.test.d.ts +2 -0
- package/dist/services/content-service.test.d.ts.map +1 -0
- package/dist/services/content-service.test.js +322 -0
- package/dist/services/indexer-service.d.ts +109 -0
- package/dist/services/indexer-service.d.ts.map +1 -0
- package/dist/services/indexer-service.js +123 -0
- package/dist/services/indexer-service.test.d.ts +2 -0
- package/dist/services/indexer-service.test.d.ts.map +1 -0
- package/dist/services/indexer-service.test.js +176 -0
- package/dist/services/overlay-service.d.ts +108 -0
- package/dist/services/overlay-service.d.ts.map +1 -0
- package/dist/services/overlay-service.js +211 -0
- package/dist/services/overlay-service.test.d.ts +2 -0
- package/dist/services/overlay-service.test.d.ts.map +1 -0
- package/dist/services/overlay-service.test.js +79 -0
- package/dist/services/snapshot-builder.test.d.ts +2 -0
- package/dist/services/snapshot-builder.test.d.ts.map +1 -0
- package/dist/services/snapshot-builder.test.js +93 -0
- package/dist/services/snapshot-service.d.ts +78 -0
- package/dist/services/snapshot-service.d.ts.map +1 -0
- package/dist/services/snapshot-service.js +165 -0
- package/dist/services/sourced-entry-service.d.ts +142 -0
- package/dist/services/sourced-entry-service.d.ts.map +1 -0
- package/dist/services/sourced-entry-service.js +203 -0
- package/dist/services/sourced-entry-service.test.d.ts +10 -0
- package/dist/services/sourced-entry-service.test.d.ts.map +1 -0
- package/dist/services/sourced-entry-service.test.js +66 -0
- package/dist/snapshot/schema.d.ts +362 -0
- package/dist/snapshot/schema.d.ts.map +1 -0
- package/dist/snapshot/schema.js +102 -0
- package/package.json +210 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { handleApiError } from "@voyant-travel/hono";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createCatalogSearchHonoModule, createCatalogSearchRoutes, } from "./routes.js";
|
|
5
|
+
const emptyResults = { total: 0, hits: [], facets: {} };
|
|
6
|
+
function createIndexer(search = async () => emptyResults) {
|
|
7
|
+
return {
|
|
8
|
+
capabilities: {
|
|
9
|
+
supportsKeywordSearch: true,
|
|
10
|
+
supportsHybridSearch: true,
|
|
11
|
+
supportsVectorFields: true,
|
|
12
|
+
vectorDimensions: 3,
|
|
13
|
+
maxVectorsPerDocument: null,
|
|
14
|
+
supportsCrossAudienceFederation: false,
|
|
15
|
+
supportsAdminDenormalization: true,
|
|
16
|
+
},
|
|
17
|
+
ensureCollection: async () => { },
|
|
18
|
+
upsert: async () => { },
|
|
19
|
+
delete: async () => { },
|
|
20
|
+
search,
|
|
21
|
+
bulkReindex: async () => { },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function routeApp(options) {
|
|
25
|
+
const app = new Hono();
|
|
26
|
+
app.onError(handleApiError);
|
|
27
|
+
app.route("/v1/admin/catalog", createCatalogSearchRoutes(options));
|
|
28
|
+
return app;
|
|
29
|
+
}
|
|
30
|
+
describe("createCatalogSearchRoutes", () => {
|
|
31
|
+
it("exposes admin and public search routes through the Hono module wrapper", () => {
|
|
32
|
+
const module = createCatalogSearchHonoModule({
|
|
33
|
+
resolveRuntime: () => ({
|
|
34
|
+
indexer: createIndexer(),
|
|
35
|
+
defaultScope: { locale: "en-GB", audience: "staff", market: "default" },
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
expect(module.module.name).toBe("catalog");
|
|
39
|
+
expect(module.adminRoutes).toBeTruthy();
|
|
40
|
+
expect(module.publicRoutes).toBeTruthy();
|
|
41
|
+
});
|
|
42
|
+
it("requires a vertical", async () => {
|
|
43
|
+
const app = routeApp({
|
|
44
|
+
surface: "admin",
|
|
45
|
+
resolveRuntime: () => ({
|
|
46
|
+
indexer: createIndexer(),
|
|
47
|
+
defaultScope: { locale: "en-GB", audience: "staff", market: "default" },
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
const response = await app.request("/v1/admin/catalog/search", {
|
|
51
|
+
method: "POST",
|
|
52
|
+
body: JSON.stringify({ query: "rome" }),
|
|
53
|
+
});
|
|
54
|
+
expect(response.status).toBe(400);
|
|
55
|
+
await expect(response.json()).resolves.toEqual({ error: "vertical is required" });
|
|
56
|
+
});
|
|
57
|
+
it("returns shared validation errors for invalid search bodies", async () => {
|
|
58
|
+
const executeSearch = vi.fn(async (_input) => emptyResults);
|
|
59
|
+
const app = routeApp({
|
|
60
|
+
surface: "admin",
|
|
61
|
+
resolveRuntime: () => ({
|
|
62
|
+
indexer: createIndexer(),
|
|
63
|
+
defaultScope: { locale: "en-GB", audience: "staff", market: "default" },
|
|
64
|
+
}),
|
|
65
|
+
executeSearch,
|
|
66
|
+
});
|
|
67
|
+
const response = await app.request("/v1/admin/catalog/search", {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: { "content-type": "application/json" },
|
|
70
|
+
body: JSON.stringify({
|
|
71
|
+
vertical: "products",
|
|
72
|
+
mode: "unknown",
|
|
73
|
+
pagination: { limit: "10" },
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
expect(response.status).toBe(400);
|
|
77
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
78
|
+
code: "invalid_request",
|
|
79
|
+
});
|
|
80
|
+
expect(executeSearch).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
it("returns a deployment error when no indexer is configured", async () => {
|
|
83
|
+
const app = routeApp({
|
|
84
|
+
surface: "admin",
|
|
85
|
+
resolveRuntime: () => ({
|
|
86
|
+
defaultScope: { locale: "en-GB", audience: "staff", market: "default" },
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
const response = await app.request("/v1/admin/catalog/search", {
|
|
90
|
+
method: "POST",
|
|
91
|
+
body: JSON.stringify({ vertical: "products" }),
|
|
92
|
+
});
|
|
93
|
+
expect(response.status).toBe(503);
|
|
94
|
+
await expect(response.json()).resolves.toEqual({
|
|
95
|
+
error: "Search indexer is not configured (missing TYPESENSE_HOST)",
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
it("uses the admin default audience and the customer public audience", async () => {
|
|
99
|
+
const executeSearch = vi.fn(async (_input) => emptyResults);
|
|
100
|
+
const module = createCatalogSearchHonoModule({
|
|
101
|
+
resolveRuntime: () => ({
|
|
102
|
+
indexer: createIndexer(),
|
|
103
|
+
defaultScope: { locale: "en-GB", audience: "staff", market: "default" },
|
|
104
|
+
}),
|
|
105
|
+
executeSearch,
|
|
106
|
+
});
|
|
107
|
+
const app = new Hono();
|
|
108
|
+
app.route("/v1/admin/catalog", module.adminRoutes);
|
|
109
|
+
app.route("/v1/public/catalog", module.publicRoutes);
|
|
110
|
+
await app.request("/v1/admin/catalog/search", {
|
|
111
|
+
method: "POST",
|
|
112
|
+
body: JSON.stringify({ vertical: "products", mode: "keyword" }),
|
|
113
|
+
});
|
|
114
|
+
await app.request("/v1/public/catalog/search", {
|
|
115
|
+
method: "POST",
|
|
116
|
+
body: JSON.stringify({ vertical: "products", mode: "keyword", locale: "ro-RO" }),
|
|
117
|
+
});
|
|
118
|
+
expect(executeSearch.mock.calls[0]?.[0].slice).toEqual({
|
|
119
|
+
vertical: "products",
|
|
120
|
+
locale: "en-GB",
|
|
121
|
+
audience: "staff",
|
|
122
|
+
market: "default",
|
|
123
|
+
});
|
|
124
|
+
expect(executeSearch.mock.calls[1]?.[0].slice).toEqual({
|
|
125
|
+
vertical: "products",
|
|
126
|
+
locale: "ro-RO",
|
|
127
|
+
audience: "customer",
|
|
128
|
+
market: "default",
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
it("downgrades semantic modes to keyword when embeddings are unavailable", async () => {
|
|
132
|
+
const executeSearch = vi.fn(async (_input) => emptyResults);
|
|
133
|
+
const app = routeApp({
|
|
134
|
+
surface: "admin",
|
|
135
|
+
resolveRuntime: () => ({
|
|
136
|
+
indexer: createIndexer(),
|
|
137
|
+
defaultScope: { locale: "en-GB", audience: "staff", market: "default" },
|
|
138
|
+
}),
|
|
139
|
+
executeSearch,
|
|
140
|
+
});
|
|
141
|
+
const response = await app.request("/v1/admin/catalog/search", {
|
|
142
|
+
method: "POST",
|
|
143
|
+
body: JSON.stringify({ vertical: "products", mode: "hybrid", query: "rome" }),
|
|
144
|
+
});
|
|
145
|
+
expect(response.status).toBe(200);
|
|
146
|
+
await expect(response.json()).resolves.toMatchObject({ mode: "keyword" });
|
|
147
|
+
expect(executeSearch).toHaveBeenCalledWith(expect.objectContaining({
|
|
148
|
+
request: expect.objectContaining({ mode: "keyword" }),
|
|
149
|
+
}));
|
|
150
|
+
});
|
|
151
|
+
it("passes typed storefront sort options into the search request", async () => {
|
|
152
|
+
const executeSearch = vi.fn(async (_input) => emptyResults);
|
|
153
|
+
const app = routeApp({
|
|
154
|
+
surface: "public",
|
|
155
|
+
resolveRuntime: () => ({
|
|
156
|
+
indexer: createIndexer(),
|
|
157
|
+
defaultScope: { locale: "en-GB", audience: "staff", market: "default" },
|
|
158
|
+
}),
|
|
159
|
+
executeSearch,
|
|
160
|
+
});
|
|
161
|
+
const response = await app.request("/v1/admin/catalog/search", {
|
|
162
|
+
method: "POST",
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
vertical: "products",
|
|
165
|
+
mode: "keyword",
|
|
166
|
+
sort: "price-asc",
|
|
167
|
+
pagination: { limit: 12 },
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
170
|
+
expect(response.status).toBe(200);
|
|
171
|
+
expect(executeSearch).toHaveBeenCalledWith(expect.objectContaining({
|
|
172
|
+
request: expect.objectContaining({
|
|
173
|
+
sort: "price-asc",
|
|
174
|
+
pagination: { limit: 12 },
|
|
175
|
+
}),
|
|
176
|
+
}));
|
|
177
|
+
});
|
|
178
|
+
it("projects storefront cards from indexed fields when requested", async () => {
|
|
179
|
+
const executeSearch = vi.fn(async (_input) => ({
|
|
180
|
+
total: 1,
|
|
181
|
+
facets: {
|
|
182
|
+
"categorySlugs[]": [{ value: "cruises", count: 4 }],
|
|
183
|
+
},
|
|
184
|
+
hits: [
|
|
185
|
+
{
|
|
186
|
+
id: "prod_abc",
|
|
187
|
+
score: 12,
|
|
188
|
+
document: {
|
|
189
|
+
id: "prod_abc",
|
|
190
|
+
fields: {
|
|
191
|
+
name: "Danube Cruise",
|
|
192
|
+
slug: "danube-cruise",
|
|
193
|
+
primaryCategoryId: "cat_cruises",
|
|
194
|
+
primaryCategoryName: "Cruises",
|
|
195
|
+
primaryCategorySlug: "cruises",
|
|
196
|
+
thumbnailUrl: "https://cdn.example/thumb.jpg",
|
|
197
|
+
coverMediaUrl: "https://cdn.example/cover.jpg",
|
|
198
|
+
priceFromAmountCents: 125000,
|
|
199
|
+
priceFromCurrency: "EUR",
|
|
200
|
+
originalPriceFromAmountCents: 150000,
|
|
201
|
+
hasOffer: true,
|
|
202
|
+
bestOfferId: "offer_spring",
|
|
203
|
+
bestOfferName: "Spring Sale",
|
|
204
|
+
bestOfferDiscountKind: "percentage",
|
|
205
|
+
bestOfferDiscountPercent: 15,
|
|
206
|
+
upcomingDepartureCount: 3,
|
|
207
|
+
nextDepartureAt: "2026-06-01T09:00:00Z",
|
|
208
|
+
nextDepartureDate: "2026-06-01",
|
|
209
|
+
"departureMonths[]": ["2026-06", "2026-07"],
|
|
210
|
+
"departureDates[]": ["2026-06-01", "2026-07-15"],
|
|
211
|
+
"regions[]": ["Europe"],
|
|
212
|
+
"countries[]": ["Romania"],
|
|
213
|
+
"cities[]": ["Tulcea"],
|
|
214
|
+
"destinationIds[]": ["dest_ro"],
|
|
215
|
+
"destinationSlugs[]": ["romania"],
|
|
216
|
+
latitude: 45.18,
|
|
217
|
+
longitude: 28.8,
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
}));
|
|
223
|
+
const module = createCatalogSearchHonoModule({
|
|
224
|
+
resolveRuntime: () => ({
|
|
225
|
+
indexer: createIndexer(),
|
|
226
|
+
defaultScope: { locale: "en-GB", audience: "staff", market: "default" },
|
|
227
|
+
}),
|
|
228
|
+
executeSearch,
|
|
229
|
+
});
|
|
230
|
+
const app = new Hono();
|
|
231
|
+
app.route("/v1/public/catalog", module.publicRoutes);
|
|
232
|
+
const response = await app.request("/v1/public/catalog/search", {
|
|
233
|
+
method: "POST",
|
|
234
|
+
body: JSON.stringify({
|
|
235
|
+
vertical: "products",
|
|
236
|
+
mode: "keyword",
|
|
237
|
+
projection: "storefront-card",
|
|
238
|
+
pagination: { limit: 12 },
|
|
239
|
+
}),
|
|
240
|
+
});
|
|
241
|
+
expect(response.status).toBe(200);
|
|
242
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
243
|
+
total: 1,
|
|
244
|
+
facets: {
|
|
245
|
+
"categorySlugs[]": [{ value: "cruises", count: 4 }],
|
|
246
|
+
},
|
|
247
|
+
cards: [
|
|
248
|
+
{
|
|
249
|
+
id: "prod_abc",
|
|
250
|
+
name: "Danube Cruise",
|
|
251
|
+
slug: "danube-cruise",
|
|
252
|
+
primaryCategory: {
|
|
253
|
+
id: "cat_cruises",
|
|
254
|
+
name: "Cruises",
|
|
255
|
+
slug: "cruises",
|
|
256
|
+
},
|
|
257
|
+
media: {
|
|
258
|
+
thumbnailUrl: "https://cdn.example/thumb.jpg",
|
|
259
|
+
coverMediaUrl: "https://cdn.example/cover.jpg",
|
|
260
|
+
},
|
|
261
|
+
priceFrom: {
|
|
262
|
+
amountCents: 125000,
|
|
263
|
+
currency: "EUR",
|
|
264
|
+
originalAmountCents: 150000,
|
|
265
|
+
},
|
|
266
|
+
offerBadges: [
|
|
267
|
+
{
|
|
268
|
+
id: "offer_spring",
|
|
269
|
+
name: "Spring Sale",
|
|
270
|
+
discountKind: "percentage",
|
|
271
|
+
discountPercent: 15,
|
|
272
|
+
discountAmountCents: null,
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
departures: {
|
|
276
|
+
upcomingCount: 3,
|
|
277
|
+
nextDepartureAt: "2026-06-01T09:00:00Z",
|
|
278
|
+
nextDepartureDate: "2026-06-01",
|
|
279
|
+
months: ["2026-06", "2026-07"],
|
|
280
|
+
dates: ["2026-06-01", "2026-07-15"],
|
|
281
|
+
},
|
|
282
|
+
destinations: {
|
|
283
|
+
regions: ["Europe"],
|
|
284
|
+
countries: ["Romania"],
|
|
285
|
+
cities: ["Tulcea"],
|
|
286
|
+
ids: ["dest_ro"],
|
|
287
|
+
slugs: ["romania"],
|
|
288
|
+
},
|
|
289
|
+
coordinates: {
|
|
290
|
+
latitude: 45.18,
|
|
291
|
+
longitude: 28.8,
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
it("retries hybrid searches as keyword when semantic execution fails", async () => {
|
|
298
|
+
const executeSearch = vi
|
|
299
|
+
.fn(async (_input) => emptyResults)
|
|
300
|
+
.mockRejectedValueOnce(new Error("embedding provider unavailable"))
|
|
301
|
+
.mockResolvedValueOnce(emptyResults);
|
|
302
|
+
const app = routeApp({
|
|
303
|
+
surface: "admin",
|
|
304
|
+
resolveRuntime: () => ({
|
|
305
|
+
indexer: createIndexer(),
|
|
306
|
+
embeddings: { kind: "test-embeddings" },
|
|
307
|
+
defaultScope: { locale: "en-GB", audience: "staff", market: "default" },
|
|
308
|
+
}),
|
|
309
|
+
executeSearch,
|
|
310
|
+
});
|
|
311
|
+
const response = await app.request("/v1/admin/catalog/search", {
|
|
312
|
+
method: "POST",
|
|
313
|
+
body: JSON.stringify({ vertical: "products", mode: "hybrid", query: "rome" }),
|
|
314
|
+
});
|
|
315
|
+
expect(response.status).toBe(200);
|
|
316
|
+
await expect(response.json()).resolves.toMatchObject({ mode: "keyword" });
|
|
317
|
+
expect(executeSearch.mock.calls.map(([input]) => input.request.mode)).toEqual([
|
|
318
|
+
"hybrid",
|
|
319
|
+
"keyword",
|
|
320
|
+
]);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic / hybrid search orchestration.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the Phase 1 `IndexerAdapter.search` with the embedding-generation
|
|
5
|
+
* step. When `mode: "semantic" | "hybrid"`, this helper embeds the query
|
|
6
|
+
* via the configured `EmbeddingProvider`, attaches the vector to the
|
|
7
|
+
* `SearchRequest` as `query_embedding`, and delegates to the adapter.
|
|
8
|
+
*
|
|
9
|
+
* Callers that already have a query embedding (an upstream agent that
|
|
10
|
+
* vectorized the user's intent) can skip embedding by passing
|
|
11
|
+
* `query_embedding` directly — `executeSemanticSearch` honors a
|
|
12
|
+
* caller-supplied vector and skips the embed call.
|
|
13
|
+
*
|
|
14
|
+
* See `docs/architecture/catalog-architecture.md` for the design.
|
|
15
|
+
*/
|
|
16
|
+
import type { EmbeddingProvider } from "../embeddings/contract.js";
|
|
17
|
+
import type { IndexerAdapter, IndexerSlice, SearchRequest, SearchResults } from "../indexer/contract.js";
|
|
18
|
+
export interface SemanticSearchOptions {
|
|
19
|
+
/** Adapter to query. */
|
|
20
|
+
adapter: IndexerAdapter;
|
|
21
|
+
/**
|
|
22
|
+
* Embedding provider — used to vectorize the query string when the mode
|
|
23
|
+
* needs vectors. Optional: callers running pure-keyword searches can omit
|
|
24
|
+
* this. `executeSemanticSearch` throws a clear error if mode is
|
|
25
|
+
* `semantic` / `hybrid` and no provider is configured.
|
|
26
|
+
*/
|
|
27
|
+
embeddings?: EmbeddingProvider;
|
|
28
|
+
/** The variant slice (vertical, locale, audience, market) to search. */
|
|
29
|
+
slice: IndexerSlice;
|
|
30
|
+
/** The search request. `mode` controls keyword/hybrid/semantic blending. */
|
|
31
|
+
request: SearchRequest;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Run a search request that may need a query embedding generated.
|
|
35
|
+
*
|
|
36
|
+
* Behavior by mode:
|
|
37
|
+
* - `keyword` — adapter.search called directly; no embedding work.
|
|
38
|
+
* - `hybrid` — query string is embedded (unless caller supplied
|
|
39
|
+
* `query_embedding`), adapter blends keyword + vector
|
|
40
|
+
* scores.
|
|
41
|
+
* - `semantic` — query string is embedded, adapter does pure vector
|
|
42
|
+
* similarity. (Engines that don't support pure-semantic
|
|
43
|
+
* typically fall back to hybrid with the keyword weight
|
|
44
|
+
* set very low.)
|
|
45
|
+
*
|
|
46
|
+
* Verifies adapter capabilities at runtime: requesting `semantic` /
|
|
47
|
+
* `hybrid` against an adapter without `supportsVectorFields` throws
|
|
48
|
+
* a clear error rather than silently degrading to keyword-only.
|
|
49
|
+
*/
|
|
50
|
+
export declare function executeSemanticSearch(options: SemanticSearchOptions): Promise<SearchResults>;
|
|
51
|
+
/**
|
|
52
|
+
* Helper for callers (typically AI agents) that have already vectorized
|
|
53
|
+
* a query upstream and want to bypass the embedding step entirely. The
|
|
54
|
+
* vector is attached to the request as-is.
|
|
55
|
+
*/
|
|
56
|
+
export declare function executeBYOVectorSearch(options: {
|
|
57
|
+
adapter: IndexerAdapter;
|
|
58
|
+
slice: IndexerSlice;
|
|
59
|
+
request: SearchRequest & {
|
|
60
|
+
query_embedding: number[];
|
|
61
|
+
};
|
|
62
|
+
}): Promise<SearchResults>;
|
|
63
|
+
//# sourceMappingURL=semantic.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"semantic.d.ts","sourceRoot":"","sources":["../../src/search/semantic.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAA;AAClE,OAAO,KAAK,EACV,cAAc,EACd,YAAY,EACZ,aAAa,EACb,aAAa,EACd,MAAM,wBAAwB,CAAA;AAE/B,MAAM,WAAW,qBAAqB;IACpC,wBAAwB;IACxB,OAAO,EAAE,cAAc,CAAA;IACvB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,iBAAiB,CAAA;IAC9B,wEAAwE;IACxE,KAAK,EAAE,YAAY,CAAA;IACnB,4EAA4E;IAC5E,OAAO,EAAE,aAAa,CAAA;CACvB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,aAAa,CAAC,CAyCxB;AAED;;;;GAIG;AACH,wBAAsB,sBAAsB,CAAC,OAAO,EAAE;IACpD,OAAO,EAAE,cAAc,CAAA;IACvB,KAAK,EAAE,YAAY,CAAA;IACnB,OAAO,EAAE,aAAa,GAAG;QAAE,eAAe,EAAE,MAAM,EAAE,CAAA;KAAE,CAAA;CACvD,GAAG,OAAO,CAAC,aAAa,CAAC,CAQzB"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic / hybrid search orchestration.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the Phase 1 `IndexerAdapter.search` with the embedding-generation
|
|
5
|
+
* step. When `mode: "semantic" | "hybrid"`, this helper embeds the query
|
|
6
|
+
* via the configured `EmbeddingProvider`, attaches the vector to the
|
|
7
|
+
* `SearchRequest` as `query_embedding`, and delegates to the adapter.
|
|
8
|
+
*
|
|
9
|
+
* Callers that already have a query embedding (an upstream agent that
|
|
10
|
+
* vectorized the user's intent) can skip embedding by passing
|
|
11
|
+
* `query_embedding` directly — `executeSemanticSearch` honors a
|
|
12
|
+
* caller-supplied vector and skips the embed call.
|
|
13
|
+
*
|
|
14
|
+
* See `docs/architecture/catalog-architecture.md` for the design.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Run a search request that may need a query embedding generated.
|
|
18
|
+
*
|
|
19
|
+
* Behavior by mode:
|
|
20
|
+
* - `keyword` — adapter.search called directly; no embedding work.
|
|
21
|
+
* - `hybrid` — query string is embedded (unless caller supplied
|
|
22
|
+
* `query_embedding`), adapter blends keyword + vector
|
|
23
|
+
* scores.
|
|
24
|
+
* - `semantic` — query string is embedded, adapter does pure vector
|
|
25
|
+
* similarity. (Engines that don't support pure-semantic
|
|
26
|
+
* typically fall back to hybrid with the keyword weight
|
|
27
|
+
* set very low.)
|
|
28
|
+
*
|
|
29
|
+
* Verifies adapter capabilities at runtime: requesting `semantic` /
|
|
30
|
+
* `hybrid` against an adapter without `supportsVectorFields` throws
|
|
31
|
+
* a clear error rather than silently degrading to keyword-only.
|
|
32
|
+
*/
|
|
33
|
+
export async function executeSemanticSearch(options) {
|
|
34
|
+
const { adapter, embeddings, slice, request } = options;
|
|
35
|
+
if (request.mode === "keyword") {
|
|
36
|
+
return adapter.search(slice, request);
|
|
37
|
+
}
|
|
38
|
+
if (!adapter.capabilities.supportsVectorFields) {
|
|
39
|
+
throw new Error(`Search mode "${request.mode}" requires an indexer that supports vector fields. ` +
|
|
40
|
+
`Configured adapter does not declare supportsVectorFields. Use mode: "keyword" or swap to a vector-capable adapter (e.g. Typesense).`);
|
|
41
|
+
}
|
|
42
|
+
if (request.mode === "hybrid" && !adapter.capabilities.supportsHybridSearch) {
|
|
43
|
+
throw new Error(`Search mode "hybrid" requires an indexer that declares supportsHybridSearch. ` +
|
|
44
|
+
`Configured adapter does not. Either use mode: "semantic" (pure vector) or swap to a hybrid-capable engine.`);
|
|
45
|
+
}
|
|
46
|
+
// Use caller-supplied embedding if provided; otherwise generate one.
|
|
47
|
+
let queryEmbedding = request.query_embedding;
|
|
48
|
+
if (!queryEmbedding && request.query.length > 0) {
|
|
49
|
+
if (!embeddings) {
|
|
50
|
+
throw new Error(`Search mode "${request.mode}" requires an EmbeddingProvider to vectorize the query. ` +
|
|
51
|
+
`Configure one (e.g. createGeminiEmbeddingProvider) or supply request.query_embedding directly.`);
|
|
52
|
+
}
|
|
53
|
+
const [vector] = await embeddings.embed([request.query]);
|
|
54
|
+
if (!vector) {
|
|
55
|
+
throw new Error("EmbeddingProvider returned no vector for the query string");
|
|
56
|
+
}
|
|
57
|
+
queryEmbedding = vector;
|
|
58
|
+
}
|
|
59
|
+
return adapter.search(slice, {
|
|
60
|
+
...request,
|
|
61
|
+
query_embedding: queryEmbedding,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Helper for callers (typically AI agents) that have already vectorized
|
|
66
|
+
* a query upstream and want to bypass the embedding step entirely. The
|
|
67
|
+
* vector is attached to the request as-is.
|
|
68
|
+
*/
|
|
69
|
+
export async function executeBYOVectorSearch(options) {
|
|
70
|
+
const { adapter, slice, request } = options;
|
|
71
|
+
if (!adapter.capabilities.supportsVectorFields) {
|
|
72
|
+
throw new Error("BYO-vector search requires an indexer with supportsVectorFields. The configured adapter does not.");
|
|
73
|
+
}
|
|
74
|
+
return adapter.search(slice, request);
|
|
75
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"semantic.test.d.ts","sourceRoot":"","sources":["../../src/search/semantic.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { executeBYOVectorSearch, executeSemanticSearch } from "./semantic.js";
|
|
3
|
+
function makeAdapter(capabilities = {}) {
|
|
4
|
+
const baseCapabilities = {
|
|
5
|
+
supportsKeywordSearch: true,
|
|
6
|
+
supportsHybridSearch: true,
|
|
7
|
+
supportsVectorFields: true,
|
|
8
|
+
vectorDimensions: 1536,
|
|
9
|
+
maxVectorsPerDocument: null,
|
|
10
|
+
supportsCrossAudienceFederation: false,
|
|
11
|
+
supportsAdminDenormalization: true,
|
|
12
|
+
...capabilities,
|
|
13
|
+
};
|
|
14
|
+
const searchSpy = vi.fn(async (_slice, _request) => ({
|
|
15
|
+
hits: [],
|
|
16
|
+
total: 0,
|
|
17
|
+
}));
|
|
18
|
+
return {
|
|
19
|
+
capabilities: baseCapabilities,
|
|
20
|
+
async ensureCollection() { },
|
|
21
|
+
async upsert() { },
|
|
22
|
+
async delete() { },
|
|
23
|
+
async bulkReindex() { },
|
|
24
|
+
search: searchSpy,
|
|
25
|
+
searchSpy,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function makeEmbeddings() {
|
|
29
|
+
const embedSpy = vi.fn(async (texts) => texts.map(() => [0.1, 0.2, 0.3]));
|
|
30
|
+
return {
|
|
31
|
+
capabilities: {
|
|
32
|
+
modelId: "test/v1",
|
|
33
|
+
dimensions: 3,
|
|
34
|
+
maxTokensPerInput: 1000,
|
|
35
|
+
maxBatchSize: 100,
|
|
36
|
+
},
|
|
37
|
+
embed: embedSpy,
|
|
38
|
+
embedSpy,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const slice = {
|
|
42
|
+
vertical: "products",
|
|
43
|
+
locale: "en-GB",
|
|
44
|
+
audience: "customer",
|
|
45
|
+
market: "default",
|
|
46
|
+
};
|
|
47
|
+
describe("executeSemanticSearch", () => {
|
|
48
|
+
it("keyword mode delegates directly without embedding", async () => {
|
|
49
|
+
const adapter = makeAdapter();
|
|
50
|
+
const embeddings = makeEmbeddings();
|
|
51
|
+
await executeSemanticSearch({
|
|
52
|
+
adapter,
|
|
53
|
+
embeddings,
|
|
54
|
+
slice,
|
|
55
|
+
request: { query: "wellness", mode: "keyword" },
|
|
56
|
+
});
|
|
57
|
+
expect(embeddings.embedSpy).not.toHaveBeenCalled();
|
|
58
|
+
expect(adapter.searchSpy).toHaveBeenCalledTimes(1);
|
|
59
|
+
});
|
|
60
|
+
it("semantic mode embeds the query and attaches the vector", async () => {
|
|
61
|
+
const adapter = makeAdapter();
|
|
62
|
+
const embeddings = makeEmbeddings();
|
|
63
|
+
await executeSemanticSearch({
|
|
64
|
+
adapter,
|
|
65
|
+
embeddings,
|
|
66
|
+
slice,
|
|
67
|
+
request: { query: "wellness", mode: "semantic" },
|
|
68
|
+
});
|
|
69
|
+
expect(embeddings.embedSpy).toHaveBeenCalledWith(["wellness"]);
|
|
70
|
+
const [, request] = adapter.searchSpy.mock.calls[0];
|
|
71
|
+
expect(request.query_embedding).toEqual([0.1, 0.2, 0.3]);
|
|
72
|
+
});
|
|
73
|
+
it("hybrid mode embeds the query when caller didn't supply one", async () => {
|
|
74
|
+
const adapter = makeAdapter();
|
|
75
|
+
const embeddings = makeEmbeddings();
|
|
76
|
+
await executeSemanticSearch({
|
|
77
|
+
adapter,
|
|
78
|
+
embeddings,
|
|
79
|
+
slice,
|
|
80
|
+
request: { query: "wellness", mode: "hybrid" },
|
|
81
|
+
});
|
|
82
|
+
expect(embeddings.embedSpy).toHaveBeenCalledTimes(1);
|
|
83
|
+
});
|
|
84
|
+
it("honors caller-supplied query_embedding without re-embedding", async () => {
|
|
85
|
+
const adapter = makeAdapter();
|
|
86
|
+
const embeddings = makeEmbeddings();
|
|
87
|
+
const caller_vector = [0.9, 0.8, 0.7];
|
|
88
|
+
await executeSemanticSearch({
|
|
89
|
+
adapter,
|
|
90
|
+
embeddings,
|
|
91
|
+
slice,
|
|
92
|
+
request: {
|
|
93
|
+
query: "wellness",
|
|
94
|
+
mode: "semantic",
|
|
95
|
+
query_embedding: caller_vector,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
expect(embeddings.embedSpy).not.toHaveBeenCalled();
|
|
99
|
+
const [, request] = adapter.searchSpy.mock.calls[0];
|
|
100
|
+
expect(request.query_embedding).toBe(caller_vector);
|
|
101
|
+
});
|
|
102
|
+
it("throws when semantic mode is requested but adapter doesn't support vectors", async () => {
|
|
103
|
+
const adapter = makeAdapter({ supportsVectorFields: false });
|
|
104
|
+
const embeddings = makeEmbeddings();
|
|
105
|
+
await expect(executeSemanticSearch({
|
|
106
|
+
adapter,
|
|
107
|
+
embeddings,
|
|
108
|
+
slice,
|
|
109
|
+
request: { query: "x", mode: "semantic" },
|
|
110
|
+
})).rejects.toThrow(/supports vector fields/);
|
|
111
|
+
});
|
|
112
|
+
it("throws when hybrid mode is requested but adapter doesn't support hybrid", async () => {
|
|
113
|
+
const adapter = makeAdapter({ supportsVectorFields: true, supportsHybridSearch: false });
|
|
114
|
+
const embeddings = makeEmbeddings();
|
|
115
|
+
await expect(executeSemanticSearch({
|
|
116
|
+
adapter,
|
|
117
|
+
embeddings,
|
|
118
|
+
slice,
|
|
119
|
+
request: { query: "x", mode: "hybrid" },
|
|
120
|
+
})).rejects.toThrow(/supportsHybridSearch/);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
describe("executeBYOVectorSearch", () => {
|
|
124
|
+
it("attaches the caller-supplied vector and delegates without embedding", async () => {
|
|
125
|
+
const adapter = makeAdapter();
|
|
126
|
+
const vector = [1, 2, 3];
|
|
127
|
+
await executeBYOVectorSearch({
|
|
128
|
+
adapter,
|
|
129
|
+
slice,
|
|
130
|
+
request: { query: "x", mode: "semantic", query_embedding: vector },
|
|
131
|
+
});
|
|
132
|
+
const [, request] = adapter.searchSpy.mock.calls[0];
|
|
133
|
+
expect(request.query_embedding).toBe(vector);
|
|
134
|
+
});
|
|
135
|
+
it("throws when adapter does not support vector fields", async () => {
|
|
136
|
+
const adapter = makeAdapter({ supportsVectorFields: false });
|
|
137
|
+
await expect(executeBYOVectorSearch({
|
|
138
|
+
adapter,
|
|
139
|
+
slice,
|
|
140
|
+
request: { query: "x", mode: "semantic", query_embedding: [1, 2, 3] },
|
|
141
|
+
})).rejects.toThrow(/supportsVectorFields/);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"build-indexer-document.test.d.ts","sourceRoot":"","sources":["../../src/services/build-indexer-document.test.ts"],"names":[],"mappings":""}
|