@shoalrun/cli 0.1.0-alpha.15 → 0.1.0-alpha.16
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 +26 -0
- package/README.md +38 -0
- package/assets/search-semantic.js +34 -0
- package/dist/artifact-bundle.d.ts +1 -1
- package/dist/artifact-bundle.js +1 -1
- package/dist/cli.js +6 -2
- package/dist/compiler.js +2 -0
- package/dist/index-build.js +7 -3
- package/dist/publication-render.d.ts +1 -1
- package/dist/retrieval.d.ts +4 -0
- package/dist/retrieval.js +28 -8
- package/dist/search-handler.js +11 -11
- package/dist/search-identifier.d.ts +2 -1
- package/dist/search-identifier.js +10 -4
- package/dist/search-results.d.ts +1 -1
- package/dist/search-results.js +39 -25
- package/package.json +1 -1
- package/skills/shoalrun-setup/SKILL.md +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.16 — 2026-11-04
|
|
4
|
+
|
|
5
|
+
- Set `outputs.html.search.provider: semantic` to connect the search box to
|
|
6
|
+
Shoalrun's semantic endpoint without writing a custom browser module. The
|
|
7
|
+
included keyword index remains available when the endpoint is absent or
|
|
8
|
+
unavailable. Configure an embedding provider, build the passage index, and
|
|
9
|
+
run or deploy the search endpoint; selecting the browser provider alone does
|
|
10
|
+
not provision a backend.
|
|
11
|
+
|
|
12
|
+
- Qualify stored passage IDs by publication so repeated page and section IDs
|
|
13
|
+
across releases cannot alias embedding rows or citation digests. Rebuild
|
|
14
|
+
passage indexes with `shoalrun index`; when using semantic retrieval, rerun
|
|
15
|
+
indexing with `--embed` and the same embedding configuration to reuse cached
|
|
16
|
+
inputs. Regenerate answer cards after rebuilding their cited passage IDs.
|
|
17
|
+
Ambiguous legacy indexes now fail with rebuild instructions.
|
|
18
|
+
- Include publication membership, routes, reader marks, and prepared search
|
|
19
|
+
evidence in passage-index generation hashes without changing embedding inputs.
|
|
20
|
+
- Read publication-specific passage counts and `sourceDigest` from the index
|
|
21
|
+
command's JSON summary to check which retained releases were indexed.
|
|
22
|
+
- Stream hosted search scores into page selection, retaining at most three
|
|
23
|
+
candidates per page and a result-sized final ranking. Large galleries no
|
|
24
|
+
longer require an unbounded sorted passage array. Exact identifiers reserve
|
|
25
|
+
a slot among displayable pages and keep literal table/code landing evidence.
|
|
26
|
+
File-backed retrieval still scans the selected publication; external vector
|
|
27
|
+
storage and publication-sharded browser downloads are not included yet.
|
|
28
|
+
|
|
3
29
|
## 0.1.0-alpha.15 — 2026-11-03
|
|
4
30
|
|
|
5
31
|
- Opt into `profiles.requireClassification` to reject unclassified public pages
|
package/README.md
CHANGED
|
@@ -102,6 +102,44 @@ for main, developer, and regional sites built from one source directory.
|
|
|
102
102
|
For deployment-owned SKU selections, direct or grandfathered feature grants, and
|
|
103
103
|
branding supplied outside the shared repository, see [deployment profiles](DEPLOYMENT-PROFILES.md).
|
|
104
104
|
|
|
105
|
+
## Enable semantic search
|
|
106
|
+
|
|
107
|
+
The default search runs in the browser with no server or API key. To use
|
|
108
|
+
Shoalrun's semantic endpoint, select the included provider:
|
|
109
|
+
|
|
110
|
+
```yaml
|
|
111
|
+
outputs:
|
|
112
|
+
html:
|
|
113
|
+
search:
|
|
114
|
+
provider: semantic
|
|
115
|
+
retrieval:
|
|
116
|
+
embeddings:
|
|
117
|
+
provider: voyage
|
|
118
|
+
model: voyage-4-large
|
|
119
|
+
queryModel: voyage-4-lite
|
|
120
|
+
dimension: 1024
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Set `VOYAGE_API_KEY` in the build and server environment, then run:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
shoalrun build docs.config.yaml site-dist
|
|
127
|
+
shoalrun index site-dist --embed --config docs.config.yaml
|
|
128
|
+
shoalrun serve site-dist --search --config docs.config.yaml
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
No custom browser adapter or chat model is needed. The browser sends the selected
|
|
132
|
+
publication and reader view to the same-origin endpoint and uses keyword search
|
|
133
|
+
if it is missing or unavailable. Each semantic query calls the embedding
|
|
134
|
+
provider; keys stay server-side. Reindex changed content and restart the preview
|
|
135
|
+
after rebuilding indexes. Existing matching embedding inputs are reused.
|
|
136
|
+
|
|
137
|
+
`provider: semantic` configures the browser connection; production hosting still
|
|
138
|
+
needs a search endpoint. The current Vercel integration supplies search through
|
|
139
|
+
its chat-function deployment, which does not yet support deployment profiles.
|
|
140
|
+
A static deployment remains usable through keyword fallback. External search
|
|
141
|
+
services can continue to use `provider: custom`.
|
|
142
|
+
|
|
105
143
|
## Export content into an application
|
|
106
144
|
|
|
107
145
|
Export pages from the same source into a portable directory for an application's
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Built-in semantic connection. Credentials and query embeddings stay server-side.
|
|
2
|
+
import * as lexical from './search-lexical.js';
|
|
3
|
+
const endpoint = new URL('../__shoalrun/search', import.meta.url).pathname;
|
|
4
|
+
let endpointMissing = false;
|
|
5
|
+
export const prepare = lexical.prepare;
|
|
6
|
+
|
|
7
|
+
export async function search(query, context) {
|
|
8
|
+
const fallback = () => lexical.search(query, context);
|
|
9
|
+
context.signal?.throwIfAborted();
|
|
10
|
+
if (endpointMissing || query.trim().length < 3) return fallback();
|
|
11
|
+
// Debounce requests before spending query-embedding credits. Every listener
|
|
12
|
+
// is removed on completion, including when a keystroke cancels this request.
|
|
13
|
+
await new Promise((resolve, reject) => {
|
|
14
|
+
const abort = () => { clearTimeout(timer); context.signal?.removeEventListener('abort', abort); reject(context.signal.reason); };
|
|
15
|
+
const timer = setTimeout(() => { context.signal?.removeEventListener('abort', abort); resolve(); }, 200);
|
|
16
|
+
context.signal?.addEventListener('abort', abort, { once: true });
|
|
17
|
+
});
|
|
18
|
+
context.signal?.throwIfAborted();
|
|
19
|
+
try {
|
|
20
|
+
const signal = AbortSignal.any([AbortSignal.timeout(4000), ...(context.signal ? [context.signal] : [])]);
|
|
21
|
+
const response = await fetch(endpoint, {
|
|
22
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
23
|
+
body: JSON.stringify({ query, variant: context.variant, limit: context.limit, readerSelection: context.readerSelection }), signal
|
|
24
|
+
});
|
|
25
|
+
if (response.status === 404) endpointMissing = true;
|
|
26
|
+
if (!response.ok) return fallback();
|
|
27
|
+
const body = await response.json();
|
|
28
|
+
context.signal?.throwIfAborted();
|
|
29
|
+
return Array.isArray(body.results) ? body.results : fallback();
|
|
30
|
+
} catch {
|
|
31
|
+
context.signal?.throwIfAborted();
|
|
32
|
+
return fallback();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -32,7 +32,7 @@ export type ArtifactAssemblyInput = {
|
|
|
32
32
|
generatedPaletteCss?: string;
|
|
33
33
|
generatedBadgeCss?: string;
|
|
34
34
|
search: {
|
|
35
|
-
provider: 'lexical' | 'custom' | 'none';
|
|
35
|
+
provider: 'lexical' | 'semantic' | 'custom' | 'none';
|
|
36
36
|
index: boolean;
|
|
37
37
|
module?: string;
|
|
38
38
|
};
|
package/dist/artifact-bundle.js
CHANGED
|
@@ -122,7 +122,7 @@ export async function assembleArtifacts(input) {
|
|
|
122
122
|
const compact = { schemaVersion: 1, documents: index.documents.map(({ id, demo, url, markdownUrl, title, summary, type, variant, headings, sections, navigationTab }) => ({ id, demo, url, markdownUrl, title, summary, type, variant, headings, ...(navigationTab ? { navigationTab } : {}), ...(sections ? { sections } : {}), ...sizes.get(markdownUrl) })) };
|
|
123
123
|
addMetadataArtifact('/search-index.min.json', JSON.stringify(compact) + '\n');
|
|
124
124
|
}
|
|
125
|
-
if (search.provider === 'custom' && search.index)
|
|
125
|
+
if ((search.provider === 'custom' || search.provider === 'semantic') && search.index)
|
|
126
126
|
addPublicArtifact('/assets/search-lexical.js', await readFile(builtInSearchProvider, 'utf8'));
|
|
127
127
|
if (search.module)
|
|
128
128
|
addPublicArtifact('/assets/search-provider.js', await readFile(search.module, 'utf8'));
|
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/index-build.js
CHANGED
|
@@ -164,7 +164,9 @@ export function chunkMarkdown(markdown, document, anchors = [], options = {}) {
|
|
|
164
164
|
return chunks;
|
|
165
165
|
}
|
|
166
166
|
function makeChunk(document, headingPath, anchor, text, used, part = 0) {
|
|
167
|
-
|
|
167
|
+
// Logical page IDs repeat across releases. Stored passage IDs must not: vector
|
|
168
|
+
// rows and answer citations are keyed globally within this site's index.
|
|
169
|
+
const base = `${encodeURIComponent(document.variant)}:${encodeURIComponent(document.id)}#${slug(headingPath.join(' ')) || 'intro'}${part ? `~${part + 1}` : ''}`;
|
|
168
170
|
const seen = used.get(base) ?? 0;
|
|
169
171
|
used.set(base, seen + 1);
|
|
170
172
|
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 };
|
|
@@ -219,8 +221,10 @@ export async function buildChunkIndex(directory, options = {}) {
|
|
|
219
221
|
const { channel, version, language } = document.variant;
|
|
220
222
|
chunks.push(...chunkMarkdown(markdown, { ...document, variant: `${channel}|${version}|${language}` }, anchors.get(document.url), options));
|
|
221
223
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
+
// Generation identity includes publication membership, routes, reader marks,
|
|
225
|
+
// and prepared search evidence. Embedding digests remain content-only.
|
|
226
|
+
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));
|
|
227
|
+
const index = { schemaVersion: 1, sourceDigest: sha(JSON.stringify({ identityVersion: 2, source, synonyms })), chunks, ...(synonyms.length ? { synonyms } : {}) };
|
|
224
228
|
const output = path.join(await aiDirectory(root), 'chunks.json');
|
|
225
229
|
await writeFile(output, JSON.stringify(index) + '\n');
|
|
226
230
|
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[];
|
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)
|
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
3
|
import { searchSnippetFromParagraphs } from './search-snippet.js';
|
|
3
|
-
import {
|
|
4
|
+
import { identifierMatch } from './search-identifier.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 {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { Hit } from './retrieval.js';
|
|
2
2
|
export declare function searchIdentifiers(query: string): string[];
|
|
3
|
-
export declare function identifierRescue(query: string, semantic:
|
|
3
|
+
export declare function identifierRescue<T extends Hit>(query: string, semantic: T[], lexical: T[]): T[];
|
|
4
|
+
export declare function identifierMatch(query: string): ((hit: Hit) => boolean) | undefined;
|
|
@@ -7,11 +7,10 @@ export function searchIdentifiers(query) {
|
|
|
7
7
|
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)))];
|
|
8
8
|
}
|
|
9
9
|
export function identifierRescue(query, semantic, lexical) {
|
|
10
|
-
const
|
|
11
|
-
if (!
|
|
10
|
+
const matches = identifierMatch(query);
|
|
11
|
+
if (!matches)
|
|
12
12
|
return semantic;
|
|
13
|
-
const
|
|
14
|
-
const candidate = lexical.find(hit => (hit.chunk.search?.paragraphs ?? searchParagraphs(hit.chunk.text)).some(p => matches.every(re => re.test(p))));
|
|
13
|
+
const candidate = lexical.find(matches);
|
|
15
14
|
if (!candidate)
|
|
16
15
|
return semantic;
|
|
17
16
|
const pages = [...new Set(semantic.map(h => h.chunk.docId))];
|
|
@@ -23,3 +22,10 @@ export function identifierRescue(query, semantic, lexical) {
|
|
|
23
22
|
const position = at < 0 ? remaining.length : at;
|
|
24
23
|
return [...remaining.slice(0, position), candidate, ...remaining.slice(position)];
|
|
25
24
|
}
|
|
25
|
+
export function identifierMatch(query) {
|
|
26
|
+
const identifiers = searchIdentifiers(query);
|
|
27
|
+
if (!identifiers.length)
|
|
28
|
+
return undefined;
|
|
29
|
+
const matches = identifiers.map(id => new RegExp(`(?<![\\w$.-])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w$-]|\\.[\\w$])`, 'i'));
|
|
30
|
+
return hit => (hit.chunk.search?.paragraphs ?? searchParagraphs(hit.chunk.text)).some(p => matches.every(re => re.test(p)));
|
|
31
|
+
}
|
package/dist/search-results.d.ts
CHANGED
|
@@ -2,5 +2,5 @@ import type { Hit, LexicalOptions, Retriever } from './retrieval.js';
|
|
|
2
2
|
type SearchHit = Hit & {
|
|
3
3
|
literalSummary?: string;
|
|
4
4
|
};
|
|
5
|
-
export declare function createSearchResultSelector(retriever: Retriever): (hits: Hit
|
|
5
|
+
export declare function createSearchResultSelector(retriever: Retriever): (hits: Iterable<Hit>, query: string, options: LexicalOptions, rescue?: Hit) => SearchHit[];
|
|
6
6
|
export {};
|
package/dist/search-results.js
CHANGED
|
@@ -1,45 +1,59 @@
|
|
|
1
|
-
import { tokenize } from './retrieval.js';
|
|
1
|
+
import { compareHits, tokenize } from './retrieval.js';
|
|
2
2
|
import { literalSearch } from './search-literal.js';
|
|
3
|
+
import { identifierRescue } from './search-identifier.js';
|
|
3
4
|
// Select landing passages for the search box, without changing passage retrieval
|
|
4
5
|
// used by Ask. A gallery's repeated examples must not crowd other pages out.
|
|
5
6
|
export function createSearchResultSelector(retriever) {
|
|
6
7
|
retriever.requirePreparedSearch();
|
|
7
8
|
const normalized = (text) => tokenize(text).join(' ');
|
|
8
|
-
return (hits, query, options) => {
|
|
9
|
+
return (hits, query, options, rescue) => {
|
|
9
10
|
const pages = new Map();
|
|
11
|
+
const normalizedQuery = normalized(query);
|
|
10
12
|
const literal = literalSearch(query);
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if (literalPages.has(hit.chunk.docId))
|
|
15
|
-
continue;
|
|
16
|
-
const literalSummary = literal(hit.chunk.search.literalLines);
|
|
17
|
-
if (literalSummary)
|
|
18
|
-
literalPages.set(hit.chunk.docId, { ...hit, literalSummary });
|
|
19
|
-
}
|
|
20
|
-
}
|
|
13
|
+
let exact;
|
|
14
|
+
// Retain at most three candidates per page, independent of its passage
|
|
15
|
+
// count. Input scores need not be sorted or collected into an array.
|
|
21
16
|
for (const hit of hits) {
|
|
22
|
-
|
|
17
|
+
const page = pages.get(hit.chunk.docId) ?? { exactTitle: normalized(hit.chunk.title) === normalizedQuery };
|
|
18
|
+
if (!page.first || compareHits(hit, page.first) < 0)
|
|
19
|
+
page.first = hit;
|
|
20
|
+
if (hit.chunk.search.paragraphs.length && (!page.prose || compareHits(hit, page.prose) < 0))
|
|
21
|
+
page.prose = hit;
|
|
22
|
+
const literalSummary = literal?.(hit.chunk.search.literalLines);
|
|
23
|
+
if (literalSummary && (!page.literal || compareHits(hit, page.literal) < 0))
|
|
24
|
+
page.literal = { ...hit, literalSummary };
|
|
25
|
+
pages.set(hit.chunk.docId, page);
|
|
26
|
+
if (page.exactTitle && (!exact || compareHits(hit, exact) < 0))
|
|
27
|
+
exact = hit;
|
|
28
|
+
}
|
|
29
|
+
const limit = options.limit ?? 8;
|
|
30
|
+
const candidates = [];
|
|
31
|
+
for (const page of pages.values()) {
|
|
32
|
+
const candidate = page.literal ? { rank: page.first, hit: { ...page.literal, score: page.first.score } } : page.prose ? { rank: page.prose, hit: page.prose } : undefined;
|
|
33
|
+
if (!candidate)
|
|
23
34
|
continue;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
35
|
+
const at = candidates.findIndex(other => compareHits(candidate.rank, other.rank) < 0);
|
|
36
|
+
candidates.splice(at < 0 ? candidates.length : at, 0, candidate);
|
|
37
|
+
if (candidates.length > limit)
|
|
38
|
+
candidates.pop();
|
|
39
|
+
}
|
|
40
|
+
let selected = candidates.map(item => item.hit);
|
|
41
|
+
if (rescue) {
|
|
42
|
+
selected = identifierRescue(query, selected, [rescue]);
|
|
43
|
+
const landing = pages.get(rescue.chunk.docId)?.literal;
|
|
44
|
+
if (landing)
|
|
45
|
+
selected = selected.map(hit => hit.chunk.docId === rescue.chunk.docId ? { ...landing, score: hit.score } : hit);
|
|
31
46
|
}
|
|
32
47
|
// An exact page-title query is a request for that topic's entry point.
|
|
33
48
|
// Ordinary semantic queries retain their ranking; incidental words do not qualify.
|
|
34
|
-
const exact = hits.find(hit => normalized(hit.chunk.title) === normalized(query));
|
|
35
49
|
if (exact) {
|
|
36
50
|
const introduction = retriever.pageIntroduction(exact.chunk.docId, options);
|
|
37
|
-
const
|
|
51
|
+
const page = pages.get(exact.chunk.docId);
|
|
52
|
+
const landing = introduction ? { chunk: introduction, score: exact.score } : page?.literal ?? page?.prose;
|
|
38
53
|
if (landing) {
|
|
39
|
-
|
|
40
|
-
return [landing, ...pages.values()].slice(0, options.limit ?? 8);
|
|
54
|
+
return [landing, ...selected.filter(hit => hit.chunk.docId !== exact.chunk.docId)].slice(0, options.limit ?? 8);
|
|
41
55
|
}
|
|
42
56
|
}
|
|
43
|
-
return
|
|
57
|
+
return selected.slice(0, limit);
|
|
44
58
|
};
|
|
45
59
|
}
|
package/package.json
CHANGED
|
@@ -33,4 +33,6 @@ For a new OpenAPI source, read `guide.api-references` and complete its API-sourc
|
|
|
33
33
|
|
|
34
34
|
Check the generated HTML, Markdown twins, and `documents.json`. Confirm navigation and API links resolve, and inspect assets inside the components that contain them. Run `shoalrun audit docs.config.yaml` when project checks or API audits are configured; documentation behavior tests can use Doc Detective through `audit.tests` or another external CLI.
|
|
35
35
|
|
|
36
|
+
For optional semantic search, use `outputs.html.search.provider: semantic` (available since `0.1.0-alpha.16`) with `retrieval.embeddings`; do not generate a custom browser adapter for Shoalrun's own endpoint. Build the site, run `shoalrun index <output> --embed --config docs.config.yaml`, then `shoalrun serve <output> --search --config docs.config.yaml` with the provider key only in the environment. Confirm the endpoint returns `mode: vector`; a visible result or keyword fallback does not prove semantic retrieval. Selecting the browser provider does not provision production hosting, and profiled Vercel chat-function builds remain unsupported.
|
|
37
|
+
|
|
36
38
|
Report the files created, commands run, and anything still needing an owner decision. Do not deploy, publish a release, or claim factual accuracy from a successful build alone.
|