@dbx-tools/appkit-web-search 0.3.28 → 0.3.29
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 +3 -1
- package/index.ts +1 -0
- package/package.json +5 -5
- package/src/allowlist.ts +2 -10
- package/src/config.ts +10 -25
- package/src/fetch.ts +2 -34
- package/src/html-text.ts +72 -0
- package/src/plugin.ts +6 -12
- package/src/scrape.ts +3 -16
- package/src/search.ts +21 -14
- package/src/tool.ts +2 -15
- package/test/allowlist.test.ts +1 -1
package/README.md
CHANGED
|
@@ -223,7 +223,9 @@ the [Databricks docs](https://docs.databricks.com/aws/en/machine-learning/model-
|
|
|
223
223
|
- `provider` - provider detection + the provider -> tool-spec map.
|
|
224
224
|
- `scrape` - `runScrapeSearch()` DuckDuckGo fallback for workspaces with no
|
|
225
225
|
native web-search model.
|
|
226
|
-
- `fetch` - `runWebFetch()` over got-scraping
|
|
226
|
+
- `fetch` - `runWebFetch()` over got-scraping.
|
|
227
|
+
- `html-text` - `htmlToText()` / `htmlFragmentToText()` / `decodeHtmlEntities()`, shared by
|
|
228
|
+
`fetch` and the DuckDuckGo scrape fallback.
|
|
227
229
|
- `runtime` - shared runtime, `getWebSearchRuntime()`, `resetWebSearchRuntime()`.
|
|
228
230
|
- `config` - config types, JSON schema, `resolveWebSearchConfig()`, approval helpers.
|
|
229
231
|
- `allowlist` - URL allow-list parsing/compiling on top of `@dbx-tools/path`.
|
package/index.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
export * as allowlist from "./src/allowlist";
|
|
6
6
|
export * as config from "./src/config";
|
|
7
7
|
export * as fetch from "./src/fetch";
|
|
8
|
+
export * as htmlText from "./src/html-text";
|
|
8
9
|
export * as plugin from "./src/plugin";
|
|
9
10
|
export * as provider from "./src/provider";
|
|
10
11
|
export * as runtime from "./src/runtime";
|
package/package.json
CHANGED
|
@@ -17,17 +17,17 @@
|
|
|
17
17
|
"@mastra/core": "^1.47.0",
|
|
18
18
|
"got-scraping": "^4.2.1",
|
|
19
19
|
"zod": "^4.3.6",
|
|
20
|
-
"@dbx-tools/path": "0.3.
|
|
21
|
-
"@dbx-tools/model": "0.3.
|
|
22
|
-
"@dbx-tools/shared-core": "0.3.
|
|
23
|
-
"@dbx-tools/shared-model": "0.3.
|
|
20
|
+
"@dbx-tools/path": "0.3.29",
|
|
21
|
+
"@dbx-tools/model": "0.3.29",
|
|
22
|
+
"@dbx-tools/shared-core": "0.3.29",
|
|
23
|
+
"@dbx-tools/shared-model": "0.3.29"
|
|
24
24
|
},
|
|
25
25
|
"main": "index.ts",
|
|
26
26
|
"license": "UNLICENSED",
|
|
27
27
|
"publishConfig": {
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
|
-
"version": "0.3.
|
|
30
|
+
"version": "0.3.29",
|
|
31
31
|
"types": "index.ts",
|
|
32
32
|
"type": "module",
|
|
33
33
|
"exports": {
|
package/src/allowlist.ts
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import { match, type PathMatcher } from "@dbx-tools/path";
|
|
33
|
-
import {
|
|
33
|
+
import { string } from "@dbx-tools/shared-core";
|
|
34
34
|
|
|
35
35
|
/** A compiled URL allow-list. Build one with {@link toUrlAllowList}. */
|
|
36
36
|
export interface UrlAllowList {
|
|
@@ -64,15 +64,7 @@ export function normalizeUrlPattern(pattern: string): string {
|
|
|
64
64
|
* identically.
|
|
65
65
|
*/
|
|
66
66
|
export function parseAllowedUrls(raw: string | string[] | undefined): string[] {
|
|
67
|
-
|
|
68
|
-
return [
|
|
69
|
-
...object
|
|
70
|
-
.sequence(entries)
|
|
71
|
-
.map((entry) => normalizeUrlPattern(entry))
|
|
72
|
-
.filter((entry) => entry.length > 0)
|
|
73
|
-
.distinct()
|
|
74
|
-
.toArray(),
|
|
75
|
-
];
|
|
67
|
+
return string.parseList(raw, normalizeUrlPattern);
|
|
76
68
|
}
|
|
77
69
|
|
|
78
70
|
/** One compiled entry: which URL slice it tests, and the matcher for it. */
|
package/src/config.ts
CHANGED
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import type { BasePluginConfig } from "@databricks/appkit";
|
|
29
|
-
import {
|
|
29
|
+
import { serving } from "@dbx-tools/model";
|
|
30
|
+
import { json, object, type OneOrMany, string } from "@dbx-tools/shared-core";
|
|
30
31
|
import type { JSONSchema7 } from "json-schema";
|
|
31
32
|
import { parseAllowedUrls, toUrlAllowList, type UrlAllowList } from "./allowlist";
|
|
32
33
|
|
|
@@ -93,7 +94,7 @@ export interface WebSearchPluginConfig extends BasePluginConfig {
|
|
|
93
94
|
* Defaults to `true`; falls back to `WEB_SEARCH_FUZZY`.
|
|
94
95
|
*/
|
|
95
96
|
modelFuzzyMatch?: boolean;
|
|
96
|
-
/** Fuse.js fuzzy threshold. Falls back to `WEB_SEARCH_FUZZY_THRESHOLD`, then
|
|
97
|
+
/** Fuse.js fuzzy threshold. Falls back to `WEB_SEARCH_FUZZY_THRESHOLD`, then the shared default. */
|
|
97
98
|
modelFuzzyThreshold?: number;
|
|
98
99
|
/**
|
|
99
100
|
* Hard cap on the number of citations a single search returns. Falls back
|
|
@@ -200,19 +201,6 @@ export const WEB_SEARCH_CONFIG_SCHEMA: JSONSchema7 = {
|
|
|
200
201
|
},
|
|
201
202
|
};
|
|
202
203
|
|
|
203
|
-
/** Split a CSV / whitespace / array value into a trimmed, non-empty string list. */
|
|
204
|
-
function toStringList(raw: string | string[] | undefined): string[] {
|
|
205
|
-
const entries = typeof raw === "string" ? raw.split(/[\s,]+/) : Array.isArray(raw) ? raw : [];
|
|
206
|
-
return [
|
|
207
|
-
...object
|
|
208
|
-
.sequence(entries)
|
|
209
|
-
.map((e) => e.trim())
|
|
210
|
-
.filter((e) => e.length > 0)
|
|
211
|
-
.distinct()
|
|
212
|
-
.toArray(),
|
|
213
|
-
];
|
|
214
|
-
}
|
|
215
|
-
|
|
216
204
|
/** Parse a positive integer env/config value, else the fallback. */
|
|
217
205
|
function resolvePositiveInt(value: number | undefined, envKey: string, fallback: number): number {
|
|
218
206
|
if (typeof value === "number" && Number.isFinite(value) && value > 0) return Math.floor(value);
|
|
@@ -222,14 +210,7 @@ function resolvePositiveInt(value: number | undefined, envKey: string, fallback:
|
|
|
222
210
|
|
|
223
211
|
/** Parse the `WEB_SEARCH_TOOLS` env var (JSON), else `{}`. Bad JSON is ignored. */
|
|
224
212
|
function parseToolsEnv(): Record<string, unknown> {
|
|
225
|
-
|
|
226
|
-
if (!raw) return {};
|
|
227
|
-
try {
|
|
228
|
-
const parsed = JSON.parse(raw) as unknown;
|
|
229
|
-
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
|
230
|
-
} catch {
|
|
231
|
-
return {};
|
|
232
|
-
}
|
|
213
|
+
return json.parseRecord(process.env.WEB_SEARCH_TOOLS) ?? {};
|
|
233
214
|
}
|
|
234
215
|
|
|
235
216
|
/**
|
|
@@ -243,7 +224,9 @@ export function resolveWebSearchConfig(
|
|
|
243
224
|
): ResolvedWebSearchConfig {
|
|
244
225
|
const patterns = parseAllowedUrls(config.allowedUrls ?? process.env.WEB_SEARCH_ALLOWED_URLS);
|
|
245
226
|
const model = config.model ?? process.env.WEB_SEARCH_MODEL;
|
|
246
|
-
const fallbacks =
|
|
227
|
+
const fallbacks = string.parseList(
|
|
228
|
+
config.modelFallbacks ?? process.env.WEB_SEARCH_MODEL_FALLBACKS,
|
|
229
|
+
);
|
|
247
230
|
const fuzzyThresholdRaw =
|
|
248
231
|
config.modelFuzzyThreshold ?? Number(process.env.WEB_SEARCH_FUZZY_THRESHOLD);
|
|
249
232
|
return {
|
|
@@ -252,7 +235,9 @@ export function resolveWebSearchConfig(
|
|
|
252
235
|
webSearchTools: { ...parseToolsEnv(), ...(config.webSearchTools ?? {}) },
|
|
253
236
|
fuzzy: config.modelFuzzyMatch ?? object.toBoolean(process.env.WEB_SEARCH_FUZZY) ?? true,
|
|
254
237
|
fuzzyThreshold:
|
|
255
|
-
Number.isFinite(fuzzyThresholdRaw) && fuzzyThresholdRaw
|
|
238
|
+
Number.isFinite(fuzzyThresholdRaw) && fuzzyThresholdRaw
|
|
239
|
+
? Number(fuzzyThresholdRaw)
|
|
240
|
+
: serving.DEFAULT_FUZZY_THRESHOLD,
|
|
256
241
|
maxCitations: resolvePositiveInt(
|
|
257
242
|
config.maxCitations,
|
|
258
243
|
"WEB_SEARCH_MAX_CITATIONS",
|
package/src/fetch.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { log } from "@dbx-tools/shared-core";
|
|
19
19
|
import { gotScraping } from "got-scraping";
|
|
20
20
|
import { assertUrlAllowed } from "./allowlist";
|
|
21
|
+
import { decodeHtmlEntities, htmlToText } from "./html-text";
|
|
21
22
|
import type { ResolvedWebSearchConfig } from "./config";
|
|
22
23
|
import type { WebFetchRequest, WebFetchResult } from "./schema";
|
|
23
24
|
|
|
@@ -26,43 +27,10 @@ const logger = log.logger("web-search/fetch");
|
|
|
26
27
|
/** Pull the <title> text out of an HTML document, when present. */
|
|
27
28
|
function extractTitle(html: string): string | undefined {
|
|
28
29
|
const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
|
|
29
|
-
const title = match?.[1] ?
|
|
30
|
+
const title = match?.[1] ? decodeHtmlEntities(match[1]).trim() : "";
|
|
30
31
|
return title.length > 0 ? title : undefined;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
/** Decode the handful of HTML entities that survive tag-stripping. */
|
|
34
|
-
function decodeEntities(text: string): string {
|
|
35
|
-
return text
|
|
36
|
-
.replace(/ /g, " ")
|
|
37
|
-
.replace(/&/g, "&")
|
|
38
|
-
.replace(/</g, "<")
|
|
39
|
-
.replace(/>/g, ">")
|
|
40
|
-
.replace(/"/g, '"')
|
|
41
|
-
.replace(/'/g, "'")
|
|
42
|
-
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)));
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Reduce an HTML document to readable plain text: drop `<script>` /
|
|
47
|
-
* `<style>` / `<noscript>` blocks and HTML comments, turn block-level tags
|
|
48
|
-
* into newlines, strip the remaining tags, decode entities, and collapse
|
|
49
|
-
* runs of blank lines / trailing spaces.
|
|
50
|
-
*/
|
|
51
|
-
export function htmlToText(html: string): string {
|
|
52
|
-
return decodeEntities(
|
|
53
|
-
html
|
|
54
|
-
.replace(/<!--[\s\S]*?-->/g, "")
|
|
55
|
-
.replace(/<(script|style|noscript)[^>]*>[\s\S]*?<\/\1>/gi, "")
|
|
56
|
-
.replace(/<\/(p|div|section|article|li|tr|h[1-6]|header|footer|br)>/gi, "\n")
|
|
57
|
-
.replace(/<br\s*\/?>/gi, "\n")
|
|
58
|
-
.replace(/<[^>]+>/g, ""),
|
|
59
|
-
)
|
|
60
|
-
.replace(/[ \t]+\n/g, "\n")
|
|
61
|
-
.replace(/\n{3,}/g, "\n\n")
|
|
62
|
-
.replace(/[ \t]{2,}/g, " ")
|
|
63
|
-
.trim();
|
|
64
|
-
}
|
|
65
|
-
|
|
66
34
|
/** Truncate `text` to `max` chars, reporting whether it was cut. */
|
|
67
35
|
function truncate(text: string, max: number): { content: string; truncated: boolean } {
|
|
68
36
|
if (text.length <= max) return { content: text, truncated: false };
|
package/src/html-text.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning fetched HTML into the plain text a model can read.
|
|
3
|
+
*
|
|
4
|
+
* Both scraping paths need this: {@link fetchUrl}'s full-page read and the
|
|
5
|
+
* DuckDuckGo fallback's title/snippet extraction. They previously carried
|
|
6
|
+
* separate entity tables that drifted, so the decode lives here once.
|
|
7
|
+
*
|
|
8
|
+
* This is deliberately regex-based rather than a DOM parse. The inputs are
|
|
9
|
+
* whole pages fetched for summarization, not documents to be queried, so the
|
|
10
|
+
* cost of a real parser buys nothing - and a malformed page still degrades to
|
|
11
|
+
* readable text instead of throwing.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The named entities that survive tag-stripping in practice, plus numeric
|
|
18
|
+
* escapes. Not a complete HTML entity table - anything rarer passes through
|
|
19
|
+
* as-is, which reads acceptably in model input.
|
|
20
|
+
*/
|
|
21
|
+
const NAMED_ENTITIES: Record<string, string> = {
|
|
22
|
+
" ": " ",
|
|
23
|
+
"&": "&",
|
|
24
|
+
"<": "<",
|
|
25
|
+
">": ">",
|
|
26
|
+
""": '"',
|
|
27
|
+
"'": "'",
|
|
28
|
+
"'": "'",
|
|
29
|
+
"'": "'",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const NAMED_ENTITY_REGEXP = new RegExp(Object.keys(NAMED_ENTITIES).join("|"), "g");
|
|
33
|
+
const NUMERIC_ENTITY_REGEXP = /&#(\d+);/g;
|
|
34
|
+
const TAG_REGEXP = /<[^>]+>/g;
|
|
35
|
+
|
|
36
|
+
/** Decode the HTML entities that survive tag-stripping. */
|
|
37
|
+
export function decodeHtmlEntities(text: string): string {
|
|
38
|
+
return text
|
|
39
|
+
.replace(NAMED_ENTITY_REGEXP, (entity) => NAMED_ENTITIES[entity] ?? entity)
|
|
40
|
+
.replace(NUMERIC_ENTITY_REGEXP, (_, code) => String.fromCodePoint(Number(code)));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Strip tags and decode entities from a short HTML fragment, collapsing all
|
|
45
|
+
* whitespace to single spaces. For inline snippets (a search result title or
|
|
46
|
+
* summary), where layout carries no meaning.
|
|
47
|
+
*/
|
|
48
|
+
export function htmlFragmentToText(html: string): string {
|
|
49
|
+
return decodeHtmlEntities(html.replace(TAG_REGEXP, "")).replace(/\s+/g, " ").trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Reduce a full HTML document to readable plain text: drop `<script>` /
|
|
54
|
+
* `<style>` / `<noscript>` blocks and comments, turn block-level tags into
|
|
55
|
+
* newlines, strip the remaining tags, decode entities, and collapse runs of
|
|
56
|
+
* blank lines / trailing spaces. Unlike {@link htmlFragmentToText}, this
|
|
57
|
+
* preserves line structure because paragraph breaks carry meaning in a page.
|
|
58
|
+
*/
|
|
59
|
+
export function htmlToText(html: string): string {
|
|
60
|
+
return decodeHtmlEntities(
|
|
61
|
+
html
|
|
62
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
63
|
+
.replace(/<(script|style|noscript)[^>]*>[\s\S]*?<\/\1>/gi, "")
|
|
64
|
+
.replace(/<\/(p|div|section|article|li|tr|h[1-6]|header|footer|br)>/gi, "\n")
|
|
65
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
66
|
+
.replace(TAG_REGEXP, ""),
|
|
67
|
+
)
|
|
68
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
69
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
70
|
+
.replace(/[ \t]{2,}/g, " ")
|
|
71
|
+
.trim();
|
|
72
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -13,13 +13,13 @@
|
|
|
13
13
|
* @module
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { Plugin, toPlugin, type PluginManifest } from "@databricks/appkit";
|
|
17
17
|
import { log } from "@dbx-tools/shared-core";
|
|
18
18
|
import { WEB_SEARCH_CONFIG_SCHEMA, type WebSearchPluginConfig } from "./config";
|
|
19
19
|
import { runWebFetch } from "./fetch";
|
|
20
20
|
import { getWebSearchRuntime } from "./runtime";
|
|
21
21
|
import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from "./schema";
|
|
22
|
-
import {
|
|
22
|
+
import { resolveWebSearchContext, runWebSearch } from "./search";
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* AppKit plugin that resolves and holds the web-search runtime config used
|
|
@@ -30,8 +30,9 @@ export class WebSearchPlugin extends Plugin<WebSearchPluginConfig> {
|
|
|
30
30
|
name: "web-search",
|
|
31
31
|
displayName: "Web Search",
|
|
32
32
|
description:
|
|
33
|
-
"Searches the web
|
|
34
|
-
"
|
|
33
|
+
"Searches the web through the Databricks native web-search tool on its own " +
|
|
34
|
+
"web-capable serving endpoint, and fetches pages via got-scraping, behind an " +
|
|
35
|
+
"optional URL allow-list and per-tool approval gating.",
|
|
35
36
|
stability: "beta",
|
|
36
37
|
resources: {
|
|
37
38
|
required: [],
|
|
@@ -58,13 +59,6 @@ export class WebSearchPlugin extends Plugin<WebSearchPluginConfig> {
|
|
|
58
59
|
});
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
/** Resolve the OBO client + host from the active execution context. */
|
|
62
|
-
private async searchContext(): Promise<WebSearchContext> {
|
|
63
|
-
const ctx = getExecutionContext();
|
|
64
|
-
const host = (await ctx.client.config.getHost()).toString();
|
|
65
|
-
return { client: ctx.client, host };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
62
|
override exports() {
|
|
69
63
|
return {
|
|
70
64
|
/**
|
|
@@ -73,7 +67,7 @@ export class WebSearchPlugin extends Plugin<WebSearchPluginConfig> {
|
|
|
73
67
|
* runtime config primed at setup.
|
|
74
68
|
*/
|
|
75
69
|
search: async (request: WebSearchRequest): Promise<WebSearchResult> =>
|
|
76
|
-
runWebSearch(request, getWebSearchRuntime().config, await
|
|
70
|
+
runWebSearch(request, getWebSearchRuntime().config, await resolveWebSearchContext()),
|
|
77
71
|
/**
|
|
78
72
|
* Fetch one URL directly (bypassing the agent tool). Enforces the
|
|
79
73
|
* configured allow-list. Reads the shared runtime config.
|
package/src/scrape.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import { log } from "@dbx-tools/shared-core";
|
|
22
22
|
import { gotScraping } from "got-scraping";
|
|
23
23
|
import type { ResolvedWebSearchConfig } from "./config";
|
|
24
|
+
import { htmlFragmentToText } from "./html-text";
|
|
24
25
|
import type { WebSearchCitation, WebSearchRequest, WebSearchResult } from "./schema";
|
|
25
26
|
|
|
26
27
|
const logger = log.logger("web-search/scrape");
|
|
@@ -28,20 +29,6 @@ const logger = log.logger("web-search/scrape");
|
|
|
28
29
|
/** DuckDuckGo's no-JS HTML results endpoint (queried via GET). */
|
|
29
30
|
const DDG_HTML_URL = "https://html.duckduckgo.com/html/";
|
|
30
31
|
|
|
31
|
-
/** Strip HTML tags and decode the few entities DDG emits in titles/snippets. */
|
|
32
|
-
function stripTags(html: string): string {
|
|
33
|
-
return html
|
|
34
|
-
.replace(/<[^>]+>/g, "")
|
|
35
|
-
.replace(/&/g, "&")
|
|
36
|
-
.replace(/</g, "<")
|
|
37
|
-
.replace(/>/g, ">")
|
|
38
|
-
.replace(/"/g, '"')
|
|
39
|
-
.replace(/'|'/g, "'")
|
|
40
|
-
.replace(/ /g, " ")
|
|
41
|
-
.replace(/\s+/g, " ")
|
|
42
|
-
.trim();
|
|
43
|
-
}
|
|
44
|
-
|
|
45
32
|
/**
|
|
46
33
|
* DDG wraps result URLs in a redirect (`//duckduckgo.com/l/?uddg=<encoded>`).
|
|
47
34
|
* Unwrap to the real destination; leave already-absolute URLs untouched.
|
|
@@ -66,12 +53,12 @@ function parseDdgHtml(html: string): WebSearchCitation[] {
|
|
|
66
53
|
const snippetRe = /<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>/g;
|
|
67
54
|
const snippets: string[] = [];
|
|
68
55
|
let sm: RegExpExecArray | null;
|
|
69
|
-
while ((sm = snippetRe.exec(html)) !== null) snippets.push(
|
|
56
|
+
while ((sm = snippetRe.exec(html)) !== null) snippets.push(htmlFragmentToText(sm[1] ?? ""));
|
|
70
57
|
let am: RegExpExecArray | null;
|
|
71
58
|
let i = 0;
|
|
72
59
|
while ((am = anchorRe.exec(html)) !== null) {
|
|
73
60
|
const url = unwrapDdgUrl(am[1] ?? "");
|
|
74
|
-
const title =
|
|
61
|
+
const title = htmlFragmentToText(am[2] ?? "");
|
|
75
62
|
if (!url || !title) continue;
|
|
76
63
|
const snippet = snippets[i] ?? "";
|
|
77
64
|
citations.push({ url, title, ...(snippet ? { snippet } : {}) });
|
package/src/search.ts
CHANGED
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
* @module
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
24
|
+
import { getExecutionContext } from "@databricks/appkit";
|
|
25
|
+
import { invoke, resolve, serving } from "@dbx-tools/model";
|
|
26
|
+
import { error, log, string } from "@dbx-tools/shared-core";
|
|
27
|
+
import { openaiChat, openaiResponses } from "@dbx-tools/shared-model";
|
|
27
28
|
import type { ResolvedWebSearchConfig } from "./config";
|
|
28
29
|
import { detectWebSearchProvider, supportsWebSearch, webSearchToolSpec } from "./provider";
|
|
29
30
|
import type { WebSearchCitation, WebSearchRequest, WebSearchResult } from "./schema";
|
|
@@ -40,6 +41,18 @@ export interface WebSearchContext {
|
|
|
40
41
|
host: string;
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the OBO workspace client + host from the active AppKit execution
|
|
46
|
+
* context. Inside `agent.stream`'s `asUser(req)` scope this hits the serving
|
|
47
|
+
* endpoint as the requesting user; outside a user context AppKit falls back to
|
|
48
|
+
* the service principal.
|
|
49
|
+
*/
|
|
50
|
+
export async function resolveWebSearchContext(): Promise<WebSearchContext> {
|
|
51
|
+
const ctx = getExecutionContext();
|
|
52
|
+
const host = (await ctx.client.config.getHost()).toString();
|
|
53
|
+
return { client: ctx.client, host };
|
|
54
|
+
}
|
|
55
|
+
|
|
43
56
|
/**
|
|
44
57
|
* Resolve a web-search-capable model against the LIVE workspace catalogue - so
|
|
45
58
|
* we never return an endpoint id that isn't actually deployed (the "endpoint
|
|
@@ -113,11 +126,6 @@ async function postServing(
|
|
|
113
126
|
|
|
114
127
|
/* --------------------------- response extraction --------------------------- */
|
|
115
128
|
|
|
116
|
-
/** Coerce an unknown to a trimmed string, or "" . */
|
|
117
|
-
function str(v: unknown): string {
|
|
118
|
-
return typeof v === "string" ? v.trim() : "";
|
|
119
|
-
}
|
|
120
|
-
|
|
121
129
|
/**
|
|
122
130
|
* Extract answer text + citations from an OpenAI Responses API payload, via the
|
|
123
131
|
* shared reader in `@dbx-tools/shared-model` (the same module the model-proxy
|
|
@@ -143,17 +151,18 @@ function fromChatPayload(payload: Record<string, unknown>): {
|
|
|
143
151
|
} {
|
|
144
152
|
const choices = Array.isArray(payload.choices) ? (payload.choices as unknown[]) : [];
|
|
145
153
|
const message = (choices[0] as { message?: Record<string, unknown> })?.message ?? {};
|
|
146
|
-
const answer =
|
|
154
|
+
const answer = openaiChat.chatContentToText(message.content);
|
|
147
155
|
const citations: WebSearchCitation[] = [];
|
|
148
156
|
// Best-effort grounding extraction: walk any nested object for {uri|url,title}.
|
|
149
157
|
const seen = new Set<string>();
|
|
150
158
|
const visit = (v: unknown, depth: number): void => {
|
|
151
159
|
if (depth > 6 || v === null || typeof v !== "object") return;
|
|
152
160
|
const o = v as Record<string, unknown>;
|
|
153
|
-
const url =
|
|
161
|
+
const url = string.trimToEmpty(o.url) || string.trimToEmpty(o.uri);
|
|
154
162
|
if (url && !seen.has(url)) {
|
|
155
163
|
seen.add(url);
|
|
156
|
-
|
|
164
|
+
const title = string.trimToEmpty(o.title);
|
|
165
|
+
citations.push({ url, ...(title ? { title } : {}) });
|
|
157
166
|
}
|
|
158
167
|
for (const val of Object.values(o)) {
|
|
159
168
|
if (Array.isArray(val)) val.forEach((x) => visit(x, depth + 1));
|
|
@@ -195,9 +204,7 @@ export async function runWebSearch(
|
|
|
195
204
|
const spec = webSearchToolSpec(provider, config.webSearchTools);
|
|
196
205
|
|
|
197
206
|
const path =
|
|
198
|
-
spec.api === "responses"
|
|
199
|
-
? "/serving-endpoints/responses"
|
|
200
|
-
: "/serving-endpoints/chat/completions";
|
|
207
|
+
spec.api === "responses" ? `/${invoke.RESPONSES_PATH}` : `/${invoke.CHAT_COMPLETIONS_PATH}`;
|
|
201
208
|
const body =
|
|
202
209
|
spec.api === "responses"
|
|
203
210
|
? {
|
package/src/tool.ts
CHANGED
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
* @module
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { getExecutionContext } from "@databricks/appkit";
|
|
22
21
|
import { string } from "@dbx-tools/shared-core";
|
|
23
22
|
import { createTool } from "@mastra/core/tools";
|
|
24
23
|
import { approvalMatches, type ApprovalGate } from "./config";
|
|
@@ -32,7 +31,7 @@ import {
|
|
|
32
31
|
type WebFetchRequest,
|
|
33
32
|
type WebSearchRequest,
|
|
34
33
|
} from "./schema";
|
|
35
|
-
import {
|
|
34
|
+
import { resolveWebSearchContext, runWebSearch } from "./search";
|
|
36
35
|
|
|
37
36
|
/** Options shared by both web tools. */
|
|
38
37
|
export interface WebSearchToolOptions {
|
|
@@ -51,18 +50,6 @@ function effectiveGate(opts: WebSearchToolOptions): ApprovalGate {
|
|
|
51
50
|
return opts.approval ?? getWebSearchRuntime().config.approval;
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
/**
|
|
55
|
-
* Resolve the OBO workspace client + host from the active AppKit execution
|
|
56
|
-
* context. Runs inside `agent.stream`'s `asUser(req)` scope, so the search
|
|
57
|
-
* hits the serving endpoint as the requesting user; outside a user context it
|
|
58
|
-
* falls back to the service principal.
|
|
59
|
-
*/
|
|
60
|
-
async function webSearchContext(): Promise<WebSearchContext> {
|
|
61
|
-
const ctx = getExecutionContext();
|
|
62
|
-
const host = (await ctx.client.config.getHost()).toString();
|
|
63
|
-
return { client: ctx.client, host };
|
|
64
|
-
}
|
|
65
|
-
|
|
66
53
|
/**
|
|
67
54
|
* Build the `web_search` tool. Spread it into the agents that should be able
|
|
68
55
|
* to search the web.
|
|
@@ -99,7 +86,7 @@ export function webSearchTool(opts: WebSearchToolOptions = {}) {
|
|
|
99
86
|
: { requireApproval: () => (typeof gate === "boolean" ? gate : true) }),
|
|
100
87
|
execute: async (input) => {
|
|
101
88
|
const { config } = getWebSearchRuntime();
|
|
102
|
-
return runWebSearch(input as WebSearchRequest, config, await
|
|
89
|
+
return runWebSearch(input as WebSearchRequest, config, await resolveWebSearchContext());
|
|
103
90
|
},
|
|
104
91
|
});
|
|
105
92
|
}
|
package/test/allowlist.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
toUrlAllowList,
|
|
8
8
|
} from "../src/allowlist";
|
|
9
9
|
import { approvalMatches, resolveWebSearchConfig } from "../src/config";
|
|
10
|
-
import { htmlToText } from "../src/
|
|
10
|
+
import { htmlToText } from "../src/html-text";
|
|
11
11
|
import {
|
|
12
12
|
detectWebSearchProvider,
|
|
13
13
|
supportsWebSearch,
|