@dbx-tools/search 0.6.9
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 +297 -0
- package/index.ts +28 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.js +22 -0
- package/lib/src/client.d.ts +304 -0
- package/lib/src/client.js +598 -0
- package/lib/src/config.d.ts +154 -0
- package/lib/src/config.js +225 -0
- package/lib/src/index-tools.d.ts +19 -0
- package/lib/src/index-tools.js +34 -0
- package/lib/src/lakebase.d.ts +86 -0
- package/lib/src/lakebase.js +218 -0
- package/lib/src/plugin.d.ts +216 -0
- package/lib/src/plugin.js +506 -0
- package/lib/src/query.d.ts +64 -0
- package/lib/src/query.js +109 -0
- package/lib/src/runtime.d.ts +38 -0
- package/lib/src/runtime.js +38 -0
- package/lib/src/schema.d.ts +83 -0
- package/lib/src/schema.js +64 -0
- package/lib/src/tool.d.ts +116 -0
- package/lib/src/tool.js +145 -0
- package/lib/tsconfig.tsbuildinfo +1 -0
- package/package.json +65 -0
- package/src/client.ts +848 -0
- package/src/config.ts +356 -0
- package/src/index-tools.ts +40 -0
- package/src/lakebase.ts +299 -0
- package/src/plugin.ts +582 -0
- package/src/query.ts +127 -0
- package/src/runtime.ts +64 -0
- package/src/schema.ts +75 -0
- package/src/tool.ts +169 -0
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AppKit plugin (registered name: `search`) that turns a Databricks AI
|
|
3
|
+
* Search (Vector Search) index into a batteries-included search surface. It is
|
|
4
|
+
* the "shortcut" half of this package: register it with nothing but an index
|
|
5
|
+
* name and you get, all at once -
|
|
6
|
+
*
|
|
7
|
+
* - a `search` / `universal_search` / (opt-in) `add_documents` tool set for
|
|
8
|
+
* both Mastra and AppKit agents (autocomplete is a small-`limit` search);
|
|
9
|
+
* - HTTP routes under `/api/search` a browser search box calls directly
|
|
10
|
+
* (`POST /` search, `POST /universal` federated, `GET /indexes` catalogue,
|
|
11
|
+
* and `POST /documents` when writes are enabled);
|
|
12
|
+
* - a `clientConfig()` payload so a UI knows the indexes, default, and page
|
|
13
|
+
* size at boot with no round-trip;
|
|
14
|
+
* - `exports()` so app code can `appkit.search.search(...)` directly.
|
|
15
|
+
*
|
|
16
|
+
* Everything runs under the caller's OBO identity (routes wrap in `asUser`),
|
|
17
|
+
* so Unity Catalog ACLs on the index apply. Registering the plugin resolves
|
|
18
|
+
* and logs the effective config (default index, known indexes, page size,
|
|
19
|
+
* mode) so a misconfiguration shows up in the boot log rather than on the
|
|
20
|
+
* first search.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
lakebase,
|
|
27
|
+
Plugin,
|
|
28
|
+
ResourceType,
|
|
29
|
+
toPlugin,
|
|
30
|
+
type IAppRouter,
|
|
31
|
+
type PluginManifest,
|
|
32
|
+
type ResourceRequirement,
|
|
33
|
+
} from "@databricks/appkit";
|
|
34
|
+
import {
|
|
35
|
+
defineTool,
|
|
36
|
+
executeFromRegistry,
|
|
37
|
+
toolsFromRegistry,
|
|
38
|
+
type AgentToolDefinition,
|
|
39
|
+
type ToolProvider,
|
|
40
|
+
type ToolRegistry,
|
|
41
|
+
} from "@databricks/appkit/beta";
|
|
42
|
+
import { plugin as pluginLookup } from "@dbx-tools/appkit";
|
|
43
|
+
import { search as searchContract, type SearchClientConfig } from "@dbx-tools/shared-search";
|
|
44
|
+
import { error as errorUtil, log, string } from "@dbx-tools/shared-core";
|
|
45
|
+
import {
|
|
46
|
+
SEARCH_CONFIG_SCHEMA,
|
|
47
|
+
DATABRICKS_INDEX_ENV,
|
|
48
|
+
ENDPOINT_ENV,
|
|
49
|
+
INDEX_ENV,
|
|
50
|
+
resolveSearchConfig,
|
|
51
|
+
type SearchPluginConfig,
|
|
52
|
+
} from "./config.ts";
|
|
53
|
+
import { LakebaseSearchBackend } from "./lakebase.ts";
|
|
54
|
+
import { toCreateIndexOptions } from "./index-tools.ts";
|
|
55
|
+
import { toDocumentArray } from "./query.ts";
|
|
56
|
+
import { getSearchRuntime, resetSearchRuntime } from "./runtime.ts";
|
|
57
|
+
import {
|
|
58
|
+
ADD_DOCUMENTS_TOOL_DESCRIPTION,
|
|
59
|
+
CREATE_INDEX_TOOL_DESCRIPTION,
|
|
60
|
+
SEARCH_TOOL_DESCRIPTION,
|
|
61
|
+
SYNC_INDEX_TOOL_DESCRIPTION,
|
|
62
|
+
UNIVERSAL_SEARCH_TOOL_DESCRIPTION,
|
|
63
|
+
} from "./schema.ts";
|
|
64
|
+
|
|
65
|
+
const logger = log.logger("search");
|
|
66
|
+
|
|
67
|
+
/** Mount-relative route (under `/api/search`) for a single-index search. */
|
|
68
|
+
const SEARCH_ROUTE = "/";
|
|
69
|
+
|
|
70
|
+
/** Mount-relative route for a universal (federated) search across indexes. */
|
|
71
|
+
const UNIVERSAL_ROUTE = "/universal";
|
|
72
|
+
|
|
73
|
+
/** Mount-relative route serving the index catalogue a search box reads. */
|
|
74
|
+
const INDEXES_ROUTE = "/indexes";
|
|
75
|
+
|
|
76
|
+
/** Mount-relative route for adding documents to a direct-access index. */
|
|
77
|
+
const DOCUMENTS_ROUTE = "/documents";
|
|
78
|
+
|
|
79
|
+
/** Mount-relative route for creating a Vector Search index. */
|
|
80
|
+
const INDEX_ROUTE = "/index";
|
|
81
|
+
|
|
82
|
+
/** Mount-relative route for syncing a Delta Sync index from its source table. */
|
|
83
|
+
const INDEX_SYNC_ROUTE = "/index/sync";
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The AI Search index resource. Declared optional because the plugin is happy
|
|
87
|
+
* with no default index (a caller can name one per request); it is promoted to
|
|
88
|
+
* required once a deployment pins one. `SELECT` is the permission a query needs.
|
|
89
|
+
*/
|
|
90
|
+
const INDEX_RESOURCE = {
|
|
91
|
+
type: ResourceType.VECTOR_SEARCH_INDEX,
|
|
92
|
+
alias: "AI Search Index",
|
|
93
|
+
resourceKey: "search-index",
|
|
94
|
+
description:
|
|
95
|
+
"Databricks AI Search (Vector Search) index the app searches by default " +
|
|
96
|
+
"(catalog.schema.index). Optional: a request may name any index the caller can read.",
|
|
97
|
+
permission: "SELECT",
|
|
98
|
+
fields: {
|
|
99
|
+
name: {
|
|
100
|
+
env: INDEX_ENV,
|
|
101
|
+
description: `Default AI Search index. ${DATABRICKS_INDEX_ENV} is also honored.`,
|
|
102
|
+
discovery: {
|
|
103
|
+
type: "cli",
|
|
104
|
+
cliCommand:
|
|
105
|
+
"databricks vector-search-indexes list-indexes --endpoint-name <ENDPOINT> --output json",
|
|
106
|
+
selectField: ".name",
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
} satisfies Omit<ResourceRequirement, "required">;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* AppKit plugin exposing AI Search as a search box, an agent tool set, and a
|
|
114
|
+
* direct API.
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* ```ts
|
|
118
|
+
* import { createApp, server } from "@databricks/appkit";
|
|
119
|
+
* import { plugin as searchPlugin } from "@dbx-tools/search";
|
|
120
|
+
*
|
|
121
|
+
* await createApp({
|
|
122
|
+
* plugins: [
|
|
123
|
+
* server(),
|
|
124
|
+
* // zero-config: reads DATABRICKS_VECTOR_SEARCH_INDEX
|
|
125
|
+
* searchPlugin.search(),
|
|
126
|
+
* // or go deeper:
|
|
127
|
+
* // searchPlugin.search({
|
|
128
|
+
* // index: "main.support.docs",
|
|
129
|
+
* // indexes: ["main.support.docs", "main.catalog.products"],
|
|
130
|
+
* // columns: ["title", "url", "body"],
|
|
131
|
+
* // mode: "hybrid",
|
|
132
|
+
* // }),
|
|
133
|
+
* ],
|
|
134
|
+
* });
|
|
135
|
+
* ```
|
|
136
|
+
*/
|
|
137
|
+
export class SearchPlugin extends Plugin<SearchPluginConfig> implements ToolProvider {
|
|
138
|
+
static manifest = {
|
|
139
|
+
name: "search",
|
|
140
|
+
displayName: "AI Search",
|
|
141
|
+
description:
|
|
142
|
+
"Search Databricks AI Search (Vector Search) indexes: agent tools, HTTP routes for a " +
|
|
143
|
+
"search box, universal search across indexes, and an opt-in document write surface.",
|
|
144
|
+
stability: "beta",
|
|
145
|
+
resources: {
|
|
146
|
+
required: [],
|
|
147
|
+
optional: [INDEX_RESOURCE],
|
|
148
|
+
},
|
|
149
|
+
config: { schema: SEARCH_CONFIG_SCHEMA },
|
|
150
|
+
} satisfies PluginManifest<"search">;
|
|
151
|
+
|
|
152
|
+
/** The base path AppKit mounts this plugin's routes under. */
|
|
153
|
+
private get basePath(): string {
|
|
154
|
+
return `/api/${SearchPlugin.manifest.name}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Promote the index to a required resource once a deployment pins a default,
|
|
159
|
+
* through plugin config or either environment name.
|
|
160
|
+
*/
|
|
161
|
+
static getResourceRequirements(config: SearchPluginConfig): ResourceRequirement[] {
|
|
162
|
+
const pinned =
|
|
163
|
+
string.trimToNull(config.index) ??
|
|
164
|
+
string.trimToNull(process.env[INDEX_ENV]) ??
|
|
165
|
+
string.trimToNull(process.env[DATABRICKS_INDEX_ENV]);
|
|
166
|
+
return pinned === null ? [] : [{ ...INDEX_RESOURCE, required: true }];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The tools this plugin offers to an AppKit agent. `search` /
|
|
171
|
+
* `universal_search` are reads; `add_documents` / `create_index` /
|
|
172
|
+
* `sync_index` are only offered when the write surface is enabled. None is
|
|
173
|
+
* autoInheritable: every tool runs under the caller's identity, so it must be
|
|
174
|
+
* granted explicitly.
|
|
175
|
+
*/
|
|
176
|
+
private get tools(): ToolRegistry {
|
|
177
|
+
const { config } = getSearchRuntime({ config: this.config });
|
|
178
|
+
const registry: ToolRegistry = {
|
|
179
|
+
search: defineTool({
|
|
180
|
+
description: SEARCH_TOOL_DESCRIPTION,
|
|
181
|
+
schema: searchContract.searchRequestSchema,
|
|
182
|
+
annotations: { effect: "read", requiresUserContext: true },
|
|
183
|
+
autoInheritable: false,
|
|
184
|
+
execute: async (args, signal) => this.runSearch(args, signal),
|
|
185
|
+
}),
|
|
186
|
+
universal_search: defineTool({
|
|
187
|
+
description: UNIVERSAL_SEARCH_TOOL_DESCRIPTION,
|
|
188
|
+
schema: searchContract.universalSearchRequestSchema,
|
|
189
|
+
annotations: { effect: "read", requiresUserContext: true },
|
|
190
|
+
autoInheritable: false,
|
|
191
|
+
execute: async (args, signal) => this.runUniversalSearch(args, signal),
|
|
192
|
+
}),
|
|
193
|
+
};
|
|
194
|
+
if (config.allowWrite) {
|
|
195
|
+
registry.add_documents = defineTool({
|
|
196
|
+
description: ADD_DOCUMENTS_TOOL_DESCRIPTION,
|
|
197
|
+
schema: searchContract.searchRequestSchema
|
|
198
|
+
.pick({ index: true })
|
|
199
|
+
.extend({ documents: searchContract.searchDocumentSchema.array() }),
|
|
200
|
+
annotations: { effect: "write", requiresUserContext: true },
|
|
201
|
+
autoInheritable: false,
|
|
202
|
+
execute: async (args, signal) => this.runAddDocuments(args, signal),
|
|
203
|
+
});
|
|
204
|
+
registry.create_index = defineTool({
|
|
205
|
+
description: CREATE_INDEX_TOOL_DESCRIPTION,
|
|
206
|
+
schema: searchContract.createIndexRequestSchema,
|
|
207
|
+
annotations: { effect: "write", requiresUserContext: true },
|
|
208
|
+
autoInheritable: false,
|
|
209
|
+
execute: async (args, signal) => this.runCreateIndex(args, signal),
|
|
210
|
+
});
|
|
211
|
+
registry.sync_index = defineTool({
|
|
212
|
+
description: SYNC_INDEX_TOOL_DESCRIPTION,
|
|
213
|
+
schema: searchContract.syncIndexRequestSchema,
|
|
214
|
+
annotations: { effect: "write", requiresUserContext: true },
|
|
215
|
+
autoInheritable: false,
|
|
216
|
+
execute: async (args, signal) => this.runSyncIndex(args, signal),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return registry;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Prime the shared runtime from config and log the effective policy at boot. */
|
|
223
|
+
override async setup(): Promise<void> {
|
|
224
|
+
// Choose a backend. Vector Search is primary; when NO Vector Search
|
|
225
|
+
// endpoint is configured but the AppKit `lakebase` plugin is registered,
|
|
226
|
+
// fall back to a Postgres full-text index. Both answer with the identical
|
|
227
|
+
// shape, so this choice is invisible to tools, routes, and the UI.
|
|
228
|
+
const lakebaseBackend = this.resolveLakebaseBackend();
|
|
229
|
+
// The runtime may already have been built (config-only) when `tools()` ran
|
|
230
|
+
// during registration; rebuild it so it carries the chosen backend.
|
|
231
|
+
resetSearchRuntime();
|
|
232
|
+
const { config } = getSearchRuntime({
|
|
233
|
+
config: this.config,
|
|
234
|
+
...(lakebaseBackend ? { lakebase: lakebaseBackend } : {}),
|
|
235
|
+
});
|
|
236
|
+
logger.info("ready", {
|
|
237
|
+
backend: lakebaseBackend ? "lakebase" : "vector-search",
|
|
238
|
+
defaultIndex: config.defaultIndex ?? "(none - pass per request)",
|
|
239
|
+
indexes: config.indexes.map((i) => i.alias),
|
|
240
|
+
pageSize: config.pageSize,
|
|
241
|
+
mode: config.mode,
|
|
242
|
+
allowWrite: config.allowWrite,
|
|
243
|
+
basePath: this.basePath,
|
|
244
|
+
ensureOnSetup: config.ensureOnSetup
|
|
245
|
+
? (config.ensureOnSetup.index ?? config.defaultIndex)
|
|
246
|
+
: "off",
|
|
247
|
+
});
|
|
248
|
+
// Provision a real index in the BACKGROUND so a slow first-time endpoint or
|
|
249
|
+
// index build never blocks the server from coming up.
|
|
250
|
+
if (config.ensureOnSetup) void this.runEnsureOnSetup(config);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Build the Lakebase FALLBACK backend, or return `undefined` when Vector
|
|
255
|
+
* Search should be used. Falls back only when there is NO Vector Search
|
|
256
|
+
* endpoint configured (plugin config or `SEARCH_ENDPOINT` /
|
|
257
|
+
* `DATABRICKS_VECTOR_SEARCH_ENDPOINT`) AND the sibling AppKit `lakebase`
|
|
258
|
+
* plugin is registered. The pg pool is built from that plugin's
|
|
259
|
+
* service-principal config exactly like `@dbx-tools/appkit-mastra`'s memory
|
|
260
|
+
* pool - no auth is re-implemented here.
|
|
261
|
+
*/
|
|
262
|
+
private resolveLakebaseBackend(): LakebaseSearchBackend | undefined {
|
|
263
|
+
const config = resolveSearchConfig(this.config);
|
|
264
|
+
const hasEndpoint =
|
|
265
|
+
config.endpoint !== undefined ||
|
|
266
|
+
string.trimToNull(process.env[ENDPOINT_ENV]) !== null ||
|
|
267
|
+
string.trimToNull(process.env.DATABRICKS_VECTOR_SEARCH_ENDPOINT) !== null;
|
|
268
|
+
if (hasEndpoint) return undefined;
|
|
269
|
+
const lake = pluginLookup.instance(this.context, lakebase);
|
|
270
|
+
if (!lake) return undefined;
|
|
271
|
+
logger.info("backend-lakebase", {
|
|
272
|
+
reason: "no Vector Search endpoint configured; using the Lakebase full-text fallback",
|
|
273
|
+
});
|
|
274
|
+
// `getPgConfig()` must be read OUTSIDE any asUser scope (as it is here at
|
|
275
|
+
// setup) so it carries the SP connection target + token-refresh callback.
|
|
276
|
+
return new LakebaseSearchBackend(() => lake.exports().getPgConfig());
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Ensure the endpoint + index exist and seed them, honoring `ensureOnSetup`.
|
|
281
|
+
* Uses boot-time SDK auth (env / config profile) via the client's
|
|
282
|
+
* out-of-request fallback. Failures are logged, never thrown - a search app
|
|
283
|
+
* should still start even if provisioning is slow or a permission is missing.
|
|
284
|
+
*/
|
|
285
|
+
private async runEnsureOnSetup(
|
|
286
|
+
config: ReturnType<typeof getSearchRuntime>["config"],
|
|
287
|
+
): Promise<void> {
|
|
288
|
+
const spec = config.ensureOnSetup;
|
|
289
|
+
if (!spec) return;
|
|
290
|
+
const index = string.trimToNull(spec.index) ?? config.defaultIndex;
|
|
291
|
+
if (!index) {
|
|
292
|
+
logger.warn("ensure-skipped", {
|
|
293
|
+
reason: "no index name (set `index` or `ensureOnSetup.index`)",
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const documents = spec.documents ?? [];
|
|
298
|
+
// Infer a managed-direct-access schema from the first seed row when not given.
|
|
299
|
+
const schema =
|
|
300
|
+
spec.schema ??
|
|
301
|
+
(documents.length > 0 && !spec.sourceTable
|
|
302
|
+
? this.inferSchema(documents[0], spec.primaryKey ?? "id", spec.textColumn ?? "text")
|
|
303
|
+
: undefined);
|
|
304
|
+
try {
|
|
305
|
+
logger.info("ensure-start", { index });
|
|
306
|
+
const { client } = getSearchRuntime();
|
|
307
|
+
const info = await client.provision(index, {
|
|
308
|
+
...(spec.endpoint ? { endpoint: spec.endpoint } : {}),
|
|
309
|
+
...(spec.primaryKey ? { primaryKey: spec.primaryKey } : {}),
|
|
310
|
+
...(spec.textColumn ? { embeddingSourceColumn: spec.textColumn } : {}),
|
|
311
|
+
...(spec.embeddingModel ? { embeddingModel: spec.embeddingModel } : {}),
|
|
312
|
+
...(spec.sourceTable ? { sourceTable: spec.sourceTable } : {}),
|
|
313
|
+
...(schema ? { schema } : {}),
|
|
314
|
+
...(spec.timeoutMs ? { timeoutMs: spec.timeoutMs } : {}),
|
|
315
|
+
...(documents.length > 0 ? { seed: documents } : {}),
|
|
316
|
+
});
|
|
317
|
+
logger.info("ensure-ready", {
|
|
318
|
+
index: info.name,
|
|
319
|
+
ready: info.ready,
|
|
320
|
+
rowCount: info.rowCount ?? 0,
|
|
321
|
+
});
|
|
322
|
+
} catch (cause) {
|
|
323
|
+
logger.warn("ensure-failed", { index, message: errorUtil.errorMessage(cause) });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Build a Vector Search `schema_json` map from a seed document's value types. */
|
|
328
|
+
private inferSchema(
|
|
329
|
+
doc: Record<string, unknown>,
|
|
330
|
+
primaryKey: string,
|
|
331
|
+
textColumn: string,
|
|
332
|
+
): Record<string, string> {
|
|
333
|
+
const schema: Record<string, string> = { [primaryKey]: "string", [textColumn]: "string" };
|
|
334
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
335
|
+
if (key in schema) continue;
|
|
336
|
+
schema[key] =
|
|
337
|
+
typeof value === "number"
|
|
338
|
+
? Number.isInteger(value)
|
|
339
|
+
? "int"
|
|
340
|
+
: "double"
|
|
341
|
+
: typeof value === "boolean"
|
|
342
|
+
? "boolean"
|
|
343
|
+
: "string";
|
|
344
|
+
}
|
|
345
|
+
return schema;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Drop the shared runtime so a restarted app re-resolves config. */
|
|
349
|
+
async shutdown(): Promise<void> {
|
|
350
|
+
resetSearchRuntime();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
override abortActiveOperations(): void {
|
|
354
|
+
super.abortActiveOperations();
|
|
355
|
+
void this.shutdown();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Mount the search routes under `/api/search`. Each is wrapped in
|
|
360
|
+
* `asUser(req)` so the query runs as the requesting user and the index's
|
|
361
|
+
* Unity Catalog ACLs apply. `GET /indexes` needs no user scope - it just
|
|
362
|
+
* echoes the configured catalogue.
|
|
363
|
+
*/
|
|
364
|
+
override injectRoutes(router: IAppRouter): void {
|
|
365
|
+
this.route(router, {
|
|
366
|
+
name: "search",
|
|
367
|
+
method: "post",
|
|
368
|
+
path: SEARCH_ROUTE,
|
|
369
|
+
handler: async (req, res) => {
|
|
370
|
+
await this.respond(res, "search", () => {
|
|
371
|
+
const request = searchContract.searchRequestSchema.parse(req.body ?? {});
|
|
372
|
+
return this.asUser(req).runSearch(request);
|
|
373
|
+
});
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
this.route(router, {
|
|
377
|
+
name: "universalSearch",
|
|
378
|
+
method: "post",
|
|
379
|
+
path: UNIVERSAL_ROUTE,
|
|
380
|
+
handler: async (req, res) => {
|
|
381
|
+
await this.respond(res, "universalSearch", () => {
|
|
382
|
+
const request = searchContract.universalSearchRequestSchema.parse(req.body ?? {});
|
|
383
|
+
return this.asUser(req).runUniversalSearch(request);
|
|
384
|
+
});
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
this.route(router, {
|
|
388
|
+
name: "indexes",
|
|
389
|
+
method: "get",
|
|
390
|
+
path: INDEXES_ROUTE,
|
|
391
|
+
handler: async (_req, res) => {
|
|
392
|
+
res.json(this.clientConfig());
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
this.route(router, {
|
|
396
|
+
name: "documents",
|
|
397
|
+
method: "post",
|
|
398
|
+
path: DOCUMENTS_ROUTE,
|
|
399
|
+
handler: async (req, res) => {
|
|
400
|
+
const { config } = getSearchRuntime();
|
|
401
|
+
if (!config.allowWrite) {
|
|
402
|
+
res.status(403).json({ error: "the document write surface is disabled" });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
await this.respond(res, "addDocuments", () =>
|
|
406
|
+
this.asUser(req).runAddDocuments(req.body ?? {}),
|
|
407
|
+
);
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
this.route(router, {
|
|
411
|
+
name: "createIndex",
|
|
412
|
+
method: "post",
|
|
413
|
+
path: INDEX_ROUTE,
|
|
414
|
+
handler: async (req, res) => {
|
|
415
|
+
const { config } = getSearchRuntime();
|
|
416
|
+
if (!config.allowWrite) {
|
|
417
|
+
res.status(403).json({ error: "the index write surface is disabled" });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
await this.respond(res, "createIndex", () =>
|
|
421
|
+
this.asUser(req).runCreateIndex(req.body ?? {}),
|
|
422
|
+
);
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
this.route(router, {
|
|
426
|
+
name: "syncIndex",
|
|
427
|
+
method: "post",
|
|
428
|
+
path: INDEX_SYNC_ROUTE,
|
|
429
|
+
handler: async (req, res) => {
|
|
430
|
+
const { config } = getSearchRuntime();
|
|
431
|
+
if (!config.allowWrite) {
|
|
432
|
+
res.status(403).json({ error: "the index write surface is disabled" });
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
await this.respond(res, "syncIndex", () => this.asUser(req).runSyncIndex(req.body ?? {}));
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Run a route body, turning a failure into a JSON error response.
|
|
442
|
+
*
|
|
443
|
+
* AppKit's `route()` registers the handler as-is, so a rejection escapes as
|
|
444
|
+
* an unhandled promise rejection and takes the whole process down rather than
|
|
445
|
+
* failing the one request. Every route here goes through this, so a bad index
|
|
446
|
+
* name or a Vector Search API error returns 500 with a message instead of
|
|
447
|
+
* killing the server.
|
|
448
|
+
*/
|
|
449
|
+
private async respond(
|
|
450
|
+
res: Parameters<Parameters<IAppRouter["post"]>[1]>[1],
|
|
451
|
+
operation: string,
|
|
452
|
+
run: () => Promise<unknown>,
|
|
453
|
+
): Promise<void> {
|
|
454
|
+
try {
|
|
455
|
+
res.json(await run());
|
|
456
|
+
} catch (cause) {
|
|
457
|
+
const message = errorUtil.errorMessage(cause);
|
|
458
|
+
logger.warn("route-failed", { operation, message });
|
|
459
|
+
res.status(500).json({ error: message });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Surface the index catalogue + defaults so a search box needs no round-trip. */
|
|
464
|
+
override clientConfig(): Record<string, unknown> {
|
|
465
|
+
const { config } = getSearchRuntime({ config: this.config });
|
|
466
|
+
const payload: SearchClientConfig = {
|
|
467
|
+
indexes: config.indexes.map((index) => ({
|
|
468
|
+
name: index.name,
|
|
469
|
+
alias: index.alias,
|
|
470
|
+
...(index.primaryKey ? { primaryKey: index.primaryKey } : {}),
|
|
471
|
+
...(index.columns ? { columns: index.columns } : {}),
|
|
472
|
+
...(index.name === config.defaultIndex ? { isDefault: true } : {}),
|
|
473
|
+
})),
|
|
474
|
+
...(config.defaultIndex ? { defaultIndex: config.defaultIndex } : {}),
|
|
475
|
+
pageSize: config.pageSize,
|
|
476
|
+
basePath: this.basePath,
|
|
477
|
+
};
|
|
478
|
+
return payload as unknown as Record<string, unknown>;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** AppKit `ToolProvider`: the tool definitions offered to an agent. */
|
|
482
|
+
getAgentTools(): AgentToolDefinition[] {
|
|
483
|
+
return toolsFromRegistry(this.tools);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** AppKit `ToolProvider`: run one tool call, validating input against its schema. */
|
|
487
|
+
async executeAgentTool(name: string, args: unknown, signal?: AbortSignal): Promise<unknown> {
|
|
488
|
+
return executeFromRegistry(this.tools, name, args, signal);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
override exports() {
|
|
492
|
+
return {
|
|
493
|
+
/** Search one index (default when omitted). Runs as the current context user. */
|
|
494
|
+
search: (request: searchContract.SearchRequest, signal?: AbortSignal) =>
|
|
495
|
+
this.runSearch(request, signal),
|
|
496
|
+
/** Search across every configured index and merge the hits. */
|
|
497
|
+
universalSearch: (request: searchContract.UniversalSearchRequest, signal?: AbortSignal) =>
|
|
498
|
+
this.runUniversalSearch(request, signal),
|
|
499
|
+
/** Add or update documents in a direct-access index (throws when writes are disabled). */
|
|
500
|
+
addDocuments: (request: { index?: string; documents: unknown }, signal?: AbortSignal) =>
|
|
501
|
+
this.runAddDocuments(request, signal),
|
|
502
|
+
/** Create a Vector Search index (throws when writes are disabled). */
|
|
503
|
+
createIndex: (request: searchContract.CreateIndexRequest, signal?: AbortSignal) =>
|
|
504
|
+
this.runCreateIndex(request, signal),
|
|
505
|
+
/** Sync a Delta Sync index from its source table (throws when writes are disabled). */
|
|
506
|
+
syncIndex: (request: searchContract.SyncIndexRequest, signal?: AbortSignal) =>
|
|
507
|
+
this.runSyncIndex(request, signal),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private async runSearch(args: unknown, signal?: AbortSignal) {
|
|
512
|
+
const request = searchContract.searchRequestSchema.parse(args);
|
|
513
|
+
const { client } = getSearchRuntime();
|
|
514
|
+
return client.search(request.query, {
|
|
515
|
+
...(request.index ? { index: request.index } : {}),
|
|
516
|
+
...(request.limit ? { limit: request.limit } : {}),
|
|
517
|
+
...(request.mode ? { mode: request.mode } : {}),
|
|
518
|
+
...(request.columns ? { columns: request.columns } : {}),
|
|
519
|
+
...(request.filter ? { filter: request.filter } : {}),
|
|
520
|
+
...(request.scoreThreshold !== undefined ? { scoreThreshold: request.scoreThreshold } : {}),
|
|
521
|
+
...(signal ? { signal } : {}),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private async runUniversalSearch(args: unknown, signal?: AbortSignal) {
|
|
526
|
+
const request = searchContract.universalSearchRequestSchema.parse(args);
|
|
527
|
+
const { client } = getSearchRuntime();
|
|
528
|
+
return client.universalSearch(request.query, {
|
|
529
|
+
...(request.indexes ? { indexes: request.indexes } : {}),
|
|
530
|
+
...(request.limit ? { limit: request.limit } : {}),
|
|
531
|
+
...(request.mode ? { mode: request.mode } : {}),
|
|
532
|
+
...(signal ? { signal } : {}),
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private async runAddDocuments(args: unknown, signal?: AbortSignal) {
|
|
537
|
+
const record = (args ?? {}) as { index?: string; documents: unknown };
|
|
538
|
+
const { client, config } = getSearchRuntime();
|
|
539
|
+
const documents = toDocumentArray(record.documents);
|
|
540
|
+
const index = record.index ?? config.defaultIndex ?? "";
|
|
541
|
+
return client.addDocuments(index, documents, signal);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
private async runCreateIndex(args: unknown, signal?: AbortSignal) {
|
|
545
|
+
const request = searchContract.createIndexRequestSchema.parse(args);
|
|
546
|
+
const { client } = getSearchRuntime();
|
|
547
|
+
return client.createIndex(request.name, toCreateIndexOptions(request, signal));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private async runSyncIndex(args: unknown, signal?: AbortSignal) {
|
|
551
|
+
const request = searchContract.syncIndexRequestSchema.parse(args);
|
|
552
|
+
const { client, config } = getSearchRuntime();
|
|
553
|
+
const index = request.index ?? config.defaultIndex ?? "";
|
|
554
|
+
await client.syncIndex(index, signal);
|
|
555
|
+
return { index, synced: true };
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Register the AI Search plugin with AppKit.
|
|
561
|
+
*
|
|
562
|
+
* @example
|
|
563
|
+
* ```ts
|
|
564
|
+
* import { createApp, server } from "@databricks/appkit";
|
|
565
|
+
* import { plugin as searchPlugin, tool as searchToolModule } from "@dbx-tools/search";
|
|
566
|
+
* import { agents, plugin as mastraPlugin } from "@dbx-tools/appkit-mastra";
|
|
567
|
+
*
|
|
568
|
+
* const support = agents.createAgent({
|
|
569
|
+
* instructions: "Answer from the docs; use `search` to find them.",
|
|
570
|
+
* tools: () => ({ search: searchToolModule.searchTool() }),
|
|
571
|
+
* });
|
|
572
|
+
*
|
|
573
|
+
* await createApp({
|
|
574
|
+
* plugins: [
|
|
575
|
+
* server(),
|
|
576
|
+
* searchPlugin.search({ index: "main.support.docs" }),
|
|
577
|
+
* mastraPlugin.mastra({ agents: support }),
|
|
578
|
+
* ],
|
|
579
|
+
* });
|
|
580
|
+
* ```
|
|
581
|
+
*/
|
|
582
|
+
export const search = toPlugin(SearchPlugin);
|