@crvouga/atlas 1.5.0 → 1.6.0
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 +7 -1
- package/docs/ui.md +11 -1
- package/llms-full.txt +11 -1
- package/package.json +1 -1
- package/packages/visualizer/README.md +28 -0
- package/packages/visualizer/src/explorer/Explorer.tsx +105 -25
- package/packages/visualizer/src/explorer/RunsView.tsx +173 -35
- package/packages/visualizer/src/explorer/catalog.tsx +9 -5
- package/packages/visualizer/src/explorer/search-notes.tsx +74 -0
- package/packages/visualizer/src/explorer/search.ts +16 -6
- package/packages/visualizer/src/find/Finder.tsx +127 -81
- package/packages/visualizer/src/find/behaviors.ts +205 -0
- package/packages/visualizer/src/find/corpus.ts +21 -7
- package/packages/visualizer/src/find/doc-search.ts +66 -0
- package/packages/visualizer/src/find/query.ts +247 -0
- package/packages/visualizer/src/find/rank.ts +195 -47
- package/packages/visualizer/src/find/search.test.ts +322 -0
- package/packages/visualizer/src/find/semantic-search.ts +153 -0
- package/packages/visualizer/src/find/semantic.ts +2 -2
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Search query language
|
|
3
|
+
*
|
|
4
|
+
* One reading of a search box, shared by the finder, the test list, the runs list and the results of one run, so a
|
|
5
|
+
* sentence means the same thing wherever it is typed:
|
|
6
|
+
*
|
|
7
|
+
* - plain words are matched by meaning, spelling and word form (see `rank.ts`), and every distinctive word must be found;
|
|
8
|
+
* - `"a quoted phrase"` and identifiers that contain `/`, `_` or `.` (`checkout/save.feature`) are matched literally and
|
|
9
|
+
* are reported as exact matches;
|
|
10
|
+
* - `field:value` narrows by structure instead of matching words: `status:failed`, `env:ios` (or `platform:`,
|
|
11
|
+
* `environment:`, `device:`), `producer:`, `domain:`, `spec:`, `tag:`, `branch:` and `has:video`. A value with spaces is
|
|
12
|
+
* quoted (`domain:"user account"`). Several values of one field (`status:failed,flaky`, or the field twice) match any of
|
|
13
|
+
* them; different fields must all hold.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here is ever dropped silently: a word it cannot use as a filter (`colour:red`, `status:great`, `has:smell`) is
|
|
16
|
+
* returned as an {@link SearchIssue} for the interface to show, and it filters nothing.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The filters a search can carry. Each list matches any of its values; the lists combine with AND. */
|
|
20
|
+
export type SearchFilters = {
|
|
21
|
+
readonly status: readonly string[];
|
|
22
|
+
readonly env: readonly string[];
|
|
23
|
+
readonly producer: readonly string[];
|
|
24
|
+
readonly domain: readonly string[];
|
|
25
|
+
readonly spec: readonly string[];
|
|
26
|
+
readonly tag: readonly string[];
|
|
27
|
+
readonly branch: readonly string[];
|
|
28
|
+
/** `has:video`: only what has a playable video recording. */
|
|
29
|
+
readonly video: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** One of the filters a search can carry, by the name a person types before the colon. */
|
|
33
|
+
export type FilterKey = Exclude<keyof SearchFilters, 'video'> | 'video';
|
|
34
|
+
|
|
35
|
+
/** One filter as typed, for showing as a removable chip. */
|
|
36
|
+
export type FilterToken = {
|
|
37
|
+
readonly key: FilterKey;
|
|
38
|
+
/** The value (`failed`), or `video` for `has:video`. */
|
|
39
|
+
readonly value: string;
|
|
40
|
+
/** The text to remove from the query to remove this filter. */
|
|
41
|
+
readonly text: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Something in the query that could not be used, and what to type instead. */
|
|
45
|
+
export type SearchIssue = {
|
|
46
|
+
/** The text as typed. */
|
|
47
|
+
readonly token: string;
|
|
48
|
+
/** A sentence for the person, naming what is supported. */
|
|
49
|
+
readonly message: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** A query read into what it asks for. */
|
|
53
|
+
export type ParsedSearch = {
|
|
54
|
+
/** The query as typed. */
|
|
55
|
+
readonly raw: string;
|
|
56
|
+
/** The loose words to match by meaning and spelling (quoted phrases, identifiers and filters removed). */
|
|
57
|
+
readonly words: string;
|
|
58
|
+
/** Quoted phrases, each of which must appear literally. */
|
|
59
|
+
readonly phrases: readonly string[];
|
|
60
|
+
/** Identifier-like words (`lists/add`, `save_answer`, `a.feature`), each of which must appear literally. */
|
|
61
|
+
readonly identifiers: readonly string[];
|
|
62
|
+
readonly filters: SearchFilters;
|
|
63
|
+
/** The filters in the order typed. */
|
|
64
|
+
readonly tokens: readonly FilterToken[];
|
|
65
|
+
readonly issues: readonly SearchIssue[];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** The statuses a `status:` filter accepts. */
|
|
69
|
+
export const STATUS_VALUES = ['passed', 'failed', 'flaky', 'not-run', 'running'] as const;
|
|
70
|
+
|
|
71
|
+
/** The `has:` values a search accepts. */
|
|
72
|
+
export const HAS_VALUES = ['video'] as const;
|
|
73
|
+
|
|
74
|
+
const FIELD_ALIASES: Readonly<Record<string, Exclude<FilterKey, 'video'>>> = {
|
|
75
|
+
status: 'status',
|
|
76
|
+
env: 'env',
|
|
77
|
+
environment: 'env',
|
|
78
|
+
platform: 'env',
|
|
79
|
+
device: 'env',
|
|
80
|
+
producer: 'producer',
|
|
81
|
+
domain: 'domain',
|
|
82
|
+
spec: 'spec',
|
|
83
|
+
tag: 'tag',
|
|
84
|
+
branch: 'branch'
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** The operator names a search understands, for guidance. */
|
|
88
|
+
export const OPERATOR_NAMES: readonly string[] = [...Object.keys(FIELD_ALIASES), 'has'];
|
|
89
|
+
|
|
90
|
+
/** A sentence that lists what can be typed, shown with every issue. */
|
|
91
|
+
export const SYNTAX_HELP = 'Filters: status:failed, env:ios, producer:, domain:, spec:, tag:, branch: and has:video. Quote phrases: "saved answer".';
|
|
92
|
+
|
|
93
|
+
/** A search with nothing in it. */
|
|
94
|
+
export const NO_FILTERS: SearchFilters = { status: [], env: [], producer: [], domain: [], spec: [], tag: [], branch: [], video: false };
|
|
95
|
+
|
|
96
|
+
const OPERATOR = /^([A-Za-z][A-Za-z-]*):(.*)$/;
|
|
97
|
+
const IDENTIFIER = /[A-Za-z0-9][/_.][A-Za-z0-9]/;
|
|
98
|
+
|
|
99
|
+
/** The pieces of a query: quoted strings (optionally after `name:`) and runs of non-space characters. */
|
|
100
|
+
function pieces(query: string): { readonly text: string; readonly quoted: boolean; readonly name?: string }[] {
|
|
101
|
+
const out: { text: string; quoted: boolean; name?: string }[] = [];
|
|
102
|
+
const pattern = /(?:([A-Za-z][A-Za-z-]*):)?"([^"]*)"|(\S+)/g;
|
|
103
|
+
for (const match of query.matchAll(pattern)) {
|
|
104
|
+
if (match[3] !== undefined) out.push({ text: match[3], quoted: false });
|
|
105
|
+
else out.push({ text: match[2] ?? '', quoted: true, ...(match[1] ? { name: match[1] } : {}) });
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Reads a query: see the module comment. Never throws; whatever it cannot use is in `issues`. */
|
|
111
|
+
export function parseSearch(query: string): ParsedSearch {
|
|
112
|
+
const words: string[] = [];
|
|
113
|
+
const phrases: string[] = [];
|
|
114
|
+
const identifiers: string[] = [];
|
|
115
|
+
const issues: SearchIssue[] = [];
|
|
116
|
+
const tokens: FilterToken[] = [];
|
|
117
|
+
const lists: Record<Exclude<FilterKey, 'video'>, string[]> = { status: [], env: [], producer: [], domain: [], spec: [], tag: [], branch: [] };
|
|
118
|
+
let video = false;
|
|
119
|
+
const issue = (token: string, message: string) => issues.push({ token, message: `${message} ${SYNTAX_HELP}` });
|
|
120
|
+
|
|
121
|
+
for (const piece of pieces(query)) {
|
|
122
|
+
const operator = piece.name !== undefined ? ([piece.name, piece.text] as const) : !piece.quoted ? OPERATOR.exec(piece.text)?.slice(1, 3) : undefined;
|
|
123
|
+
if (piece.quoted && piece.name === undefined) {
|
|
124
|
+
const phrase = piece.text.trim().toLowerCase();
|
|
125
|
+
if (phrase) phrases.push(phrase);
|
|
126
|
+
else issue('""', 'is an empty phrase, which matches nothing.');
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (operator) {
|
|
130
|
+
const name = operator[0].toLowerCase();
|
|
131
|
+
const original = piece.quoted ? `${operator[0]}:"${operator[1]}"` : piece.text;
|
|
132
|
+
const rawValues = piece.quoted ? [operator[1]!] : operator[1]!.split(',');
|
|
133
|
+
const values = rawValues.map((value) => value.trim().replace(/^"|"$/g, '')).filter(Boolean);
|
|
134
|
+
if (name === 'has') {
|
|
135
|
+
if (!values.length) issue(original, 'needs a value after the colon (has:video).');
|
|
136
|
+
for (const value of values) {
|
|
137
|
+
if (value.toLowerCase() === 'video') {
|
|
138
|
+
video = true;
|
|
139
|
+
tokens.push({ key: 'video', value: 'video', text: original });
|
|
140
|
+
} else issue(original, 'is not supported (only has:video).');
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const key = FIELD_ALIASES[name];
|
|
145
|
+
if (!key && !piece.quoted && !values.length) {
|
|
146
|
+
// Prose such as "Done:" is not a filter.
|
|
147
|
+
words.push(piece.text.replace(/:$/, ''));
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (!key) {
|
|
151
|
+
issue(original, 'is not a filter.');
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (!values.length) {
|
|
155
|
+
issue(original, 'needs a value after the colon.');
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
for (const raw of values) {
|
|
159
|
+
const value = key === 'tag' ? raw.replace(/^#/, '').toLowerCase() : raw.toLowerCase();
|
|
160
|
+
if (key === 'status' && !(STATUS_VALUES as readonly string[]).includes(value)) {
|
|
161
|
+
issue(original, `is not a status (use ${STATUS_VALUES.join(', ')}).`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (!lists[key].includes(value)) lists[key].push(value);
|
|
165
|
+
tokens.push({ key, value, text: original });
|
|
166
|
+
}
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (piece.text.startsWith('"')) {
|
|
170
|
+
issue(piece.text, 'has a quote that is never closed, so its words are matched as ordinary words.');
|
|
171
|
+
words.push(piece.text.replace(/"/g, ' '));
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const bare = piece.text.replace(/^[("']+|[)"',;!?]+$/g, '').replace(/\.+$/, '');
|
|
175
|
+
if (IDENTIFIER.test(bare)) identifiers.push(bare.toLowerCase());
|
|
176
|
+
else words.push(piece.text);
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
raw: query,
|
|
180
|
+
words: words.join(' ').trim(),
|
|
181
|
+
phrases,
|
|
182
|
+
identifiers,
|
|
183
|
+
filters: { ...lists, video },
|
|
184
|
+
tokens,
|
|
185
|
+
issues
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** True when the query narrows by structure (any filter, phrase or identifier) rather than only by loose words. */
|
|
190
|
+
export function isStructured(parsed: ParsedSearch): boolean {
|
|
191
|
+
return parsed.tokens.length > 0 || parsed.phrases.length > 0 || parsed.identifiers.length > 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** True when the query has nothing to match at all (no words, phrases, identifiers or filters). */
|
|
195
|
+
export function isBlank(parsed: ParsedSearch): boolean {
|
|
196
|
+
return parsed.words.length === 0 && !isStructured(parsed);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The query with one filter's text removed, other text left as typed. */
|
|
200
|
+
export function withoutToken(query: string, token: FilterToken): string {
|
|
201
|
+
const at = query.indexOf(token.text);
|
|
202
|
+
if (at < 0) return query;
|
|
203
|
+
return `${query.slice(0, at)}${query.slice(at + token.text.length)}`.replace(/\s{2,}/g, ' ').trim();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* What a document says about itself, to be filtered by structure. Lowercased on use. A document without facets (a
|
|
208
|
+
* flow, a spec) can satisfy no filter.
|
|
209
|
+
*/
|
|
210
|
+
export type DocFacets = {
|
|
211
|
+
readonly status: readonly string[];
|
|
212
|
+
readonly env: readonly string[];
|
|
213
|
+
readonly producer: readonly string[];
|
|
214
|
+
readonly domain: readonly string[];
|
|
215
|
+
readonly spec: readonly string[];
|
|
216
|
+
readonly tag: readonly string[];
|
|
217
|
+
readonly branch: readonly string[];
|
|
218
|
+
readonly video: boolean;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/** The filter groups a count can leave out (status and environment buttons count what the other filters leave). */
|
|
222
|
+
export type FilterOmit = ReadonlySet<FilterKey>;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Whether facets satisfy the filters: every filter that is set must match at least one of the document's values.
|
|
226
|
+
* `omit` skips some filters (to count what a button would add).
|
|
227
|
+
*/
|
|
228
|
+
export function passesFilters(facets: DocFacets | undefined, filters: SearchFilters, omit?: FilterOmit): boolean {
|
|
229
|
+
const wanted = (key: FilterKey) => !omit?.has(key);
|
|
230
|
+
const lists: (Exclude<FilterKey, 'video'>)[] = ['status', 'env', 'producer', 'domain', 'spec', 'tag', 'branch'];
|
|
231
|
+
const active = lists.filter((key) => filters[key].length > 0 && wanted(key));
|
|
232
|
+
const needsVideo = filters.video && wanted('video');
|
|
233
|
+
if (!active.length && !needsVideo) return true;
|
|
234
|
+
if (!facets) return false;
|
|
235
|
+
if (needsVideo && !facets.video) return false;
|
|
236
|
+
for (const key of active) {
|
|
237
|
+
const have = facets[key].map((value) => value.toLowerCase());
|
|
238
|
+
const ok = filters[key].some((value) => (key === 'spec' ? have.some((path) => path === value || path.endsWith(`/${value}`) || path.includes(value)) : have.includes(value)));
|
|
239
|
+
if (!ok) return false;
|
|
240
|
+
}
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** True when a search carries a filter (as opposed to words), counting only those not in `omit`. */
|
|
245
|
+
export function hasFilters(filters: SearchFilters): boolean {
|
|
246
|
+
return filters.video || filters.status.length + filters.env.length + filters.producer.length + filters.domain.length + filters.spec.length + filters.tag.length + filters.branch.length > 0;
|
|
247
|
+
}
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Ranked search
|
|
3
|
+
*
|
|
4
|
+
* The one matching engine behind every search box: the finder, the test list, the runs list and the results of one run.
|
|
5
|
+
* It indexes documents (`FindDoc`) by their weighted fields, matches words by stem, everyday synonym, spelling and
|
|
6
|
+
* the start of a longer word, applies the structured parts of a query (`query.ts`), and says why each document matched.
|
|
7
|
+
*/
|
|
8
|
+
import { parseSearch, passesFilters, type DocFacets, type ParsedSearch } from './query';
|
|
9
|
+
|
|
1
10
|
/** Everyday words a new person uses for the same action. The first word is the class id. */
|
|
2
11
|
const CLASSES: readonly (readonly string[])[] = [
|
|
3
12
|
['complete', 'done', 'finish', 'mark', 'check'],
|
|
@@ -27,10 +36,15 @@ const TYPE_PHRASES: readonly { readonly notation: string; readonly left: string;
|
|
|
27
36
|
const TYPE_OF = new Map<string, string>();
|
|
28
37
|
for (const entry of TYPE_WORDS) for (const word of entry.words) TYPE_OF.set(word, entry.notation);
|
|
29
38
|
|
|
30
|
-
|
|
39
|
+
/** How much a word counts in each field of a document. */
|
|
40
|
+
export const FIELD_WEIGHT = { title: 5, steps: 3, description: 2, metadata: 1, path: 0.5 } as const;
|
|
31
41
|
|
|
42
|
+
/** Where in a document a word was found. `metadata` is domain, tags, labels, environments and producers. */
|
|
32
43
|
export type FieldName = keyof typeof FIELD_WEIGHT;
|
|
33
44
|
|
|
45
|
+
const FIELD_NAMES = Object.keys(FIELD_WEIGHT) as FieldName[];
|
|
46
|
+
|
|
47
|
+
/** One searchable text of a document and where it comes from. */
|
|
34
48
|
export type FindField = { readonly name: FieldName; readonly text: string };
|
|
35
49
|
|
|
36
50
|
export type FindGroup = 'Flows' | 'Specs' | 'Runs';
|
|
@@ -57,6 +71,7 @@ export type FindTarget = {
|
|
|
57
71
|
};
|
|
58
72
|
};
|
|
59
73
|
|
|
74
|
+
/** A searchable thing: a flow, a spec, a scenario, a recorded behavior or a run. */
|
|
60
75
|
export type FindDoc = {
|
|
61
76
|
readonly id: string;
|
|
62
77
|
readonly group: FindGroup;
|
|
@@ -69,9 +84,30 @@ export type FindDoc = {
|
|
|
69
84
|
readonly related: readonly string[];
|
|
70
85
|
readonly target: FindTarget;
|
|
71
86
|
readonly recording: FindTarget | null;
|
|
87
|
+
/** What structured filters (`status:`, `has:video`…) read. Without it, a document passes no filter. */
|
|
88
|
+
readonly facets?: DocFacets;
|
|
72
89
|
};
|
|
73
90
|
|
|
74
|
-
|
|
91
|
+
/** A matched document, and why it matched. */
|
|
92
|
+
export type Ranked = {
|
|
93
|
+
readonly doc: FindDoc;
|
|
94
|
+
readonly score: number;
|
|
95
|
+
readonly snippet: string;
|
|
96
|
+
/** Where the query's words were found. */
|
|
97
|
+
readonly fields?: readonly FieldName[];
|
|
98
|
+
/** True when a quoted phrase or an identifier was found literally. */
|
|
99
|
+
readonly exact?: boolean;
|
|
100
|
+
/** The literal phrases or identifiers found. */
|
|
101
|
+
readonly literals?: readonly string[];
|
|
102
|
+
/** True when a word matched only by a close spelling or the start of a longer word. */
|
|
103
|
+
readonly fuzzy?: boolean;
|
|
104
|
+
/** True when a distinctive query word is missing: a weaker match, listed below those that explain every word. */
|
|
105
|
+
readonly partial?: boolean;
|
|
106
|
+
/** True when it is listed because a matched document relates to it, not because it matched. */
|
|
107
|
+
readonly related?: boolean;
|
|
108
|
+
/** True when only the meaning of the query, not its words, brought it here. */
|
|
109
|
+
readonly semantic?: boolean;
|
|
110
|
+
};
|
|
75
111
|
|
|
76
112
|
const CLASS_OF = new Map<string, string>();
|
|
77
113
|
for (const members of CLASSES) {
|
|
@@ -137,8 +173,9 @@ function damerau(a: string, b: string, limit: number): number {
|
|
|
137
173
|
return rows[a.length]![b.length]!;
|
|
138
174
|
}
|
|
139
175
|
|
|
140
|
-
type Posting = { readonly doc: number; readonly weight: number; readonly snippet: string; readonly snippetRank: number };
|
|
176
|
+
type Posting = { readonly doc: number; readonly weight: number; readonly snippet: string; readonly snippetRank: number; readonly fields: number };
|
|
141
177
|
|
|
178
|
+
/** Documents indexed for matching. Build once with {@link indexCorpus}; query many times. */
|
|
142
179
|
export type FindIndex = {
|
|
143
180
|
readonly docs: readonly FindDoc[];
|
|
144
181
|
readonly postings: ReadonlyMap<string, readonly Posting[]>;
|
|
@@ -152,6 +189,7 @@ function snippetRank(name: FieldName): number {
|
|
|
152
189
|
return 0;
|
|
153
190
|
}
|
|
154
191
|
|
|
192
|
+
/** Indexes documents by the stems of the words in their fields. */
|
|
155
193
|
export function indexCorpus(docs: readonly FindDoc[]): FindIndex {
|
|
156
194
|
const postings = new Map<string, Posting[]>();
|
|
157
195
|
const best = new Map<string, Posting>();
|
|
@@ -170,13 +208,15 @@ export function indexCorpus(docs: readonly FindDoc[]): FindIndex {
|
|
|
170
208
|
if (stemmed.length >= 4 && !surfaces.has(stemmed)) surfaces.set(stemmed, canonical(stemmed));
|
|
171
209
|
const prior = best.get(term);
|
|
172
210
|
const fromField = rank > 0 ? field.text : '';
|
|
173
|
-
|
|
174
|
-
|
|
211
|
+
const bit = 1 << FIELD_NAMES.indexOf(field.name);
|
|
212
|
+
if (!prior) best.set(term, { doc, weight, snippet: fromField, snippetRank: rank, fields: bit });
|
|
213
|
+
else {
|
|
175
214
|
best.set(term, {
|
|
176
215
|
doc,
|
|
177
216
|
weight: Math.max(weight, prior.weight),
|
|
178
217
|
snippet: rank > prior.snippetRank ? fromField : prior.snippet,
|
|
179
|
-
snippetRank: Math.max(rank, prior.snippetRank)
|
|
218
|
+
snippetRank: Math.max(rank, prior.snippetRank),
|
|
219
|
+
fields: prior.fields | bit
|
|
180
220
|
});
|
|
181
221
|
}
|
|
182
222
|
}
|
|
@@ -204,6 +244,11 @@ function candidatesFor(piece: string, index: FindIndex): Candidate[] {
|
|
|
204
244
|
if (piece.length >= 4) {
|
|
205
245
|
const limit = piece.length > 6 ? 2 : 1;
|
|
206
246
|
const consider = (surface: string, term: string) => {
|
|
247
|
+
if (surface.length > piece.length && surface.startsWith(piece)) {
|
|
248
|
+
// The start of a longer word, so a word is found while it is still being typed.
|
|
249
|
+
if ((factors.get(term) ?? 0) < 0.6) factors.set(term, 0.6);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
207
252
|
if (Math.abs(surface.length - piece.length) > limit) return;
|
|
208
253
|
const dist = damerau(piece, surface, limit);
|
|
209
254
|
if (dist <= 0 || dist > limit) return;
|
|
@@ -308,9 +353,13 @@ function lineSnippet(text: string, terms: ReadonlySet<string>): string {
|
|
|
308
353
|
return best.length > 140 ? `${best.slice(0, 137)}…` : best;
|
|
309
354
|
}
|
|
310
355
|
|
|
311
|
-
type Scored = Ranked & { readonly misses: number };
|
|
356
|
+
type Scored = Ranked & { readonly misses: number; readonly index: number };
|
|
312
357
|
|
|
313
|
-
|
|
358
|
+
/**
|
|
359
|
+
* Scores every document that holds at least one query word. A word is matched through its candidates (itself, its
|
|
360
|
+
* everyday synonyms, close spellings, longer words it starts); `allow` leaves documents out before they are scored.
|
|
361
|
+
*/
|
|
362
|
+
function scoreAll(index: FindIndex, query: string, allow?: (docIndex: number) => boolean): Scored[] {
|
|
314
363
|
const pieces: string[] = [];
|
|
315
364
|
const seen = new Set<string>();
|
|
316
365
|
for (const piece of query.toLowerCase().split(/[^a-z0-9]+/)) {
|
|
@@ -328,36 +377,61 @@ function scoreAll(index: FindIndex, query: string): Scored[] {
|
|
|
328
377
|
return { candidates, rare: df * 2 <= total };
|
|
329
378
|
});
|
|
330
379
|
const queryTerms = new Set(queries.flatMap((entry) => entry.candidates.map((candidate) => candidate.term)));
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
for (const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
380
|
+
const rareCount = queries.filter((entry) => entry.rare).length;
|
|
381
|
+
type Acc = { score: number; rareHits: number; snippet: string; snippetRank: number; fields: number; fuzzy: boolean };
|
|
382
|
+
const acc = new Map<number, Acc>();
|
|
383
|
+
for (const entry of queries) {
|
|
384
|
+
const best = new Map<number, { value: number; snippet: string; rank: number; fields: number; factor: number }>();
|
|
385
|
+
for (const candidate of entry.candidates) {
|
|
386
|
+
const postings = index.postings.get(candidate.term);
|
|
387
|
+
if (!postings) continue;
|
|
388
|
+
const weightOfTerm = idf(index.df.get(candidate.term) ?? 0, total);
|
|
389
|
+
for (const posting of postings) {
|
|
390
|
+
if (allow && !allow(posting.doc)) continue;
|
|
391
|
+
const value = weightOfTerm * posting.weight * candidate.factor;
|
|
392
|
+
const prior = best.get(posting.doc);
|
|
393
|
+
if (!prior) best.set(posting.doc, { value, snippet: posting.snippet, rank: posting.snippetRank, fields: posting.fields, factor: candidate.factor });
|
|
394
|
+
else {
|
|
395
|
+
if (value > prior.value) {
|
|
396
|
+
prior.value = value;
|
|
397
|
+
prior.factor = candidate.factor;
|
|
398
|
+
prior.fields = posting.fields;
|
|
399
|
+
}
|
|
400
|
+
if (posting.snippetRank > prior.rank) {
|
|
401
|
+
prior.rank = posting.snippetRank;
|
|
402
|
+
prior.snippet = posting.snippet;
|
|
403
|
+
}
|
|
350
404
|
}
|
|
351
405
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
406
|
+
}
|
|
407
|
+
for (const [doc, hit] of best) {
|
|
408
|
+
const own = acc.get(doc) ?? { score: 0, rareHits: 0, snippet: '', snippetRank: 0, fields: 0, fuzzy: false };
|
|
409
|
+
own.score += hit.value;
|
|
410
|
+
if (entry.rare) own.rareHits++;
|
|
411
|
+
own.fields |= hit.fields;
|
|
412
|
+
if (hit.factor < 1) own.fuzzy = true;
|
|
413
|
+
if (hit.rank > own.snippetRank && hit.snippet) {
|
|
414
|
+
own.snippetRank = hit.rank;
|
|
415
|
+
own.snippet = hit.snippet;
|
|
357
416
|
}
|
|
417
|
+
acc.set(doc, own);
|
|
358
418
|
}
|
|
359
|
-
|
|
360
|
-
|
|
419
|
+
}
|
|
420
|
+
const out: Scored[] = [];
|
|
421
|
+
for (const [docIndex, hit] of acc) {
|
|
422
|
+
if (hit.score <= 0) continue;
|
|
423
|
+
const fields = FIELD_NAMES.filter((_, at) => (hit.fields & (1 << at)) !== 0);
|
|
424
|
+
out.push({
|
|
425
|
+
doc: index.docs[docIndex]!,
|
|
426
|
+
index: docIndex,
|
|
427
|
+
score: hit.score,
|
|
428
|
+
misses: rareCount - hit.rareHits,
|
|
429
|
+
snippet: lineSnippet(hit.snippet, queryTerms),
|
|
430
|
+
fields,
|
|
431
|
+
...(hit.fuzzy ? { fuzzy: true } : {})
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
return out;
|
|
361
435
|
}
|
|
362
436
|
|
|
363
437
|
/** Journeys first, then failing runs. An empty box stays a short list of stories. */
|
|
@@ -367,41 +441,115 @@ export function suggest(docs: readonly FindDoc[], limit = 40): Ranked[] {
|
|
|
367
441
|
return [...journeys, ...failing].slice(0, limit).map((doc) => ({ doc, score: 0, snippet: '' }));
|
|
368
442
|
}
|
|
369
443
|
|
|
444
|
+
const textOf = new WeakMap<FindDoc, string>();
|
|
445
|
+
/** Everything a document says, lowercased, for literal (quoted or identifier) matches. */
|
|
446
|
+
function literalText(doc: FindDoc): string {
|
|
447
|
+
let text = textOf.get(doc);
|
|
448
|
+
if (text === undefined) {
|
|
449
|
+
text = [doc.id, doc.title, doc.subtitle, ...doc.fields.map((field) => field.text)].join('\n').toLowerCase();
|
|
450
|
+
textOf.set(doc, text);
|
|
451
|
+
}
|
|
452
|
+
return text;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** The literal phrases and identifiers of a search that a document contains, or null when it lacks one. */
|
|
456
|
+
function literalsIn(doc: FindDoc, parsed: ParsedSearch): string[] | null {
|
|
457
|
+
const found: string[] = [];
|
|
458
|
+
const text = literalText(doc);
|
|
459
|
+
for (const literal of [...parsed.phrases, ...parsed.identifiers]) {
|
|
460
|
+
if (!text.includes(literal)) return null;
|
|
461
|
+
found.push(literal);
|
|
462
|
+
}
|
|
463
|
+
return found;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** How a document matched the words, phrases and identifiers of a search (not its filters). */
|
|
467
|
+
export type TextMatch = Ranked & { readonly order: number; readonly misses: number };
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* The documents that match the text of a search: its words (every distinctive word found, unless `partial`), and every
|
|
471
|
+
* quoted phrase and identifier literally. Filters are not applied here; callers apply them with `passesFilters`, so
|
|
472
|
+
* a count can leave one out. Returns null when the search has no text at all. Hits are best first: exact before
|
|
473
|
+
* fuzzy, then by score and title.
|
|
474
|
+
*
|
|
475
|
+
* With `partial: true` a document that misses a distinctive word is kept, flagged `partial`, after those that do not.
|
|
476
|
+
*/
|
|
477
|
+
export function matchText(index: FindIndex, parsed: ParsedSearch, options: { readonly partial?: boolean; readonly words?: string; readonly allow?: (doc: FindDoc) => boolean; readonly snippets?: boolean } = {}): TextMatch[] | null {
|
|
478
|
+
const words = options.words ?? parsed.words;
|
|
479
|
+
const literal = parsed.phrases.length + parsed.identifiers.length > 0;
|
|
480
|
+
const hasWords = tokens(words).length > 0;
|
|
481
|
+
if (!hasWords && !literal) return null;
|
|
482
|
+
const allow = options.allow;
|
|
483
|
+
const pass = (docIndex: number) => {
|
|
484
|
+
const doc = index.docs[docIndex]!;
|
|
485
|
+
return (!allow || allow(doc)) && (!literal || literalsIn(doc, parsed) !== null);
|
|
486
|
+
};
|
|
487
|
+
let hits: TextMatch[];
|
|
488
|
+
if (hasWords) {
|
|
489
|
+
hits = [];
|
|
490
|
+
for (const scored of scoreAll(index, words, pass)) {
|
|
491
|
+
if (scored.misses > 0 && !options.partial) continue;
|
|
492
|
+
const literals = literal ? literalsIn(scored.doc, parsed) : [];
|
|
493
|
+
hits.push({ doc: scored.doc, score: scored.score, snippet: options.snippets === false ? '' : scored.snippet, fields: scored.fields, ...(scored.fuzzy ? { fuzzy: true } : {}), ...(scored.misses > 0 ? { partial: true } : {}), ...(literals?.length ? { exact: true, literals } : {}), order: scored.index, misses: scored.misses });
|
|
494
|
+
}
|
|
495
|
+
} else {
|
|
496
|
+
hits = [];
|
|
497
|
+
index.docs.forEach((doc, order) => {
|
|
498
|
+
if (!pass(order)) return;
|
|
499
|
+
hits.push({ doc, score: 0, snippet: '', exact: true, literals: literalsIn(doc, parsed) ?? [], order, misses: 0 });
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
const compare = (a: TextMatch, b: TextMatch) =>
|
|
503
|
+
a.misses - b.misses ||
|
|
504
|
+
Number(b.exact === true) - Number(a.exact === true) ||
|
|
505
|
+
b.score - a.score ||
|
|
506
|
+
(a.doc.title < b.doc.title ? -1 : a.doc.title > b.doc.title ? 1 : 0);
|
|
507
|
+
return hits.sort(compare);
|
|
508
|
+
}
|
|
509
|
+
|
|
370
510
|
/**
|
|
371
|
-
* Rank documents for a query. A document that misses a
|
|
372
|
-
* document that explains it, so a word that is in almost every row cannot win on its own.
|
|
373
|
-
* Matching a document also lists its `related` rows.
|
|
511
|
+
* Rank documents for a query typed in a search box. A document that misses a distinctive word sinks below every
|
|
512
|
+
* document that explains it (and is flagged `partial`), so a word that is in almost every row cannot win on its own.
|
|
513
|
+
* Matching a document also lists its `related` rows. Filters and quoted phrases in the query narrow the result; a
|
|
514
|
+
* kind word (`gherkin`) keeps only that kind of spec unless `kinds` is false.
|
|
374
515
|
*/
|
|
375
|
-
export function rankIndex(index: FindIndex, query: string, limit = 40): Ranked[] {
|
|
376
|
-
const
|
|
377
|
-
const
|
|
378
|
-
|
|
516
|
+
export function rankIndex(index: FindIndex, query: string, limit = 40, options: { readonly kinds?: boolean } = {}): Ranked[] {
|
|
517
|
+
const parsed = parseSearch(query);
|
|
518
|
+
const kinds = options.kinds !== false;
|
|
519
|
+
const { notations, rest } = kinds ? splitQuery(parsed.words) : { notations: new Set<string>(), rest: parsed.words };
|
|
520
|
+
const allows = (doc: FindDoc) => (notations.size === 0 || notations.has(notationOf(doc))) && passesFilters(doc.facets, parsed.filters);
|
|
521
|
+
const matched = matchText(index, parsed, { partial: true, words: rest, allow: allows });
|
|
522
|
+
if (matched === null) {
|
|
523
|
+
if (parsed.tokens.length > 0) {
|
|
524
|
+
return index.docs.filter(allows).slice(0, limit).map((doc) => ({ doc, score: 0, snippet: '' }));
|
|
525
|
+
}
|
|
379
526
|
if (notations.size === 0) return suggest(index.docs, limit);
|
|
380
527
|
return listOfType(index.docs.filter(allows), limit);
|
|
381
528
|
}
|
|
382
|
-
const scored = scoreAll(index, rest).filter((entry) => entry.score > 0 && allows(entry.doc));
|
|
383
|
-
scored.sort((a, b) => a.misses - b.misses || b.score - a.score || (a.doc.title < b.doc.title ? -1 : a.doc.title > b.doc.title ? 1 : 0));
|
|
384
529
|
const byId = new Map(index.docs.map((doc) => [doc.id, doc]));
|
|
385
530
|
const seen = new Set<string>();
|
|
386
531
|
const out: Ranked[] = [];
|
|
387
|
-
|
|
532
|
+
const own = new Map(matched.map((hit) => [hit.doc.id, hit]));
|
|
533
|
+
for (const hit of matched) {
|
|
388
534
|
if (out.length >= limit) break;
|
|
389
535
|
if (!seen.has(hit.doc.id)) {
|
|
390
|
-
|
|
536
|
+
const { order: _order, misses: _misses, ...ranked } = hit;
|
|
537
|
+
out.push(ranked);
|
|
391
538
|
seen.add(hit.doc.id);
|
|
392
539
|
}
|
|
393
540
|
for (const id of hit.doc.related) {
|
|
394
541
|
if (out.length >= limit || seen.has(id)) continue;
|
|
395
542
|
const doc = byId.get(id);
|
|
396
543
|
if (!doc || !allows(doc)) continue;
|
|
397
|
-
const
|
|
398
|
-
out.push({ doc, score: hit.score, snippet:
|
|
544
|
+
const inherited = own.get(id);
|
|
545
|
+
out.push({ doc, score: hit.score, snippet: inherited?.snippet || hit.snippet, ...(inherited ? {} : { related: true as const }) });
|
|
399
546
|
seen.add(id);
|
|
400
547
|
}
|
|
401
548
|
}
|
|
402
549
|
return out;
|
|
403
550
|
}
|
|
404
551
|
|
|
552
|
+
/** Index `docs` and rank them for `query`: see {@link rankIndex}. */
|
|
405
553
|
export function rank(docs: readonly FindDoc[], query: string, limit = 40): Ranked[] {
|
|
406
554
|
return rankIndex(indexCorpus(docs), query, limit);
|
|
407
555
|
}
|