@shoalrun/cli 0.1.0-alpha.15 → 0.1.0-alpha.17
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/CHANGELOG.md +43 -0
- package/README.md +41 -3
- package/agent-docs/SNAPSHOT.json +5 -5
- package/agent-docs/author/frontmatter-reference.md +15 -11
- package/agent-docs/author/frontmatter-reference.sections/choose-a-publication-and-visibility.md +13 -11
- package/agent-docs/author/frontmatter-reference.sections/control-output-details.md +1 -1
- package/agent-docs/author/frontmatter-reference.sections/global-defaults-and-enforced-values.md +1 -1
- package/agent-docs/author/frontmatter-reference.sections/html-markdown-and-agents.md +1 -1
- package/agent-docs/author/frontmatter-reference.sections/identify-and-route-a-page.md +1 -1
- package/agent-docs/author/frontmatter-reference.sections/point-agents-to-it.md +1 -1
- package/agent-docs/author/frontmatter-reference.sections/project-defined-fields.md +1 -1
- package/agent-docs/author/frontmatter-reference.sections/related-reading.md +1 -1
- package/agent-docs/author/frontmatter-reference.sections/use-the-local-files-while-authoring.md +1 -1
- package/agent-docs/author/frontmatter-reference.sections/when-the-files-update.md +1 -1
- package/agent-docs/author/recipe-drafts.md +2 -2
- package/agent-docs/author/recipe-drafts.sections/1-write-one-reusable-step.md +1 -1
- package/agent-docs/author/recipe-drafts.sections/2-list-the-steps-for-one-complete-goal.md +1 -1
- package/agent-docs/author/recipe-drafts.sections/3-generate-a-draft-and-review-it.md +1 -1
- package/agent-docs/author/recipe-drafts.sections/4-maintain-shoalrun-s-published-recipe-snapshots.md +1 -1
- package/agent-docs/author/recipe-drafts.sections/related-reading.md +1 -1
- package/agent-docs/author/recipe-drafts.sections/start-an-ingredient-from-an-api-example.md +2 -2
- package/agent-docs/author/recipes.md +3 -3
- package/agent-docs/author/recipes.sections/how-agents-use-this.md +2 -2
- package/agent-docs/author/recipes.sections/one-example-from-shoalrun.md +2 -2
- package/agent-docs/author/recipes.sections/related-reading.md +2 -2
- package/agent-docs/author/recipes.sections/try-a-complete-workflow.md +3 -3
- package/agent-docs/author/recipes.sections/use-recipes-for-task-oriented-how-tos.md +2 -2
- package/agent-docs/author/recipes.sections/what-changing-an-ingredient-updates.md +2 -2
- package/agent-docs/author/recipes.sections/what-each-file-does.md +2 -2
- package/agent-docs/author/recipes.sections/what-happens-and-when.md +2 -2
- package/agent-docs/author/search.md +55 -6
- package/agent-docs/author/search.sections/agent-and-mcp-search-scope.md +48 -0
- package/agent-docs/author/search.sections/choose-a-provider.md +7 -6
- package/agent-docs/author/search.sections/cli-and-mcp-ranking-details.md +1 -1
- package/agent-docs/author/search.sections/enable-included-semantic-search.md +66 -0
- package/agent-docs/author/search.sections/hosted-semantic-search-limits.md +1 -1
- package/agent-docs/author/search.sections/html-markdown-and-agents.md +1 -1
- package/agent-docs/author/search.sections/lexical-search.md +1 -1
- package/agent-docs/author/search.sections/local-semantic-preview.md +1 -1
- package/agent-docs/author/search.sections/read-a-search-result-with-an-agent.md +1 -1
- package/agent-docs/author/search.sections/related-reading.md +1 -1
- package/agent-docs/author/search.sections/reuse-the-lexical-index.md +1 -1
- package/agent-docs/author/search.sections/turn-search-off.md +1 -1
- package/agent-docs/author/search.sections/use-the-search-box.md +1 -1
- package/agent-docs/author/search.sections/write-a-custom-provider.md +1 -1
- package/agent-docs/chat/passage-index.md +16 -12
- package/agent-docs/chat/passage-index.sections/build-the-index.md +3 -3
- package/agent-docs/chat/passage-index.sections/embed-passages.md +3 -3
- package/agent-docs/chat/passage-index.sections/how-the-default-chunker-splits-a-page.md +3 -3
- package/agent-docs/chat/passage-index.sections/limits.md +3 -3
- package/agent-docs/chat/passage-index.sections/passage-ids-and-links.md +14 -10
- package/agent-docs/chat/passage-index.sections/related-reading.md +3 -3
- package/agent-docs/chat/passage-index.sections/what-a-passage-contains.md +5 -5
- package/agent-docs/cookbooks/welcome-email.md +12 -9
- package/agent-docs/documents.json +207 -183
- package/agent-docs/provenance.json +4 -4
- package/agent-docs/recipes/queue-email.md +1 -1
- package/agent-docs/recipes/send-welcome-email.md +3 -3
- package/agent-docs/search-index.json +1 -1
- package/agent-docs/start.md +2 -2
- package/agent-docs/start.sections/add-the-page-to-navigation.md +1 -1
- package/agent-docs/start.sections/build-and-check-both-outputs.md +1 -1
- package/agent-docs/start.sections/build-this-site.md +1 -1
- package/agent-docs/start.sections/check-writing-style.md +1 -1
- package/agent-docs/start.sections/create-a-page.md +1 -1
- package/agent-docs/start.sections/make-it-yours.md +1 -1
- package/agent-docs/start.sections/migrating-from-github-packages.md +1 -1
- package/agent-docs/start.sections/prerequisites.md +1 -1
- package/agent-docs/start.sections/related-reading.md +1 -1
- package/agent-docs/start.sections/start-your-own-project.md +2 -2
- package/assets/search-semantic.js +34 -0
- package/dist/ai-directory.d.ts +1 -0
- package/dist/ai-directory.js +12 -0
- package/dist/answer-cards.js +1 -1
- package/dist/artifact-bundle.d.ts +1 -1
- package/dist/artifact-bundle.js +1 -1
- package/dist/ask-function.d.ts +4 -18
- package/dist/ask-function.js +12 -71
- package/dist/ask-runtime.d.ts +45 -0
- package/dist/ask-runtime.js +96 -0
- package/dist/ask-setup.d.ts +3 -42
- package/dist/ask-setup.js +5 -93
- package/dist/cli.js +6 -2
- package/dist/compiler.js +2 -0
- package/dist/embeddings.d.ts +1 -1
- package/dist/embeddings.js +1 -1
- package/dist/function-package.js +5 -0
- package/dist/function-runtime.d.ts +23 -0
- package/dist/function-runtime.js +45 -0
- package/dist/index-build.d.ts +1 -2
- package/dist/index-build.js +10 -14
- package/dist/publication-render.d.ts +1 -1
- package/dist/retrieval-runtime.d.ts +5 -0
- package/dist/retrieval-runtime.js +21 -0
- package/dist/retrieval.d.ts +4 -0
- package/dist/retrieval.js +28 -8
- package/dist/search-function.d.ts +2 -0
- package/dist/search-function.js +30 -0
- package/dist/search-handler.js +12 -12
- package/dist/search-identifier-core.d.ts +4 -0
- package/dist/search-identifier-core.js +30 -0
- package/dist/search-identifier.d.ts +3 -2
- package/dist/search-identifier.js +7 -24
- package/dist/search-literal.js +1 -1
- package/dist/search-results.d.ts +1 -1
- package/dist/search-results.js +39 -25
- package/dist/search-snippet.d.ts +1 -1
- package/dist/search-snippet.js +2 -14
- package/dist/search-text.d.ts +1 -0
- package/dist/search-text.js +14 -0
- package/dist/vercel-function.js +10 -2
- package/package.json +1 -1
- package/skills/shoalrun-setup/SKILL.md +2 -0
package/dist/ask-setup.js
CHANGED
|
@@ -1,99 +1,11 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { AnswerCache } from './answer-cache.js';
|
|
4
|
-
import { CardIndex, readCards } from './answer-cards.js';
|
|
5
|
-
import { chatProviderConfig, chatRoutes, validateChat } from './chat-config.js';
|
|
6
|
-
import { createChatProvider, fallbackChat } from './chat.js';
|
|
7
1
|
import { loadConfig } from './config.js';
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import { jevAvailable, jevEndpoint } from './jev.js';
|
|
13
|
-
import { queryProviderConfig, validateRetrieval } from './retrieval-config.js';
|
|
14
|
-
import { Retriever } from './retrieval.js';
|
|
15
|
-
// `--reader-view audience=authors,maintainers`, repeatable for more than one filter.
|
|
16
|
-
export function parseReaderView(specs) {
|
|
17
|
-
if (!specs.length)
|
|
18
|
-
return undefined;
|
|
19
|
-
const selection = {};
|
|
20
|
-
for (const spec of specs) {
|
|
21
|
-
const match = /^([a-z][a-z0-9-]*)=(.+)$/.exec(spec);
|
|
22
|
-
if (!match)
|
|
23
|
-
throw new Error(`--reader-view needs facet=choice[,choice], such as audience=authors; got ${JSON.stringify(spec)}`);
|
|
24
|
-
if (selection[match[1]])
|
|
25
|
-
throw new Error(`--reader-view names ${match[1]} twice`);
|
|
26
|
-
selection[match[1]] = match[2].split(',').map(choice => choice.trim()).filter(Boolean);
|
|
27
|
-
}
|
|
28
|
-
return selection;
|
|
29
|
-
}
|
|
30
|
-
// Everything `shoalrun ask` and the answer eval share: read the project's chat and retrieval settings, load the passages and any
|
|
31
|
-
// vectors, and wire up the optional layers. A layer that isn't configured is left out, and one that is configured but can't run
|
|
32
|
-
// (Jev without a key) is left out with a note.
|
|
2
|
+
import { prepareAskRuntime } from './ask-runtime.js';
|
|
3
|
+
export { parseReaderView } from './ask-runtime.js';
|
|
4
|
+
// Local authoring/evaluation accepts a source config. Hosted functions import
|
|
5
|
+
// ask-runtime directly and receive already-resolved settings instead.
|
|
33
6
|
export async function prepareAsk(input) {
|
|
34
7
|
if (!input.settings && !input.configFile)
|
|
35
8
|
throw new Error('prepareAsk needs a config file or settings');
|
|
36
9
|
const settings = input.settings ?? (await loadConfig(input.configFile)).value;
|
|
37
|
-
|
|
38
|
-
const chatConfig = validateChat(settings.chat, readerFilters);
|
|
39
|
-
const retrieval = validateRetrieval(settings.retrieval);
|
|
40
|
-
// --model replaces the main model only. Fallback and escalation models still inherit the config's settings.
|
|
41
|
-
const mainConfig = chatProviderConfig(chatConfig, { model: input.model });
|
|
42
|
-
const routes = chatRoutes(chatConfig, chatProviderConfig(chatConfig));
|
|
43
|
-
const chat = fallbackChat([createChatProvider(mainConfig), ...routes.fallbacks.map(createChatProvider)]);
|
|
44
|
-
let chunks;
|
|
45
|
-
try {
|
|
46
|
-
chunks = JSON.parse(await readFile(path.join(input.siteDirectory, 'ai', 'chunks.json'), 'utf8'));
|
|
47
|
-
}
|
|
48
|
-
catch (error) {
|
|
49
|
-
throw error.code === 'ENOENT' ? new Error(`No ai/chunks.json in ${input.siteDirectory}; run shoalrun index first`) : error;
|
|
50
|
-
}
|
|
51
|
-
const embeddings = await readEmbeddings(input.siteDirectory);
|
|
52
|
-
const retriever = new Retriever(chunks, embeddings);
|
|
53
|
-
const variants = retriever.variants();
|
|
54
|
-
const variant = input.channel && input.version && input.language ? `${input.channel}|${input.version}|${input.language}` : variants.length === 1 ? variants[0] : undefined;
|
|
55
|
-
if (!variant)
|
|
56
|
-
throw new Error(`The site has several publications (${variants.join(', ')}); pass --channel, --version, and --language`);
|
|
57
|
-
let embedQuery;
|
|
58
|
-
let embedTexts;
|
|
59
|
-
let embedding;
|
|
60
|
-
if (embeddings) {
|
|
61
|
-
const queryProvider = createProvider(queryProviderConfig(retrieval, embeddings.meta));
|
|
62
|
-
if (queryProvider.space !== embeddings.meta.space || (embeddings.meta.backend && queryProvider.backend !== embeddings.meta.backend))
|
|
63
|
-
throw new Error(`${queryProvider.model} is not in the embedding space or backend of ${embeddings.meta.model}; set retrieval.embeddings.queryModel and baseUrl to compatible values`);
|
|
64
|
-
embedQuery = async (text, signal) => (await queryProvider.embed([text], 'query', signal))[0];
|
|
65
|
-
embedTexts = texts => queryProvider.embed(texts, 'query');
|
|
66
|
-
embedding = { provider: queryProvider.name, model: queryProvider.model, space: queryProvider.space, dimension: queryProvider.dimension };
|
|
67
|
-
}
|
|
68
|
-
// A reader view on the command line replaces the config's, so one run can answer for a different audience.
|
|
69
|
-
if (input.readerView && !readerFilters)
|
|
70
|
-
throw new Error('--reader-view needs readerFilters.facets in the site config');
|
|
71
|
-
const readerSelection = input.readerView ? validateReaderSelection(input.readerView, readerFilters, '--reader-view') : chatConfig?.readerView;
|
|
72
|
-
const notes = [];
|
|
73
|
-
const rerank = Boolean(chatConfig?.rerank) && !input.noRerank;
|
|
74
|
-
const scoring = chatConfig?.rerank;
|
|
75
|
-
const scorer = { ...(scoring?.endpoint ? { endpoint: jevEndpoint(scoring.endpoint) } : {}), ...(scoring?.model ? { model: scoring.model } : {}), ...(scoring?.keyEnv ? { keyEnv: scoring.keyEnv } : {}), ...(scoring?.stateFormat ? { stateFormat: scoring.stateFormat } : {}), ...(input.jevCacheDir ? { cacheDir: input.jevCacheDir } : {}) };
|
|
76
|
-
const scoringReady = jevAvailable(scoring?.keyEnv);
|
|
77
|
-
// A scorer that is configured but has no key counts as down, not as absent: it is what keeps off-topic questions from the model.
|
|
78
|
-
if (rerank && !scoringReady)
|
|
79
|
-
notes.push(`The relevance scorer has no key: set ${scoring?.keyEnv ?? 'JEV_API_KEY'}. Until then it counts as down, so ${scoring?.onError === 'answer' ? 'the model answers without the gate and the claim check' : 'questions get the closest pages and no model call'}`);
|
|
80
|
-
// Answer cards need semantic search and Jev. A configured but missing card file is a note, not an error, so the chat still answers.
|
|
81
|
-
let cards;
|
|
82
|
-
if (chatConfig?.cards && !input.noCards && rerank && scoringReady) {
|
|
83
|
-
const file = await readCards(input.siteDirectory);
|
|
84
|
-
if (!file)
|
|
85
|
-
notes.push('chat.cards is set but there is no ai/answers.json; run shoalrun answers to make it');
|
|
86
|
-
else if (!embedding || file.embedding.space !== embedding.space || file.embedding.dimension !== embedding.dimension)
|
|
87
|
-
notes.push('The answer cards were made with a different embedding model, so they are not used; run shoalrun answers again');
|
|
88
|
-
else
|
|
89
|
-
cards = { index: new CardIndex(file), threshold: chatConfig.cards.threshold, confirm: chatConfig.cards.confirm, jev: { ...scorer, maxCalls: 1 } };
|
|
90
|
-
}
|
|
91
|
-
// The shared store is for a hosted function. A local run keeps its counters in memory, so it never needs the store's keys.
|
|
92
|
-
const store = input.sharedStore && chatConfig?.budget?.store ? envBudgetStore(chatConfig.budget.store) : memoryBudgetStore();
|
|
93
|
-
const reported = new Set();
|
|
94
|
-
const onBudgetError = (error) => { if (!reported.has(error.message)) {
|
|
95
|
-
reported.add(error.message);
|
|
96
|
-
process.stderr.write(`Budget: ${error.message}. Questions get the closest pages, with no model call, until it works.\n`);
|
|
97
|
-
} };
|
|
98
|
-
return { ...(chatConfig?.budget ? { budget: new DailyBudget(store, chatConfig.budget, Date.now, onBudgetError), budgetStore: store } : {}), chat, notes, embedTexts, embedding, options: { ...(scoring?.onError ? { onScorerError: scoring.onError } : {}), retriever, chat, variant, readerSelection, embedQuery, passages: input.passages ?? chatConfig?.passages, gate: chatConfig?.gate, ...(input.maxTokens ? { maxTokens: input.maxTokens } : {}), ...(rerank ? { jev: { ...scorer, top: 24, maxCalls: 2 } } : {}), ...(cards ? { cards } : {}), ...(input.cache && chatConfig?.cache ? { cache: new AnswerCache({ maxEntries: chatConfig.cache.maxEntries, ttlMs: chatConfig.cache.ttlMinutes * 60_000 }) } : {}), ...(routes.escalate && !input.noEscalate ? { escalate: { chat: createChatProvider(routes.escalate.config), when: routes.escalate.when } } : {}), ...(rerank && scoringReady && !input.noDirect && (input.direct || chatConfig?.direct) ? { direct: { threshold: input.direct?.threshold ?? chatConfig.direct.threshold, maxChars: input.direct?.maxChars ?? chatConfig?.direct?.maxChars ?? 1200, margin: chatConfig?.direct?.margin ?? 0.1, others: chatConfig?.direct?.others ?? 1, jev: { ...scorer, maxCalls: 2 } } } : {}), ...(rerank && scoringReady && chatConfig?.verify ? { verify: { ...chatConfig.verify, jev: { ...scorer, maxCalls: 3 } } } : {}) } };
|
|
10
|
+
return prepareAskRuntime({ ...input, settings });
|
|
99
11
|
}
|
package/dist/cli.js
CHANGED
|
@@ -238,7 +238,7 @@ try {
|
|
|
238
238
|
embedQuery = async (query, signal) => (await provider.embed([query], 'query', signal))[0];
|
|
239
239
|
}
|
|
240
240
|
extras = { search: createSearchHandler({ retriever, embedQuery, hosts, readerFilters: validateReaderFilters(settings.readerFilters), pageSummaries }) };
|
|
241
|
-
banner = `Search is on: ${embeddings ? '
|
|
241
|
+
banner = `Search is on: ${embeddings ? 'semantic retrieval' : 'lexical retrieval; run shoalrun index --embed for semantic results'}.\n`;
|
|
242
242
|
}
|
|
243
243
|
if (ask) {
|
|
244
244
|
// The preview spends your API credits on every question, so it answers only on this machine.
|
|
@@ -425,7 +425,11 @@ try {
|
|
|
425
425
|
const provider = createProvider(embeddingProviderConfig(retrieval, { provider: values['--provider'], model: values['--model'], dimension: values['--dimension'] === undefined ? undefined : Number(values['--dimension']), baseUrl: values['--base-url'], keyEnv: values['--key-env'] }));
|
|
426
426
|
embeddings = await embedChunkIndex(subcommand, result.index, provider, embeddingInput);
|
|
427
427
|
}
|
|
428
|
-
|
|
428
|
+
const counts = new Map();
|
|
429
|
+
for (const chunk of result.index.chunks)
|
|
430
|
+
counts.set(chunk.variant, (counts.get(chunk.variant) ?? 0) + 1);
|
|
431
|
+
const publications = [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([variant, passages]) => ({ variant, passages }));
|
|
432
|
+
process.stdout.write(JSON.stringify({ status: 'ok', documents: result.documents, chunks: result.index.chunks.length, sourceDigest: result.index.sourceDigest, publications, output: result.output, ...(embeddings ? { embeddings } : {}) }, null, 2) + '\n');
|
|
429
433
|
}
|
|
430
434
|
else if (command === 'ask') {
|
|
431
435
|
const usage = 'Usage: shoalrun ask <site-directory> "<question>" [--config FILE] [--model MODEL] [--passages N] [--reader-view FACET=CHOICE[,CHOICE]]... [--channel C --version V --language L] [--no-rerank] [--no-direct] [--no-escalate] [--no-cards] [--json]';
|
package/dist/compiler.js
CHANGED
|
@@ -66,6 +66,8 @@ function validateSearch(value, declaringFile) {
|
|
|
66
66
|
return { provider, index: false };
|
|
67
67
|
if (provider === 'lexical')
|
|
68
68
|
return { provider, module: builtInSearchProvider, index: true };
|
|
69
|
+
if (provider === 'semantic')
|
|
70
|
+
return { provider, module: path.resolve(path.dirname(builtInSearchProvider), 'search-semantic.js'), index: true };
|
|
69
71
|
if (provider !== 'custom')
|
|
70
72
|
throw new Error(`Unknown search provider ${provider}`);
|
|
71
73
|
if (typeof config.module !== 'string' || !/\.m?js$/.test(config.module))
|
package/dist/embeddings.d.ts
CHANGED
package/dist/embeddings.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile, realpath, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { aiDirectory } from './
|
|
4
|
+
import { aiDirectory } from './ai-directory.js';
|
|
5
5
|
import { postJson, throwIfCancelled } from './provider-http.js';
|
|
6
6
|
const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
7
7
|
function normalize(vector) {
|
package/dist/function-package.js
CHANGED
|
@@ -55,4 +55,9 @@ export async function copyFunctionRuntime(dist, entry, target) {
|
|
|
55
55
|
const done = new Set();
|
|
56
56
|
for (const name of packages)
|
|
57
57
|
await copyPackage(name, dist, target, done);
|
|
58
|
+
// Preserve the runtime author's license as well as the licenses retained in
|
|
59
|
+
// copied dependency packages. Custom runtime directories may have no parent license.
|
|
60
|
+
const license = path.join(dist, '..', 'LICENSE');
|
|
61
|
+
if (await stat(license).then(info => info.isFile(), () => false))
|
|
62
|
+
await cp(license, path.join(target, 'LICENSE.shoalrun'));
|
|
58
63
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
export type FunctionRuntime = {
|
|
3
|
+
schemaVersion: 1;
|
|
4
|
+
settings: Record<string, unknown>;
|
|
5
|
+
variant?: {
|
|
6
|
+
channel: string;
|
|
7
|
+
version: string;
|
|
8
|
+
language: string;
|
|
9
|
+
};
|
|
10
|
+
hosts?: string[];
|
|
11
|
+
callerHeader?: string;
|
|
12
|
+
maxRequestsPerMinute?: number;
|
|
13
|
+
searchStore?: {
|
|
14
|
+
provider: 'upstash';
|
|
15
|
+
urlEnv: string;
|
|
16
|
+
tokenEnv: string;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
export type AskFunction = (request: Request) => Promise<Response>;
|
|
20
|
+
export declare function allowedHosts(runtime: FunctionRuntime, env: NodeJS.ProcessEnv): string[];
|
|
21
|
+
export declare function nodeAdapter(load: () => Promise<AskFunction>, limits?: {
|
|
22
|
+
bodyBytes: number;
|
|
23
|
+
}): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function allowedHosts(runtime, env) {
|
|
2
|
+
return [...new Set([...(runtime.hosts ?? []), env.VERCEL_URL, env.VERCEL_BRANCH_URL, env.VERCEL_PROJECT_PRODUCTION_URL, ...(env.SHOALRUN_ASK_HOSTS?.split(',') ?? [])].map(host => host?.trim().toLowerCase()).filter((host) => Boolean(host)))];
|
|
3
|
+
}
|
|
4
|
+
const failure = (status, message) => new Response(JSON.stringify({ error: message }), { status, headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
|
|
5
|
+
// Vercel's Node runtime calls `(req, res)`. This turns that into a Web request and back. Starting up happens on the first request, and a
|
|
6
|
+
// failed start is tried again on the next one. The reader never sees why it failed; the reason goes to the function log.
|
|
7
|
+
export function nodeAdapter(load, limits = { bodyBytes: 32_768 }) {
|
|
8
|
+
let ready;
|
|
9
|
+
const write = async (res, response) => {
|
|
10
|
+
res.statusCode = response.status;
|
|
11
|
+
response.headers.forEach((value, name) => { if (name !== 'set-cookie')
|
|
12
|
+
res.setHeader(name, value); });
|
|
13
|
+
const cookies = response.headers.getSetCookie();
|
|
14
|
+
if (cookies.length)
|
|
15
|
+
res.setHeader('set-cookie', cookies);
|
|
16
|
+
res.end(Buffer.from(await response.arrayBuffer()));
|
|
17
|
+
};
|
|
18
|
+
return async (req, res) => {
|
|
19
|
+
try {
|
|
20
|
+
const handler = await (ready ??= load().catch(error => { ready = undefined; throw error; }));
|
|
21
|
+
const chunks = [];
|
|
22
|
+
let size = 0;
|
|
23
|
+
for await (const chunk of req) {
|
|
24
|
+
size += chunk.length;
|
|
25
|
+
if (size > limits.bodyBytes) {
|
|
26
|
+
await write(res, failure(413, 'The request is too large.'));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
chunks.push(chunk);
|
|
30
|
+
}
|
|
31
|
+
const headers = new Headers();
|
|
32
|
+
for (const [name, value] of Object.entries(req.headers))
|
|
33
|
+
for (const item of Array.isArray(value) ? value : value === undefined ? [] : [value])
|
|
34
|
+
headers.append(name, item);
|
|
35
|
+
const method = req.method ?? 'GET';
|
|
36
|
+
const request = new Request(`https://${req.headers.host ?? 'localhost'}${req.url ?? '/'}`, { method, headers, ...(method === 'GET' || method === 'HEAD' ? {} : { body: Buffer.concat(chunks) }) });
|
|
37
|
+
await write(res, await handler(request));
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
console.error(`The answer function failed: ${error.message}`);
|
|
41
|
+
if (!res.headersSent)
|
|
42
|
+
await write(res, failure(503, 'The chat is unavailable right now.'));
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
package/dist/index-build.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { aiDirectory } from './ai-directory.js';
|
|
1
2
|
import { type PreparedSearchPassage } from './search-snippet.js';
|
|
2
3
|
export type Chunk = {
|
|
3
4
|
id: string;
|
|
@@ -46,10 +47,8 @@ export declare function chunkMarkdown(markdown: string, document: ChunkSource, a
|
|
|
46
47
|
id: string;
|
|
47
48
|
title: string;
|
|
48
49
|
}>, options?: ChunkOptions): Chunk[];
|
|
49
|
-
export declare function aiDirectory(directory: string): Promise<string>;
|
|
50
50
|
export declare function buildChunkIndex(directory: string, options?: ChunkOptions): Promise<{
|
|
51
51
|
index: ChunkIndex;
|
|
52
52
|
output: string;
|
|
53
53
|
documents: number;
|
|
54
54
|
}>;
|
|
55
|
-
export {};
|
package/dist/index-build.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { aiDirectory } from './ai-directory.js';
|
|
2
|
+
export { aiDirectory } from './ai-directory.js';
|
|
1
3
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
4
|
+
import { readFile, realpath, writeFile } from 'node:fs/promises';
|
|
3
5
|
import path from 'node:path';
|
|
4
6
|
import { validateSearchSynonyms } from './search-synonyms.js';
|
|
5
7
|
import { prepareSearchPassage } from './search-snippet.js';
|
|
@@ -164,7 +166,9 @@ export function chunkMarkdown(markdown, document, anchors = [], options = {}) {
|
|
|
164
166
|
return chunks;
|
|
165
167
|
}
|
|
166
168
|
function makeChunk(document, headingPath, anchor, text, used, part = 0) {
|
|
167
|
-
|
|
169
|
+
// Logical page IDs repeat across releases. Stored passage IDs must not: vector
|
|
170
|
+
// rows and answer citations are keyed globally within this site's index.
|
|
171
|
+
const base = `${encodeURIComponent(document.variant)}:${encodeURIComponent(document.id)}#${slug(headingPath.join(' ')) || 'intro'}${part ? `~${part + 1}` : ''}`;
|
|
168
172
|
const seen = used.get(base) ?? 0;
|
|
169
173
|
used.set(base, seen + 1);
|
|
170
174
|
const chunk = { id: seen ? `${base}-${seen + 1}` : base, docId: document.id, title: document.title, type: document.type, variant: document.variant, headingPath, url: anchor ? `${document.url}#${anchor}` : document.url, markdownUrl: document.markdownUrl, text };
|
|
@@ -185,16 +189,6 @@ async function readJson(file) {
|
|
|
185
189
|
}
|
|
186
190
|
// The directory that holds the passage and vector files. It is created on demand, and it must stay inside the site even if
|
|
187
191
|
// `ai` is a symlink.
|
|
188
|
-
export async function aiDirectory(directory) {
|
|
189
|
-
const root = path.resolve(directory);
|
|
190
|
-
const target = path.join(root, 'ai');
|
|
191
|
-
await mkdir(target, { recursive: true });
|
|
192
|
-
const real = await realpath(root);
|
|
193
|
-
const inside = await realpath(target);
|
|
194
|
-
if (inside !== real && !inside.startsWith(real + path.sep))
|
|
195
|
-
throw new Error('The ai directory leaves the site directory');
|
|
196
|
-
return target;
|
|
197
|
-
}
|
|
198
192
|
export async function buildChunkIndex(directory, options = {}) {
|
|
199
193
|
const root = path.resolve(directory);
|
|
200
194
|
const manifest = await readJson(path.join(root, 'documents.json'));
|
|
@@ -219,8 +213,10 @@ export async function buildChunkIndex(directory, options = {}) {
|
|
|
219
213
|
const { channel, version, language } = document.variant;
|
|
220
214
|
chunks.push(...chunkMarkdown(markdown, { ...document, variant: `${channel}|${version}|${language}` }, anchors.get(document.url), options));
|
|
221
215
|
}
|
|
222
|
-
|
|
223
|
-
|
|
216
|
+
// Generation identity includes publication membership, routes, reader marks,
|
|
217
|
+
// and prepared search evidence. Embedding digests remain content-only.
|
|
218
|
+
const source = chunks.map(({ id, variant, digest, url, markdownUrl, readerFilters, search }) => ({ id, variant, digest, url, markdownUrl, readerFilters: Object.fromEntries(Object.entries(readerFilters ?? {}).sort(([a], [b]) => a.localeCompare(b)).map(([facet, values]) => [facet, [...values].sort()])), search })).sort((a, b) => a.id.localeCompare(b.id));
|
|
219
|
+
const index = { schemaVersion: 1, sourceDigest: sha(JSON.stringify({ identityVersion: 2, source, synonyms })), chunks, ...(synonyms.length ? { synonyms } : {}) };
|
|
224
220
|
const output = path.join(await aiDirectory(root), 'chunks.json');
|
|
225
221
|
await writeFile(output, JSON.stringify(index) + '\n');
|
|
226
222
|
return { index, output, documents: manifest.documents.length };
|
|
@@ -52,7 +52,7 @@ export declare function renderPublicationPages({ rendered, docs, relationships,
|
|
|
52
52
|
header: HeaderConfig;
|
|
53
53
|
footer?: FooterConfig;
|
|
54
54
|
search: {
|
|
55
|
-
provider: 'lexical' | 'custom' | 'none';
|
|
55
|
+
provider: 'lexical' | 'semantic' | 'custom' | 'none';
|
|
56
56
|
};
|
|
57
57
|
readerFilters?: ReaderFiltersConfig;
|
|
58
58
|
variants: Variant[];
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Retriever } from './retrieval.js';
|
|
2
|
+
export declare function prepareSearchRetrieval(directory: string, settings: Record<string, unknown>): Promise<{
|
|
3
|
+
retriever: Retriever;
|
|
4
|
+
embedQuery: ((query: string, signal?: AbortSignal) => Promise<Float32Array>) | undefined;
|
|
5
|
+
}>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createProvider, readEmbeddings } from './embeddings.js';
|
|
4
|
+
import { queryProviderConfig, validateRetrieval } from './retrieval-config.js';
|
|
5
|
+
import { Retriever } from './retrieval.js';
|
|
6
|
+
// Search needs only passage retrieval and query embeddings, never chat models,
|
|
7
|
+
// answer cards, source configuration, rendering, or component compilation.
|
|
8
|
+
export async function prepareSearchRetrieval(directory, settings) {
|
|
9
|
+
const retrieval = validateRetrieval(settings.retrieval);
|
|
10
|
+
const chunks = JSON.parse(await readFile(path.join(directory, 'ai', 'chunks.json'), 'utf8'));
|
|
11
|
+
const embeddings = await readEmbeddings(directory);
|
|
12
|
+
const retriever = new Retriever(chunks, embeddings);
|
|
13
|
+
let embedQuery;
|
|
14
|
+
if (embeddings) {
|
|
15
|
+
const provider = createProvider(queryProviderConfig(retrieval, embeddings.meta));
|
|
16
|
+
if (provider.space !== embeddings.meta.space || (embeddings.meta.backend && provider.backend !== embeddings.meta.backend))
|
|
17
|
+
throw new Error(`${provider.model} is not in the embedding space or backend of ${embeddings.meta.model}`);
|
|
18
|
+
embedQuery = async (query, signal) => (await provider.embed([query], 'query', signal))[0];
|
|
19
|
+
}
|
|
20
|
+
return { retriever, embedQuery };
|
|
21
|
+
}
|
package/dist/retrieval.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ export declare class LexicalIndex {
|
|
|
21
21
|
private readonly average;
|
|
22
22
|
constructor(chunks: Chunk[], synonyms?: SynonymGroups);
|
|
23
23
|
search(query: string, limit?: number, keep?: Keep): Hit[];
|
|
24
|
+
scan(query: string, keep?: Keep): IterableIterator<Hit>;
|
|
24
25
|
}
|
|
25
26
|
type VectorRow = {
|
|
26
27
|
chunk: Chunk;
|
|
@@ -30,7 +31,9 @@ export declare class VectorIndex {
|
|
|
30
31
|
private readonly rows;
|
|
31
32
|
constructor(rows: VectorRow[]);
|
|
32
33
|
search(query: Float32Array, limit?: number, keep?: Keep): Hit[];
|
|
34
|
+
scan(query: Float32Array, keep?: Keep): IterableIterator<Hit>;
|
|
33
35
|
}
|
|
36
|
+
export declare const compareHits: (left: Hit, right: Hit) => number;
|
|
34
37
|
export declare function fuse(lists: Hit[][], limit?: number, k?: number): Hit[];
|
|
35
38
|
export type Coverage = {
|
|
36
39
|
total: number;
|
|
@@ -53,6 +56,7 @@ export declare class Retriever {
|
|
|
53
56
|
private variant;
|
|
54
57
|
pageIntroduction(docId: string, options?: LexicalOptions): Chunk | undefined;
|
|
55
58
|
lexical(query: string, options?: LexicalOptions): Hit[];
|
|
59
|
+
searchPassages(query: string, queryVector: Float32Array | undefined, options?: LexicalOptions): Iterable<Hit>;
|
|
56
60
|
vector(queryVector: Float32Array, options?: LexicalOptions): Hit[];
|
|
57
61
|
hasVectors(options?: LexicalOptions): boolean;
|
|
58
62
|
hybrid(query: string, queryVector: Float32Array | undefined, options?: LexicalOptions): Hit[];
|
package/dist/retrieval.js
CHANGED
|
@@ -67,10 +67,12 @@ export class LexicalIndex {
|
|
|
67
67
|
this.average = this.entries.reduce((sum, entry) => sum + entry.length, 0) / (this.entries.length || 1);
|
|
68
68
|
}
|
|
69
69
|
search(query, limit = 8, keep) {
|
|
70
|
+
return [...this.scan(query, keep)].sort(compareHits).slice(0, limit);
|
|
71
|
+
}
|
|
72
|
+
*scan(query, keep) {
|
|
70
73
|
const terms = [...new Set(tokenize(query))];
|
|
71
74
|
const units = queryUnits(query, this.synonyms).filter(unit => unit.alternatives.length);
|
|
72
75
|
const total = this.entries.length;
|
|
73
|
-
const hits = [];
|
|
74
76
|
for (const entry of this.entries) {
|
|
75
77
|
if (keep && !keep(entry.chunk))
|
|
76
78
|
continue;
|
|
@@ -93,9 +95,8 @@ export class LexicalIndex {
|
|
|
93
95
|
}
|
|
94
96
|
}
|
|
95
97
|
if (score > 0)
|
|
96
|
-
|
|
98
|
+
yield { chunk: entry.chunk, score };
|
|
97
99
|
}
|
|
98
|
-
return hits.sort((left, right) => right.score - left.score || left.chunk.id.localeCompare(right.chunk.id)).slice(0, limit);
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
function unit(vector) {
|
|
@@ -113,8 +114,10 @@ export class VectorIndex {
|
|
|
113
114
|
this.rows = rows;
|
|
114
115
|
}
|
|
115
116
|
search(query, limit = 8, keep) {
|
|
117
|
+
return [...this.scan(query, keep)].sort(compareHits).slice(0, limit);
|
|
118
|
+
}
|
|
119
|
+
*scan(query, keep) {
|
|
116
120
|
const q = unit(query);
|
|
117
|
-
const hits = [];
|
|
118
121
|
for (const row of this.rows) {
|
|
119
122
|
if (keep && !keep(row.chunk))
|
|
120
123
|
continue;
|
|
@@ -123,11 +126,11 @@ export class VectorIndex {
|
|
|
123
126
|
let score = 0;
|
|
124
127
|
for (let at = 0; at < q.length; at++)
|
|
125
128
|
score += q[at] * row.vector[at];
|
|
126
|
-
|
|
129
|
+
yield { chunk: row.chunk, score };
|
|
127
130
|
}
|
|
128
|
-
return hits.sort((left, right) => right.score - left.score || left.chunk.id.localeCompare(right.chunk.id)).slice(0, limit);
|
|
129
131
|
}
|
|
130
132
|
}
|
|
133
|
+
export const compareHits = (left, right) => right.score - left.score || left.chunk.id.localeCompare(right.chunk.id);
|
|
131
134
|
// Reciprocal rank fusion. Lexical and vector scores are not comparable, so only ranks are combined.
|
|
132
135
|
export function fuse(lists, limit = 8, k = 60) {
|
|
133
136
|
const scores = new Map();
|
|
@@ -149,10 +152,16 @@ export class Retriever {
|
|
|
149
152
|
constructor(index, embeddings) {
|
|
150
153
|
if (index?.schemaVersion !== 1 || !Array.isArray(index.chunks))
|
|
151
154
|
throw new Error('Unsupported or invalid chunks.json');
|
|
155
|
+
if (new Set(index.chunks.map(chunk => chunk.id)).size !== index.chunks.length)
|
|
156
|
+
throw new Error('Ambiguous passage IDs in chunks.json; run shoalrun index <site-directory> to rebuild publication-qualified IDs. Existing passage embeddings can be reused.');
|
|
152
157
|
const synonyms = validateSearchSynonyms(index.synonyms);
|
|
153
158
|
const groups = new Map();
|
|
154
|
-
for (const chunk of index.chunks)
|
|
155
|
-
|
|
159
|
+
for (const chunk of index.chunks) {
|
|
160
|
+
let passages = groups.get(chunk.variant);
|
|
161
|
+
if (!passages)
|
|
162
|
+
groups.set(chunk.variant, passages = []);
|
|
163
|
+
passages.push(chunk);
|
|
164
|
+
}
|
|
156
165
|
this.counts = { total: index.chunks.length, embedded: 0, stale: 0, ...(embeddings ? { model: embeddings.meta.model, dimension: embeddings.meta.dimension } : {}) };
|
|
157
166
|
const rowOf = new Map((embeddings?.meta.ids ?? []).map((id, row) => [id, row]));
|
|
158
167
|
for (const chunk of index.chunks)
|
|
@@ -209,6 +218,17 @@ export class Retriever {
|
|
|
209
218
|
lexical(query, options = {}) {
|
|
210
219
|
return this.byVariant.get(this.variant(options)).search(query, options.limit, keeping(options));
|
|
211
220
|
}
|
|
221
|
+
// File-backed search still scans its selected publication, but streams scores
|
|
222
|
+
// into page selection instead of sorting/materializing every passage first.
|
|
223
|
+
searchPassages(query, queryVector, options = {}) {
|
|
224
|
+
const variant = this.variant(options);
|
|
225
|
+
if (!queryVector)
|
|
226
|
+
return this.byVariant.get(variant).scan(query, keeping(options));
|
|
227
|
+
const index = this.vectors.get(variant);
|
|
228
|
+
if (!index)
|
|
229
|
+
throw new Error('No current embeddings for this publication; run shoalrun index --embed');
|
|
230
|
+
return index.scan(queryVector, keeping(options));
|
|
231
|
+
}
|
|
212
232
|
vector(queryVector, options = {}) {
|
|
213
233
|
const index = this.vectors.get(this.variant(options));
|
|
214
234
|
if (!index)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { DailyBudget, SharedRateLimit } from './budget.js';
|
|
4
|
+
import { envBudgetStore } from './budget-store.js';
|
|
5
|
+
import { validateReaderFilters } from './reader-filters.js';
|
|
6
|
+
import { createSearchHandler } from './search-handler.js';
|
|
7
|
+
import { validateSearch } from './search-config.js';
|
|
8
|
+
import { prepareSearchRetrieval } from './retrieval-runtime.js';
|
|
9
|
+
import { allowedHosts } from './function-runtime.js';
|
|
10
|
+
export async function createHostedSearchFunction(directory, env = process.env) {
|
|
11
|
+
const runtime = JSON.parse(await readFile(path.join(directory, 'runtime.json'), 'utf8'));
|
|
12
|
+
if (runtime?.schemaVersion !== 1 || !runtime.settings)
|
|
13
|
+
throw new Error('runtime.json is not a Shoalrun search function file');
|
|
14
|
+
const filters = validateReaderFilters(runtime.settings.readerFilters);
|
|
15
|
+
// Accept old function metadata while new builds omit the chat configuration.
|
|
16
|
+
const legacy = runtime.settings.chat;
|
|
17
|
+
const settings = runtime.searchStore ?? legacy?.budget?.store;
|
|
18
|
+
if (!settings || typeof settings !== 'object' || !('provider' in settings) || settings.provider !== 'upstash' || !('urlEnv' in settings) || typeof settings.urlEnv !== 'string' || !('tokenEnv' in settings) || typeof settings.tokenEnv !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(settings.urlEnv) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(settings.tokenEnv))
|
|
19
|
+
throw new Error('Hosted semantic search needs a shared budget store');
|
|
20
|
+
const setup = await prepareSearchRetrieval(path.join(directory, 'site'), runtime.settings);
|
|
21
|
+
const store = envBudgetStore({ urlEnv: settings.urlEnv, tokenEnv: settings.tokenEnv }, env);
|
|
22
|
+
const { limits } = validateSearch(runtime.settings.search);
|
|
23
|
+
return createSearchHandler({
|
|
24
|
+
...setup, readerFilters: filters, hosts: allowedHosts(runtime, env),
|
|
25
|
+
onFallback: reason => console.warn(`Search fallback: ${reason}`),
|
|
26
|
+
rateLimiter: new SharedRateLimit(store, limits.perCallerPerMinute, Date.now, error => console.error(`Search rate limit: ${error.message}`), 'search-rate'),
|
|
27
|
+
budget: new DailyBudget(store, { perCaller: limits.perCallerPerDay, perDay: limits.perDay }, Date.now, error => console.error(`Search budget: ${error.message}`), 'search-budget'),
|
|
28
|
+
clientKey: request => request.headers.get(runtime.callerHeader ?? 'x-vercel-forwarded-for')?.split(',')[0].trim() || 'unknown'
|
|
29
|
+
});
|
|
30
|
+
}
|
package/dist/search-handler.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { compareHits } from './retrieval.js';
|
|
1
2
|
import { validateReaderSelection } from './reader-filters.js';
|
|
2
|
-
import { searchSnippetFromParagraphs } from './search-
|
|
3
|
-
import {
|
|
3
|
+
import { searchSnippetFromParagraphs } from './search-text.js';
|
|
4
|
+
import { identifierMatch } from './search-identifier-core.js';
|
|
4
5
|
import { createSearchResultSelector } from './search-results.js';
|
|
5
6
|
export function createSearchHandler(options) {
|
|
6
7
|
const selectResults = createSearchResultSelector(options.retriever);
|
|
@@ -91,17 +92,16 @@ export function createSearchHandler(options) {
|
|
|
91
92
|
}
|
|
92
93
|
if (!vector && fallbackReason)
|
|
93
94
|
options.onFallback?.(fallbackReason);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const lexical = options.retriever.lexical(query.trim(), retrievalOptions);
|
|
102
|
-
hits = identifierRescue(query.trim(), hits, lexical);
|
|
95
|
+
let candidate;
|
|
96
|
+
const matchesIdentifier = identifierMatch(query);
|
|
97
|
+
if (vector && limit >= 3 && matchesIdentifier) {
|
|
98
|
+
for (const hit of options.retriever.searchPassages(query.trim(), undefined, searchOptions)) {
|
|
99
|
+
if (matchesIdentifier(hit) && (!candidate || compareHits(hit, candidate) < 0))
|
|
100
|
+
candidate = hit;
|
|
101
|
+
}
|
|
103
102
|
}
|
|
104
|
-
const
|
|
103
|
+
const hits = selectResults(options.retriever.searchPassages(query.trim(), vector, searchOptions), query.trim(), searchOptions, candidate);
|
|
104
|
+
const results = hits.map(({ chunk, literalSummary }) => ({ url: chunk.url, title: chunk.title, section: chunk.headingPath.at(-1), summary: literalSummary ?? searchSnippetFromParagraphs(chunk.search.paragraphs, query.trim(), options.pageSummaries?.get(`${chunk.variant}:${chunk.docId}`)) }));
|
|
105
105
|
return json(200, { results, mode: vector ? 'vector' : 'lexical', ...(!vector ? { fallbackReason } : {}) });
|
|
106
106
|
}
|
|
107
107
|
catch {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Hit } from './retrieval.js';
|
|
2
|
+
export declare function searchIdentifiers(query: string): string[];
|
|
3
|
+
export declare function identifierRescue<T extends Hit>(query: string, semantic: T[], lexical: T[], paragraphs?: (hit: Hit) => string[]): T[];
|
|
4
|
+
export declare function identifierMatch(query: string, paragraphs?: (hit: Hit) => string[]): ((hit: Hit) => boolean) | undefined;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Reserve at most one search-box result for a literal configuration key or flag
|
|
2
|
+
// explained in prose. Tables, code listings and identifier prefixes do not qualify.
|
|
3
|
+
export function searchIdentifiers(query) {
|
|
4
|
+
const identifiers = query.match(/\b[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+\b|--[a-z][\w-]*/g) ?? [];
|
|
5
|
+
// File names and URLs need ordinary retrieval, not a config-key shortcut.
|
|
6
|
+
return [...new Set(identifiers.filter(id => !/\.(?:txt|json|ya?ml|mdx?|[cm]?js|tsx?|html?|css|svg|png|jpe?g|com|org|net)$/i.test(id)))];
|
|
7
|
+
}
|
|
8
|
+
export function identifierRescue(query, semantic, lexical, paragraphs) {
|
|
9
|
+
const matches = identifierMatch(query, paragraphs);
|
|
10
|
+
if (!matches)
|
|
11
|
+
return semantic;
|
|
12
|
+
const candidate = lexical.find(matches);
|
|
13
|
+
if (!candidate)
|
|
14
|
+
return semantic;
|
|
15
|
+
const pages = [...new Set(semantic.map(h => h.chunk.docId))];
|
|
16
|
+
if (pages.slice(0, 3).includes(candidate.chunk.docId))
|
|
17
|
+
return semantic;
|
|
18
|
+
const remaining = semantic.filter(h => h.chunk.docId !== candidate.chunk.docId);
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
const at = remaining.findIndex(hit => { seen.add(hit.chunk.docId); return seen.size === 3; });
|
|
21
|
+
const position = at < 0 ? remaining.length : at;
|
|
22
|
+
return [...remaining.slice(0, position), candidate, ...remaining.slice(position)];
|
|
23
|
+
}
|
|
24
|
+
export function identifierMatch(query, paragraphs = hit => hit.chunk.search?.paragraphs ?? []) {
|
|
25
|
+
const identifiers = searchIdentifiers(query);
|
|
26
|
+
if (!identifiers.length)
|
|
27
|
+
return undefined;
|
|
28
|
+
const matches = identifiers.map(id => new RegExp(`(?<![\\w$.-])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w$-]|\\.[\\w$])`, 'i'));
|
|
29
|
+
return hit => paragraphs(hit).some(p => matches.every(re => re.test(p)));
|
|
30
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { Hit } from './retrieval.js';
|
|
2
|
-
export
|
|
3
|
-
export declare
|
|
2
|
+
export { searchIdentifiers } from './search-identifier-core.js';
|
|
3
|
+
export declare const identifierMatch: (query: string) => ((hit: Hit) => boolean) | undefined;
|
|
4
|
+
export declare const identifierRescue: <T extends Hit>(query: string, semantic: T[], lexical: T[]) => T[];
|
|
@@ -1,25 +1,8 @@
|
|
|
1
1
|
import { searchParagraphs } from './search-snippet.js';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export function identifierRescue(query, semantic, lexical) {
|
|
10
|
-
const identifiers = searchIdentifiers(query);
|
|
11
|
-
if (!identifiers.length)
|
|
12
|
-
return semantic;
|
|
13
|
-
const matches = identifiers.map(id => new RegExp(`(?<![\\w$.-])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w$-]|\\.[\\w$])`, 'i'));
|
|
14
|
-
const candidate = lexical.find(hit => (hit.chunk.search?.paragraphs ?? searchParagraphs(hit.chunk.text)).some(p => matches.every(re => re.test(p))));
|
|
15
|
-
if (!candidate)
|
|
16
|
-
return semantic;
|
|
17
|
-
const pages = [...new Set(semantic.map(h => h.chunk.docId))];
|
|
18
|
-
if (pages.slice(0, 3).includes(candidate.chunk.docId))
|
|
19
|
-
return semantic;
|
|
20
|
-
const remaining = semantic.filter(h => h.chunk.docId !== candidate.chunk.docId);
|
|
21
|
-
const seen = new Set();
|
|
22
|
-
const at = remaining.findIndex(hit => { seen.add(hit.chunk.docId); return seen.size === 3; });
|
|
23
|
-
const position = at < 0 ? remaining.length : at;
|
|
24
|
-
return [...remaining.slice(0, position), candidate, ...remaining.slice(position)];
|
|
25
|
-
}
|
|
2
|
+
import { identifierMatch as preparedMatch, identifierRescue as preparedRescue } from './search-identifier-core.js';
|
|
3
|
+
export { searchIdentifiers } from './search-identifier-core.js';
|
|
4
|
+
// Keep the older unprepared-passage API usable by local callers. Hosted search
|
|
5
|
+
// imports the prepared core and never needs a Markdown parser at request time.
|
|
6
|
+
const paragraphs = (hit) => hit.chunk.search?.paragraphs ?? searchParagraphs(hit.chunk.text);
|
|
7
|
+
export const identifierMatch = (query) => preparedMatch(query, paragraphs);
|
|
8
|
+
export const identifierRescue = (query, semantic, lexical) => preparedRescue(query, semantic, lexical, paragraphs);
|
package/dist/search-literal.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { searchIdentifiers } from './search-identifier.js';
|
|
1
|
+
import { searchIdentifiers } from './search-identifier-core.js';
|
|
2
2
|
const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
3
3
|
const boundary = (value) => new RegExp(`(?<![\\w$.-])${escape(value)}(?![\\w$-]|\\.[\\w$])`, 'i');
|
|
4
4
|
// Only explicit technical syntax gets this exception. Ordinary topic words must
|