@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/src/query.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Translation between the browser-safe search contract and the Databricks
3
+ * Vector Search query API, kept in one place so the client, the tools, and the
4
+ * routes never hand-roll a `filters_json` string or unpack a `data_array` by
5
+ * hand.
6
+ *
7
+ * - {@link toQueryType} maps the friendly {@link SearchMode} onto the API's
8
+ * `query_type` (`HYBRID` / `ANN`). Keyword-only search rides on `ANN` with
9
+ * text but no vector, which Databricks answers with its BM25 path.
10
+ * - {@link compileFilter} turns the `{ column: value }` filter object (with
11
+ * optional operator maps like `{ ">=": 10 }`) into the `filters_json`
12
+ * string the API expects, so a caller never learns Databricks' filter
13
+ * spelling.
14
+ * - {@link toHits} unpacks the columnar `{ manifest, result }` response into
15
+ * `{ id, score, fields }` hits, pulling the score out of the reserved
16
+ * `__db_score` / `score` column and the id out of the primary-key column.
17
+ *
18
+ * @module
19
+ */
20
+
21
+ import { ValidationError } from "@databricks/appkit";
22
+ import type { SearchHit, SearchMode } from "@dbx-tools/shared-search";
23
+ import { json, object } from "@dbx-tools/shared-core";
24
+
25
+ /** The column name Databricks Vector Search returns the relevance score under. */
26
+ const SCORE_COLUMN = "__db_score";
27
+
28
+ /** Map a {@link SearchMode} onto the serving API `query_type`. */
29
+ export function toQueryType(mode: SearchMode): string {
30
+ return mode === "hybrid" ? "HYBRID" : "ANN";
31
+ }
32
+
33
+ /**
34
+ * Compile a `{ column: value }` filter object into the `filters_json` string
35
+ * the query API expects. A scalar becomes an equality; an array becomes an
36
+ * IN-style match; an operator map (`{ ">=": 10, "<": 20 }`) expands to the
37
+ * `column operator` keys Databricks uses. Returns `undefined` for an empty
38
+ * filter so the field is omitted rather than sent as `{}`.
39
+ */
40
+ export function compileFilter(filter: Record<string, unknown> | undefined): string | undefined {
41
+ if (!filter || Object.keys(filter).length === 0) return undefined;
42
+ const compiled: Record<string, unknown> = {};
43
+ for (const [column, value] of Object.entries(filter)) {
44
+ if (object.isRecord(value)) {
45
+ for (const [op, operand] of Object.entries(value)) {
46
+ compiled[`${column} ${op}`] = operand;
47
+ }
48
+ } else {
49
+ compiled[column] = value;
50
+ }
51
+ }
52
+ return JSON.stringify(compiled);
53
+ }
54
+
55
+ /** A minimal structural view of the Vector Search query response. */
56
+ export interface QueryResponseLike {
57
+ manifest?: { columns?: Array<{ name?: string }> };
58
+ result?: { data_array?: Array<Array<unknown>> };
59
+ next_page_token?: string;
60
+ }
61
+
62
+ /**
63
+ * Unpack a columnar query response into {@link SearchHit}s. The manifest names
64
+ * the columns in order; each row is a positional array. The score comes from
65
+ * the reserved score column and the id from `primaryKey` (falling back to the
66
+ * first column when the key is unknown). The score column is stripped from
67
+ * `fields` so a hit's fields are just the document.
68
+ */
69
+ export function toHits(
70
+ response: QueryResponseLike,
71
+ primaryKey: string | undefined,
72
+ indexName?: string,
73
+ ): SearchHit[] {
74
+ const columns = (response.manifest?.columns ?? []).map((c) => c.name ?? "");
75
+ const rows = response.result?.data_array ?? [];
76
+ const scoreIdx = columns.indexOf(SCORE_COLUMN);
77
+ const keyIdx = primaryKey ? columns.indexOf(primaryKey) : -1;
78
+ return rows.map((row, rowIndex) => {
79
+ const fields: Record<string, unknown> = {};
80
+ columns.forEach((name, i) => {
81
+ if (i === scoreIdx || !name) return;
82
+ fields[name] = row[i];
83
+ });
84
+ const scoreRaw = scoreIdx >= 0 ? Number(row[scoreIdx]) : NaN;
85
+ const idRaw =
86
+ keyIdx >= 0 ? row[keyIdx] : primaryKey ? fields[primaryKey] : (row[0] ?? rowIndex);
87
+ return {
88
+ id: String(idRaw ?? rowIndex),
89
+ score: Number.isFinite(scoreRaw) ? scoreRaw : 0,
90
+ fields,
91
+ ...(indexName ? { index: indexName } : {}),
92
+ };
93
+ });
94
+ }
95
+
96
+ /**
97
+ * The columns to request from an index for a search. When neither the request
98
+ * nor the index config names columns, the score column alone is requested and
99
+ * the primary key is appended so a hit always has an id. Callers that want the
100
+ * whole document should pass the index's own column list.
101
+ */
102
+ export function toRequestColumns(
103
+ requested: readonly string[] | undefined,
104
+ fallback: readonly string[] | undefined,
105
+ primaryKey: string | undefined,
106
+ ): string[] {
107
+ const base = requested && requested.length > 0 ? requested : (fallback ?? []);
108
+ const columns = new Set<string>(base);
109
+ if (primaryKey) columns.add(primaryKey);
110
+ if (columns.size === 0 && primaryKey) columns.add(primaryKey);
111
+ return [...columns];
112
+ }
113
+
114
+ /**
115
+ * Parse a JSON document payload the model / a route supplied for a write.
116
+ * Accepts an already-parsed array/object or a JSON string, and always returns
117
+ * an array so a single document and a batch are handled the same way. Throws a
118
+ * {@link ValidationError} on unparseable input so the caller can surface it.
119
+ */
120
+ export function toDocumentArray(input: unknown): Array<Record<string, unknown>> {
121
+ const value = typeof input === "string" ? json.parse(input, undefined) : input;
122
+ if (value === undefined) {
123
+ throw new ValidationError("documents must be a JSON object, array, or string");
124
+ }
125
+ const list = Array.isArray(value) ? value : [value];
126
+ return list.filter(object.isRecord);
127
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The shared AI Search runtime: the resolved config plus a single
3
+ * {@link SearchClient} instance the plugin, the Mastra tools, and the routes
4
+ * all read. Mirrors the web-search / email runtime pattern - the plugin primes
5
+ * it from its config at setup, and everything else reads the same instance so
6
+ * a tool invoked outside the agent still sees the deployment's config.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import { createSearchClient, SearchClient } from "./client.ts";
12
+ import {
13
+ resolveSearchConfig,
14
+ type SearchPluginConfig,
15
+ type ResolvedSearchConfig,
16
+ } from "./config.ts";
17
+ import { LakebaseSearchBackend } from "./lakebase.ts";
18
+
19
+ /**
20
+ * How the runtime is built. `lakebase` (when present) is the Postgres full-text
21
+ * FALLBACK backend the plugin wires up when no Vector Search endpoint is
22
+ * configured but the AppKit `lakebase` plugin is registered. Every read/write
23
+ * returns the same shape either way.
24
+ */
25
+ export interface SearchRuntimeOptions {
26
+ config?: SearchPluginConfig;
27
+ lakebase?: LakebaseSearchBackend;
28
+ }
29
+
30
+ /** The shared resolved config plus the client reads run through. */
31
+ export interface SearchRuntime {
32
+ config: ResolvedSearchConfig;
33
+ client: SearchClient;
34
+ /** The Lakebase fallback backend, when the runtime is backed by Postgres. */
35
+ lakebase?: LakebaseSearchBackend;
36
+ }
37
+
38
+ let runtime: SearchRuntime | undefined;
39
+
40
+ /**
41
+ * Return the shared runtime, building it on first use from the supplied config
42
+ * layered over environment defaults. Overrides are only read when the runtime
43
+ * is first created, so prime it from the plugin's config at setup; subsequent
44
+ * calls pass nothing and get the same instance.
45
+ */
46
+ export function getSearchRuntime(options?: SearchRuntimeOptions): SearchRuntime {
47
+ if (!runtime) {
48
+ const config = resolveSearchConfig(options?.config);
49
+ const lakebase = options?.lakebase;
50
+ runtime = {
51
+ config,
52
+ client: createSearchClient(config, undefined, lakebase),
53
+ ...(lakebase ? { lakebase } : {}),
54
+ };
55
+ }
56
+ return runtime;
57
+ }
58
+
59
+ /** Drop the memoized runtime so the next {@link getSearchRuntime} rebuilds it. */
60
+ export function resetSearchRuntime(): void {
61
+ const backend = runtime?.lakebase;
62
+ runtime = undefined;
63
+ if (backend) void backend.close();
64
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Tool-facing descriptions and the request schemas the Mastra tools + AppKit
3
+ * tool provider validate against. The wire shapes themselves live in
4
+ * `@dbx-tools/shared-search`; this module adds the model-readable
5
+ * descriptions so both hosts describe the tools identically.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import { search } from "@dbx-tools/shared-search";
11
+ import { string } from "@dbx-tools/shared-core";
12
+
13
+ /** Description the model reads for the `search` tool. */
14
+ export const SEARCH_TOOL_DESCRIPTION = string.toDescription(`
15
+ Search a Databricks AI Search (Vector Search) index for the documents most
16
+ relevant to a query. Pass a natural-language question or keywords; hybrid
17
+ matching combines semantic similarity with keyword ranking, so exact terms
18
+ (product names, error codes) and paraphrases both work. Optionally name an
19
+ index (defaults to the app's configured index), a result limit, and attribute
20
+ filters. Use it to look up docs, knowledge-base articles, products, or any
21
+ indexed content before answering.
22
+ `);
23
+
24
+ /** Description the model reads for the `universal_search` tool. */
25
+ export const UNIVERSAL_SEARCH_TOOL_DESCRIPTION = string.toDescription(`
26
+ Search across every configured AI Search index at once and return the best
27
+ matches from all of them, merged and ranked. Use it when the right index
28
+ isn't known in advance or when an answer may live in any of several
29
+ collections (docs, tickets, products).
30
+ `);
31
+
32
+ /** Description the model reads for the `add_documents` tool. */
33
+ export const ADD_DOCUMENTS_TOOL_DESCRIPTION = string.toDescription(`
34
+ Add or update documents in a direct-access AI Search index. Pass an array of
35
+ documents as JSON objects; each MUST include the index's primary-key column.
36
+ Only available when the app enables the write surface. Use it to index new
37
+ content the user provides.
38
+ `);
39
+
40
+ /** Description the model reads for the `create_index` tool. */
41
+ export const CREATE_INDEX_TOOL_DESCRIPTION = string.toDescription(`
42
+ Create a Databricks AI Search (Vector Search) index. For the common case pass
43
+ a Delta source table (catalog.schema.table): Databricks computes embeddings
44
+ from its text column and keeps the index synced. To create a direct-access
45
+ index you write vectors to yourself, pass an embedding dimension instead of a
46
+ source table. The endpoint, embedding model, primary key, and text column are
47
+ inferred when omitted. Only available when the app enables the write surface.
48
+ Creating an index provisions infrastructure - do this only when the user
49
+ explicitly asks to set up a new index.
50
+ `);
51
+
52
+ /** Description the model reads for the `sync_index` tool. */
53
+ export const SYNC_INDEX_TOOL_DESCRIPTION = string.toDescription(`
54
+ Refresh a Delta Sync AI Search index from its source table so newly added or
55
+ changed rows become searchable. Optionally name the index (defaults to the
56
+ app's default index). Only available when the app enables the write surface.
57
+ `);
58
+
59
+ /** Schema for the `search` tool input (the shared request schema). */
60
+ export const searchToolSchema = search.searchRequestSchema;
61
+
62
+ /** Schema for the `universal_search` tool input. */
63
+ export const universalSearchToolSchema = search.universalSearchRequestSchema;
64
+
65
+ /** Schema for the `search` / `universal_search` tool output. */
66
+ export const searchResultSchema = search.searchResultSchema;
67
+
68
+ /** Schema for the `create_index` tool input. */
69
+ export const createIndexToolSchema = search.createIndexRequestSchema;
70
+
71
+ /** Schema for the `create_index` tool output (a resolved index definition). */
72
+ export const indexInfoSchema = search.indexInfoSchema;
73
+
74
+ /** Schema for the `sync_index` tool input. */
75
+ export const syncIndexToolSchema = search.syncIndexRequestSchema;
package/src/tool.ts ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The `search`, `universal_search`, and (opt-in) `add_documents`,
3
+ * `create_index`, and `sync_index` Mastra tools.
4
+ *
5
+ * All three read the shared runtime primed by the plugin, so a tool spread
6
+ * into an agent uses the deployment's default index, columns, page size, and
7
+ * mode without any per-tool wiring. They run under the caller's OBO scope (the
8
+ * client resolves the execution context's workspace client), so search runs as
9
+ * the requesting user and Unity Catalog ACLs apply.
10
+ *
11
+ * The same tools are exposed to AppKit's own agents through the plugin's
12
+ * `ToolProvider` (see `plugin.ts`); this module is the Mastra half.
13
+ *
14
+ * @module
15
+ */
16
+
17
+ import { search as searchContract, type UpsertResult } from "@dbx-tools/shared-search";
18
+ import { createTool } from "@mastra/core/tools";
19
+ import { z } from "zod";
20
+ import { toCreateIndexOptions } from "./index-tools.ts";
21
+ import { toDocumentArray } from "./query.ts";
22
+ import { getSearchRuntime } from "./runtime.ts";
23
+ import {
24
+ ADD_DOCUMENTS_TOOL_DESCRIPTION,
25
+ CREATE_INDEX_TOOL_DESCRIPTION,
26
+ SEARCH_TOOL_DESCRIPTION,
27
+ SYNC_INDEX_TOOL_DESCRIPTION,
28
+ UNIVERSAL_SEARCH_TOOL_DESCRIPTION,
29
+ createIndexToolSchema,
30
+ indexInfoSchema,
31
+ searchResultSchema,
32
+ searchToolSchema,
33
+ syncIndexToolSchema,
34
+ universalSearchToolSchema,
35
+ } from "./schema.ts";
36
+
37
+ /** Common option accepted by every tool factory: override the tool id. */
38
+ export interface SearchToolOptions {
39
+ /** Override the tool id (defaults per tool). */
40
+ id?: string;
41
+ }
42
+
43
+ /**
44
+ * Build the `search` tool. Spread it into any agent that should be able to look
45
+ * things up in an index.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * import { searchTool } from "@dbx-tools/search";
50
+ * import { createAgent } from "@dbx-tools/appkit-mastra";
51
+ *
52
+ * const support = createAgent({
53
+ * instructions: "Answer from the docs. Use `search` to find them.",
54
+ * tools: () => ({ search: searchTool() }),
55
+ * });
56
+ * ```
57
+ */
58
+ export function searchTool(options: SearchToolOptions = {}) {
59
+ return createTool({
60
+ id: options.id ?? "search",
61
+ description: SEARCH_TOOL_DESCRIPTION,
62
+ inputSchema: searchToolSchema,
63
+ outputSchema: searchResultSchema,
64
+ execute: async (input, context) => {
65
+ const request = searchToolSchema.parse(input);
66
+ const { client } = getSearchRuntime();
67
+ return client.search(request.query, {
68
+ ...(request.index ? { index: request.index } : {}),
69
+ ...(request.limit ? { limit: request.limit } : {}),
70
+ ...(request.mode ? { mode: request.mode } : {}),
71
+ ...(request.columns ? { columns: request.columns } : {}),
72
+ ...(request.filter ? { filter: request.filter } : {}),
73
+ ...(request.scoreThreshold !== undefined ? { scoreThreshold: request.scoreThreshold } : {}),
74
+ ...(context?.abortSignal ? { signal: context.abortSignal } : {}),
75
+ });
76
+ },
77
+ });
78
+ }
79
+
80
+ /** Build the `universal_search` tool (federated search across every index). */
81
+ export function universalSearchTool(options: SearchToolOptions = {}) {
82
+ return createTool({
83
+ id: options.id ?? "universal_search",
84
+ description: UNIVERSAL_SEARCH_TOOL_DESCRIPTION,
85
+ inputSchema: universalSearchToolSchema,
86
+ outputSchema: searchResultSchema,
87
+ execute: async (input, context) => {
88
+ const request = universalSearchToolSchema.parse(input);
89
+ const { client } = getSearchRuntime();
90
+ return client.universalSearch(request.query, {
91
+ ...(request.indexes ? { indexes: request.indexes } : {}),
92
+ ...(request.limit ? { limit: request.limit } : {}),
93
+ ...(request.mode ? { mode: request.mode } : {}),
94
+ ...(context?.abortSignal ? { signal: context.abortSignal } : {}),
95
+ });
96
+ },
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Build the opt-in `add_documents` tool (write into a direct-access index).
102
+ * Only install it when the plugin's write surface is enabled.
103
+ */
104
+ export function addDocumentsTool(options: SearchToolOptions = {}) {
105
+ const inputSchema = searchContract.searchDocumentSchema
106
+ .array()
107
+ .describe("Documents to add or update. Each must include the index primary key.");
108
+ return createTool({
109
+ id: options.id ?? "add_documents",
110
+ description: ADD_DOCUMENTS_TOOL_DESCRIPTION,
111
+ inputSchema: searchContract.searchRequestSchema
112
+ .pick({ index: true })
113
+ .extend({ documents: inputSchema }),
114
+ outputSchema: searchContract.upsertResultSchema,
115
+ execute: async (input, context): Promise<UpsertResult> => {
116
+ const { client, config } = getSearchRuntime();
117
+ const record = input as { index?: string; documents: unknown };
118
+ const documents = toDocumentArray(record.documents);
119
+ const index = record.index ?? config.defaultIndex ?? "";
120
+ return client.addDocuments(index, documents, context?.abortSignal);
121
+ },
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Build the opt-in `create_index` tool (provision a Vector Search index).
127
+ * Only install it when the plugin's write surface is enabled. Delegates to
128
+ * {@link SearchClient.createIndex}, inferring the endpoint, embedding model,
129
+ * key, and columns from the request + plugin config.
130
+ */
131
+ export function createIndexTool(options: SearchToolOptions = {}) {
132
+ return createTool({
133
+ id: options.id ?? "create_index",
134
+ description: CREATE_INDEX_TOOL_DESCRIPTION,
135
+ inputSchema: createIndexToolSchema,
136
+ outputSchema: indexInfoSchema,
137
+ execute: async (input, context) => {
138
+ const request = createIndexToolSchema.parse(input);
139
+ const { client } = getSearchRuntime();
140
+ return client.createIndex(request.name, toCreateIndexOptions(request, context?.abortSignal));
141
+ },
142
+ });
143
+ }
144
+
145
+ /** Output schema for the `sync_index` tool. */
146
+ const syncIndexResultSchema = z.object({
147
+ index: z.string().describe("The index that was synced."),
148
+ synced: z.boolean().describe("True once the sync was triggered."),
149
+ });
150
+
151
+ /**
152
+ * Build the opt-in `sync_index` tool (refresh a Delta Sync index from its
153
+ * source table). Only install it when the plugin's write surface is enabled.
154
+ */
155
+ export function syncIndexTool(options: SearchToolOptions = {}) {
156
+ return createTool({
157
+ id: options.id ?? "sync_index",
158
+ description: SYNC_INDEX_TOOL_DESCRIPTION,
159
+ inputSchema: syncIndexToolSchema,
160
+ outputSchema: syncIndexResultSchema,
161
+ execute: async (input, context) => {
162
+ const request = syncIndexToolSchema.parse(input);
163
+ const { client, config } = getSearchRuntime();
164
+ const index = request.index ?? config.defaultIndex ?? "";
165
+ await client.syncIndex(index, context?.abortSignal);
166
+ return { index, synced: true };
167
+ },
168
+ });
169
+ }