@crvouga/atlas 1.5.0 → 1.7.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 +13 -1
- package/docs/extending.md +17 -0
- package/docs/ui.md +25 -1
- package/llms-full.txt +42 -1
- package/package.json +1 -1
- package/packages/visualizer/README.md +28 -0
- package/packages/visualizer/src/evidence/behavior-panel.tsx +10 -16
- package/packages/visualizer/src/evidence/check-detail.tsx +50 -15
- package/packages/visualizer/src/evidence/outcome-summary.tsx +208 -0
- package/packages/visualizer/src/evidence/outcome.test.ts +129 -0
- package/packages/visualizer/src/evidence/outcome.ts +309 -0
- package/packages/visualizer/src/explorer/Explorer.tsx +105 -25
- package/packages/visualizer/src/explorer/RunsView.tsx +173 -35
- package/packages/visualizer/src/explorer/TestDetail.tsx +23 -1
- 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 +19 -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,322 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { RunResult, RunSummary, TestSummary } from '@crvouga/atlas/reports/test-catalog';
|
|
3
|
+
|
|
4
|
+
import { behaviorDocs, behaviorId, describeHit, forRuns, resultDoc, runDoc, testHasVideo } from './behaviors';
|
|
5
|
+
import { buildCorpus } from './corpus';
|
|
6
|
+
import { searchList } from './doc-search';
|
|
7
|
+
import { hasFilters, isBlank, parseSearch, passesFilters, withoutToken } from './query';
|
|
8
|
+
import { indexCorpus, rankIndex, type FindDoc } from './rank';
|
|
9
|
+
import { embedQuery, EMBED_CAP, provideEmbedder, semanticNote } from './semantic-search';
|
|
10
|
+
import { applySemantic, type Embedder } from './semantic';
|
|
11
|
+
|
|
12
|
+
const stats = { results: 1, passed: 1, failed: 0, passRate: 1, flaky: false, flakyReason: '', flips: 0, durations: null, firstSeenMs: null, lastSeenMs: null, lastPassedAtMs: null, lastFailedAtMs: null, runs: 1, branches: ['main'] };
|
|
13
|
+
|
|
14
|
+
type Options = { id: string; title: string; spec?: string; domain?: string; env?: string[]; tags?: string[]; status?: TestSummary['status']; video?: boolean; flaky?: boolean; detail?: string; producer?: string };
|
|
15
|
+
function behavior(options: Options): TestSummary {
|
|
16
|
+
const folder = options.id.split('/')[0]!;
|
|
17
|
+
const spec = options.spec ?? `specs/${folder}/${folder}.feature`;
|
|
18
|
+
const status = options.status ?? 'passed';
|
|
19
|
+
const videos = options.video ? [{ name: 'run.webm', owner: 'check' as const, recording: `rec-${options.id}`, available: true }] : undefined;
|
|
20
|
+
const environments = options.env ?? ['desktop-web'];
|
|
21
|
+
return {
|
|
22
|
+
key: JSON.stringify([spec, options.id]),
|
|
23
|
+
id: options.id,
|
|
24
|
+
title: options.title,
|
|
25
|
+
specPath: spec,
|
|
26
|
+
spec: { path: spec, title: `The ${folder} flows`, domain: options.domain ?? 'answers', kind: 'gherkin' },
|
|
27
|
+
status,
|
|
28
|
+
environments,
|
|
29
|
+
producers: [options.producer ?? 'web'],
|
|
30
|
+
outcomes: environments.map((environment) => ({
|
|
31
|
+
at: 1, status, durationMs: 10, environment, producer: options.producer ?? 'web', kind: 'checks', parameters: {}, branch: 'main', sha: null, runUrl: null, run: 'r1', layer: 'l1', backend: 'b', detail: options.detail ?? '',
|
|
32
|
+
evidence: { attachments: videos ? 1 : 0, steps: 0, annotations: 0, attempts: 0, links: 0, tags: [], labels: {}, roles: videos ? ['video'] : [], ...(videos ? { videos } : {}) }
|
|
33
|
+
})),
|
|
34
|
+
stats: { ...stats, flaky: options.flaky ?? false },
|
|
35
|
+
tags: options.tags ?? [],
|
|
36
|
+
labels: {},
|
|
37
|
+
evidence: { attachments: 0, steps: 0, attempts: 0, roles: [] },
|
|
38
|
+
lastRecordedAtMs: 1,
|
|
39
|
+
live: false
|
|
40
|
+
} as unknown as TestSummary;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ANSWER = behavior({ id: 'memory/keep', title: 'Keep a saved answer', domain: 'checkout', env: ['ios'], video: true });
|
|
44
|
+
const OTHERS = [
|
|
45
|
+
behavior({ id: 'memory/forget', title: 'Forget a saved note', domain: 'checkout', env: ['ios'], status: 'failed', detail: 'Expected the note to be gone' }),
|
|
46
|
+
behavior({ id: 'rooms/video', title: 'Join the product video room', domain: 'rooms', env: ['desktop-web'] }),
|
|
47
|
+
behavior({ id: 'checkout/pay', title: 'Pay for the order', domain: 'checkout', env: ['android'], tags: ['smoke'] }),
|
|
48
|
+
behavior({ id: 'billing/refund', title: 'Refund a payment', domain: 'billing', env: ['desktop-web'], flaky: true }),
|
|
49
|
+
behavior({ id: 'profile/photo', title: 'Change a profile photo', domain: 'profile', env: ['ios', 'android'] })
|
|
50
|
+
];
|
|
51
|
+
const TESTS = [ANSWER, ...OTHERS];
|
|
52
|
+
|
|
53
|
+
function listIds(query: string, tests: readonly TestSummary[] = TESTS): string[] {
|
|
54
|
+
const docs = [...behaviorDocs(tests).values()];
|
|
55
|
+
const parsed = parseSearch(query);
|
|
56
|
+
const hits = searchList(indexCorpus(docs), parsed);
|
|
57
|
+
return docs.filter((doc) => (!hits || hits.has(doc.id)) && passesFilters(doc.facets, parsed.filters)).map((doc) => doc.id);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('the query language', () => {
|
|
61
|
+
test('reads words, quoted phrases, identifiers and filters apart', () => {
|
|
62
|
+
const parsed = parseSearch('domain:checkout has:video platform:ios status:failed "saved answer" lists/add.feature keep');
|
|
63
|
+
expect(parsed.words).toBe('keep');
|
|
64
|
+
expect(parsed.phrases).toEqual(['saved answer']);
|
|
65
|
+
expect(parsed.identifiers).toEqual(['lists/add.feature']);
|
|
66
|
+
expect(parsed.filters).toEqual({ status: ['failed'], env: ['ios'], producer: [], domain: ['checkout'], spec: [], tag: [], branch: [], video: true });
|
|
67
|
+
expect(parsed.issues).toEqual([]);
|
|
68
|
+
expect(parsed.tokens.map((token) => token.key)).toEqual(['domain', 'video', 'env', 'status']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('several values and repeated fields are alternatives; a quoted value keeps its spaces', () => {
|
|
72
|
+
const parsed = parseSearch('status:failed,flaky env:ios env:android domain:"user account" tag:#smoke');
|
|
73
|
+
expect(parsed.filters.status).toEqual(['failed', 'flaky']);
|
|
74
|
+
expect(parsed.filters.env).toEqual(['ios', 'android']);
|
|
75
|
+
expect(parsed.filters.domain).toEqual(['user account']);
|
|
76
|
+
expect(parsed.filters.tag).toEqual(['smoke']);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('what cannot be used is reported, never dropped silently, and filters nothing', () => {
|
|
80
|
+
const parsed = parseSearch('colour:red status:great has:smell domain: "" "never closed');
|
|
81
|
+
expect(parsed.issues.map((issue) => issue.token)).toEqual(['colour:red', 'status:great', 'has:smell', 'domain:', '""', '"never']);
|
|
82
|
+
expect(parsed.issues.every((issue) => issue.message.includes('has:video'))).toBe(true);
|
|
83
|
+
expect(hasFilters(parsed.filters)).toBe(false);
|
|
84
|
+
expect(parsed.words).toContain('closed');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('prose with a colon is a word, and a blank query is blank', () => {
|
|
88
|
+
expect(parseSearch('Done: all').words).toBe('Done all');
|
|
89
|
+
expect(isBlank(parseSearch(' '))).toBe(true);
|
|
90
|
+
expect(isBlank(parseSearch('has:video'))).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('removing a filter leaves the rest of the text as typed', () => {
|
|
94
|
+
const parsed = parseSearch('keep status:failed answer');
|
|
95
|
+
expect(withoutToken(parsed.raw, parsed.tokens[0]!)).toBe('keep answer');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('one matching rule everywhere', () => {
|
|
100
|
+
const index = indexCorpus([...behaviorDocs(TESTS).values()]);
|
|
101
|
+
|
|
102
|
+
test('a misspelling finds the behavior in the finder, the list and the results of a run', () => {
|
|
103
|
+
// The finder: the corpus of a project (its runs group holds the same behaviors).
|
|
104
|
+
const corpus = buildCorpus({
|
|
105
|
+
charts: [], states: [], transitions: [], journeys: [], files: [], texts: new Map(), explorer: true,
|
|
106
|
+
tests: new Map(TESTS.map((test) => [test.key, test])),
|
|
107
|
+
slots: TESTS.map((test) => ({ key: test.key, id: test.id, title: test.title, specPath: test.specPath, status: test.status, detail: '', recording: false }))
|
|
108
|
+
});
|
|
109
|
+
const finder = rankIndex(indexCorpus(corpus), 'savde answer', Infinity).filter((hit) => !hit.partial && !hit.related).map((hit) => hit.doc.id);
|
|
110
|
+
expect(finder).toContain(behaviorId(ANSWER.key));
|
|
111
|
+
|
|
112
|
+
// The list.
|
|
113
|
+
const list = [...searchList(index, parseSearch('savde answer'))!.keys()];
|
|
114
|
+
expect(list).toContain(behaviorId(ANSWER.key));
|
|
115
|
+
|
|
116
|
+
// The results of a run: the same index, narrowed to what the run recorded.
|
|
117
|
+
const results: RunResult[] = [ANSWER, OTHERS[0]!].map((test) => ({ key: test.key, id: test.id, title: test.title, specPath: test.specPath, environment: 'ios', producer: 'web', status: test.status, durationMs: 5, at: 1, layer: 'l1', detail: '' }));
|
|
118
|
+
const inRun = results.filter((result) => list.includes(behaviorId(result.key))).map((result) => behaviorId(result.key));
|
|
119
|
+
expect(inRun).toEqual([behaviorId(ANSWER.key)]);
|
|
120
|
+
|
|
121
|
+
// Identical eligible identities, not merely overlapping ones.
|
|
122
|
+
expect([...list].sort()).toEqual([...finder].sort());
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('every distinctive word must be found; a typed prefix finds the word being typed', () => {
|
|
126
|
+
expect(listIds('saved answer')).toEqual([behaviorId(ANSWER.key)]);
|
|
127
|
+
expect(listIds('refu')).toEqual([behaviorId(OTHERS[3]!.key)]);
|
|
128
|
+
expect(listIds('nothing like it')).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('metadata is searchable: domain, tags, environment and the first line of a failure', () => {
|
|
132
|
+
expect(listIds('smoke')).toEqual([behaviorId(OTHERS[2]!.key)]);
|
|
133
|
+
expect(listIds('android')).toEqual([behaviorId(OTHERS[2]!.key), behaviorId(OTHERS[4]!.key)].sort((a, b) => TESTS.findIndex((t) => behaviorId(t.key) === a) - TESTS.findIndex((t) => behaviorId(t.key) === b)));
|
|
134
|
+
expect(listIds('expected gone')).toEqual([behaviorId(OTHERS[0]!.key)]);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('media availability is a filter, not a word', () => {
|
|
139
|
+
test('has:video keeps only behaviors with a playable recording, whatever their titles say', () => {
|
|
140
|
+
expect(testHasVideo(ANSWER)).toBe(true);
|
|
141
|
+
expect(listIds('has:video')).toEqual([behaviorId(ANSWER.key)]);
|
|
142
|
+
// The word finds the title that says it, and not the recording.
|
|
143
|
+
expect(listIds('video')).toEqual([behaviorId(OTHERS[1]!.key)]);
|
|
144
|
+
expect(listIds('video has:video')).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('a video reference that names no file is not a recording', () => {
|
|
148
|
+
const gone = behavior({ id: 'x/gone', title: 'Gone', video: true });
|
|
149
|
+
(gone.outcomes[0]!.evidence!.videos as unknown as { available: boolean; recording?: string }[])[0] = { available: false } as never;
|
|
150
|
+
expect(testHasVideo(gone)).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('filters combine over the whole dataset', () => {
|
|
155
|
+
const many = Array.from({ length: 300 }, (_, at) =>
|
|
156
|
+
behavior({ id: `bulk/${at}`, title: `Bulk invoice ${at}`, domain: at % 2 ? 'billing' : 'checkout', env: at % 3 === 0 ? ['ios'] : ['android'], status: at % 5 === 0 ? 'failed' : 'passed', video: at % 7 === 0, spec: `specs/bulk/${at}.feature` })
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
test('different fields are ANDed, several values of one field are ORed, and nothing is paged away', () => {
|
|
160
|
+
expect(listIds('invoice', many)).toHaveLength(300);
|
|
161
|
+
const expected = (check: (at: number) => boolean) => many.filter((_, at) => check(at)).length;
|
|
162
|
+
expect(listIds('invoice domain:billing', many)).toHaveLength(expected((at) => at % 2 === 1));
|
|
163
|
+
expect(listIds('invoice domain:billing,checkout', many)).toHaveLength(300);
|
|
164
|
+
expect(listIds('invoice domain:billing env:ios', many)).toHaveLength(expected((at) => at % 2 === 1 && at % 3 === 0));
|
|
165
|
+
expect(listIds('invoice env:ios env:android status:failed has:video', many)).toHaveLength(expected((at) => at % 5 === 0 && at % 7 === 0));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('counts can leave one filter out, to say what a button would add', () => {
|
|
169
|
+
const docs = [...behaviorDocs(many).values()];
|
|
170
|
+
const parsed = parseSearch('status:failed env:ios');
|
|
171
|
+
const both = docs.filter((doc) => passesFilters(doc.facets, parsed.filters));
|
|
172
|
+
const withoutStatus = docs.filter((doc) => passesFilters(doc.facets, parsed.filters, new Set(['status'])));
|
|
173
|
+
expect(withoutStatus.length).toBeGreaterThan(both.length);
|
|
174
|
+
expect(withoutStatus.every((doc) => doc.facets!.env.includes('ios'))).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('a document that carries no facets satisfies no filter', () => {
|
|
178
|
+
expect(passesFilters(undefined, parseSearch('status:failed').filters)).toBe(false);
|
|
179
|
+
expect(passesFilters(undefined, parseSearch('plain words').filters)).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('exact matches are distinguishable and explained', () => {
|
|
184
|
+
const index = indexCorpus([...behaviorDocs(TESTS).values()]);
|
|
185
|
+
|
|
186
|
+
test('a quoted phrase must appear as written and is reported as exact', () => {
|
|
187
|
+
const hits = searchList(index, parseSearch('"saved answer"'))!;
|
|
188
|
+
expect([...hits.keys()]).toEqual([behaviorId(ANSWER.key)]);
|
|
189
|
+
const hit = hits.get(behaviorId(ANSWER.key))!;
|
|
190
|
+
expect(hit.exact).toBe(true);
|
|
191
|
+
expect(describeHit(hit)).toContain('Exact: “saved answer”');
|
|
192
|
+
expect(searchList(index, parseSearch('"answer saved"'))!.size).toBe(0);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('an identifier is matched literally, and exact results rank before fuzzy ones', () => {
|
|
196
|
+
const hits = searchList(index, parseSearch('memory/keep'))!;
|
|
197
|
+
expect([...hits.keys()]).toEqual([behaviorId(ANSWER.key)]);
|
|
198
|
+
expect(hits.get(behaviorId(ANSWER.key))!.exact).toBe(true);
|
|
199
|
+
const mixed = searchList(index, parseSearch('saved "keep a saved"'))!;
|
|
200
|
+
expect([...mixed.keys()][0]).toBe(behaviorId(ANSWER.key));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('a fuzzy hit says which fields matched and that the spelling was close', () => {
|
|
204
|
+
const hit = searchList(index, parseSearch('savde answer'))!.get(behaviorId(ANSWER.key))!;
|
|
205
|
+
expect(hit.exact).toBeUndefined();
|
|
206
|
+
expect(hit.fuzzy).toBe(true);
|
|
207
|
+
expect(describeHit(hit)).toBe('Matches title (close spelling)');
|
|
208
|
+
const meta = searchList(index, parseSearch('smoke'))!.get(behaviorId(OTHERS[2]!.key))!;
|
|
209
|
+
expect(describeHit(meta)).toBe('Matches tags and details');
|
|
210
|
+
const failure = searchList(index, parseSearch('expected gone'))!.get(behaviorId(OTHERS[0]!.key))!;
|
|
211
|
+
expect(describeHit(failure)).toBe('Matches description');
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('runs and their results', () => {
|
|
216
|
+
const run = (key: string, extra: Partial<RunSummary> = {}): RunSummary =>
|
|
217
|
+
({ key, runUrl: null, branch: 'main', sha: 'abcdef1234', startedAtMs: 1, endedAtMs: 2, spanMs: 1, producers: ['web'], environments: ['ios'], layers: 1, tests: 2, counts: { passed: 1, failed: 1, running: 0, 'not-run': 0 }, totalDurationMs: null, failing: [{ key: 'k', title: 'Keep a saved answer', environment: 'ios', detail: 'Expected tea' }], ...extra }) as RunSummary;
|
|
218
|
+
|
|
219
|
+
test('a run is found by the behavior that failed in it, and run filters apply to its facets', () => {
|
|
220
|
+
const failing = runDoc(run('run:1'), 'Run 1');
|
|
221
|
+
const green = runDoc(run('run:2', { counts: { passed: 2, failed: 0, running: 0, 'not-run': 0 }, failing: [], videos: { references: 1, recordings: 1, unavailable: 0 } }), 'Run 2');
|
|
222
|
+
const index = indexCorpus([failing, green]);
|
|
223
|
+
expect([...searchList(index, parseSearch('savde answer'))!.keys()]).toEqual([failing.id]);
|
|
224
|
+
expect(passesFilters(failing.facets, parseSearch('status:failed').filters)).toBe(true);
|
|
225
|
+
expect(passesFilters(green.facets, parseSearch('status:failed').filters)).toBe(false);
|
|
226
|
+
expect(passesFilters(green.facets, parseSearch('has:video env:ios').filters)).toBe(true);
|
|
227
|
+
expect(passesFilters(failing.facets, parseSearch('has:video').filters)).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('filters a run does not have are said so rather than emptying the list', () => {
|
|
231
|
+
const parsed = forRuns(parseSearch('domain:checkout status:failed'));
|
|
232
|
+
expect(parsed.issues.map((issue) => issue.token)).toEqual(['domain:checkout']);
|
|
233
|
+
expect(parsed.issues[0]!.message).toContain('does not apply to runs');
|
|
234
|
+
expect(hasFilters({ ...parsed.filters, status: [] })).toBe(false);
|
|
235
|
+
expect(parsed.tokens.map((token) => token.key)).toEqual(['status']);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('a result of a run takes the status, environment and videos of that run, with the behavior’s own words', () => {
|
|
239
|
+
const result: RunResult = { key: ANSWER.key, id: ANSWER.id, title: ANSWER.title, specPath: ANSWER.specPath, environment: 'android', producer: 'api', status: 'failed', durationMs: 1, at: 1, layer: 'l', detail: '' };
|
|
240
|
+
const doc = resultDoc(result, ANSWER);
|
|
241
|
+
expect(doc.id).toBe(behaviorId(ANSWER.key));
|
|
242
|
+
expect(doc.facets).toMatchObject({ status: ['failed'], env: ['android'], producer: ['api'], video: false, domain: ['checkout'] });
|
|
243
|
+
expect(doc.fields).toEqual(behaviorDocs(TESTS).get(ANSWER.key)!.fields);
|
|
244
|
+
const orphan = resultDoc(result, undefined);
|
|
245
|
+
expect(orphan.title).toBe(ANSWER.title);
|
|
246
|
+
expect(orphan.facets!.env).toEqual(['android']);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('meaning-based search', () => {
|
|
251
|
+
/** A fake model: the words of a text vote for a topic. */
|
|
252
|
+
const TOPICS: Record<string, number> = { keep: 0, saved: 0, remember: 0, reply: 0, answer: 0, pay: 1, refund: 1, payment: 1, photo: 2 };
|
|
253
|
+
const fake: Embedder = {
|
|
254
|
+
async embed(texts) {
|
|
255
|
+
return texts.map((text) => {
|
|
256
|
+
const vector = new Float32Array(3);
|
|
257
|
+
for (const word of text.toLowerCase().split(/[^a-z]+/)) if (word in TOPICS) vector[TOPICS[word]!]! += 1;
|
|
258
|
+
return vector;
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
const docs = [...behaviorDocs(TESTS).values()];
|
|
263
|
+
const index = indexCorpus(docs);
|
|
264
|
+
|
|
265
|
+
async function semanticList(query: string, provider: Embedder) {
|
|
266
|
+
const parsed = parseSearch(query);
|
|
267
|
+
const vectors = new Map<string, Float32Array>();
|
|
268
|
+
let queryVector: Float32Array | null = null;
|
|
269
|
+
await embedQuery(provider, parsed.words, docs, {
|
|
270
|
+
cancelled: () => false,
|
|
271
|
+
onQuery: (vector) => (queryVector = vector),
|
|
272
|
+
onBatch: () => {}
|
|
273
|
+
});
|
|
274
|
+
const { cachedVectors } = await import('./semantic-search');
|
|
275
|
+
for (const [id, vector] of cachedVectors(docs)) vectors.set(id, vector);
|
|
276
|
+
return searchList(index, parsed, { queryVector, vectors, docs });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
test('a behavior with no word in common is returned when a provider is available', async () => {
|
|
280
|
+
provideEmbedder(null);
|
|
281
|
+
expect(searchList(index, parseSearch('remember reply'))?.size ?? 0).toBe(0);
|
|
282
|
+
const hits = await semanticList('remember reply', fake);
|
|
283
|
+
expect([...hits!.keys()]).toContain(behaviorId(ANSWER.key));
|
|
284
|
+
const hit = hits!.get(behaviorId(ANSWER.key))!;
|
|
285
|
+
expect(hit.semantic).toBe(true);
|
|
286
|
+
expect(describeHit(hit)).toBe('Similar meaning');
|
|
287
|
+
// Unrelated behaviors stay out.
|
|
288
|
+
expect(hits!.has(behaviorId(OTHERS[4]!.key))).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('a provider that fails rejects, and the lexical results are what is left', async () => {
|
|
292
|
+
const broken: Embedder = { embed: async () => Promise.reject(new Error('model offline')) };
|
|
293
|
+
await expect(semanticList('keep answer', broken)).rejects.toThrow('model offline');
|
|
294
|
+
const lexical = searchList(index, parseSearch('keep answer'))!;
|
|
295
|
+
expect([...lexical.keys()]).toContain(behaviorId(ANSWER.key));
|
|
296
|
+
expect(semanticNote('failed')).toContain('words and spellings only');
|
|
297
|
+
expect(semanticNote('unavailable')).toContain('not available');
|
|
298
|
+
expect(semanticNote('working')).toContain('Refining');
|
|
299
|
+
expect(semanticNote('ready')).toBeNull();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('an exact phrase is never replaced by a paraphrase', async () => {
|
|
303
|
+
const hits = await semanticList('"remember reply"', fake);
|
|
304
|
+
expect(hits!.size).toBe(0);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('applySemantic keeps exact hits ahead of semantic ones', () => {
|
|
308
|
+
const first: FindDoc = docs[0]!;
|
|
309
|
+
const second: FindDoc = docs[1]!;
|
|
310
|
+
const ranked = applySemantic([first, second], [{ doc: second, score: 1, snippet: '', exact: true }, { doc: first, score: 50, snippet: '' }], Float32Array.of(1, 0, 0), new Map(), 10);
|
|
311
|
+
expect(ranked[0]!.doc.id).toBe(second.id);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('only the best candidates are embedded', async () => {
|
|
315
|
+
const seen: string[][] = [];
|
|
316
|
+
const counting: Embedder = { embed: async (texts) => { seen.push([...texts]); return texts.map(() => Float32Array.of(1)); } };
|
|
317
|
+
const crowd: FindDoc[] = Array.from({ length: EMBED_CAP + 50 }, (_, at) => ({ ...docs[0]!, id: `d${at}`, title: `Title ${at}`, fields: [{ name: 'title', text: `Title ${at}` }] }));
|
|
318
|
+
provideEmbedder(null);
|
|
319
|
+
await embedQuery(counting, 'q', crowd, { cancelled: () => false, onQuery: () => {}, onBatch: () => {} });
|
|
320
|
+
expect(seen.flat().length).toBeLessThanOrEqual(EMBED_CAP + 1);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Semantic search state
|
|
3
|
+
*
|
|
4
|
+
* The optional meaning-based part of every search box. Lexical results are always shown first and never wait for it;
|
|
5
|
+
* when an embedder is available the query and the candidate documents are embedded in the background and the results
|
|
6
|
+
* are re-ranked (`applySemantic`). The state it reports is part of the interface: a search says when it is words only
|
|
7
|
+
* (no embedder here, or the embedder failed) and when it is still refining, so a missing paraphrase is never mistaken
|
|
8
|
+
* for no match.
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useRef, useState } from 'react';
|
|
11
|
+
|
|
12
|
+
import type { FindDoc } from './rank';
|
|
13
|
+
import { createWorkerEmbedder, embeddingsAvailable, embedText, textHash, type Embedder } from './semantic';
|
|
14
|
+
|
|
15
|
+
/** How the semantic part of a search stands. */
|
|
16
|
+
export type SemanticStatus = 'idle' | 'unavailable' | 'working' | 'ready' | 'failed';
|
|
17
|
+
|
|
18
|
+
/** At most this many documents are embedded for one query, lexical matches first. A catalog of thousands is never embedded whole. */
|
|
19
|
+
export const EMBED_CAP = 400;
|
|
20
|
+
|
|
21
|
+
/** What a search box tells a person about its semantic part, or null when there is nothing to say. */
|
|
22
|
+
export function semanticNote(status: SemanticStatus): string | null {
|
|
23
|
+
if (status === 'working') return 'Refining by meaning…';
|
|
24
|
+
if (status === 'unavailable') return 'Matching words and spellings only; meaning-based search is not available here.';
|
|
25
|
+
if (status === 'failed') return 'Matching words and spellings only; meaning-based search failed, so paraphrases may be missing.';
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The vectors of documents already embedded, by their text, so a live run does not embed the suite again. */
|
|
30
|
+
const vectorCache = new Map<string, Float32Array>();
|
|
31
|
+
|
|
32
|
+
let provided: (() => Embedder) | null = null;
|
|
33
|
+
let shared: Embedder | null = null;
|
|
34
|
+
let failure: string | null = null;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Supplies the embedder every search box uses (a host with its own model, or a test with a fake), or null to go back
|
|
38
|
+
* to the browser's worker. Clears a remembered failure and the cached vectors.
|
|
39
|
+
*/
|
|
40
|
+
export function provideEmbedder(factory: (() => Embedder) | null): void {
|
|
41
|
+
provided = factory;
|
|
42
|
+
shared = null;
|
|
43
|
+
failure = null;
|
|
44
|
+
vectorCache.clear();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function embedder(): Embedder | null {
|
|
48
|
+
if (shared) return shared;
|
|
49
|
+
if (provided) shared = provided();
|
|
50
|
+
else if (embeddingsAvailable()) shared = createWorkerEmbedder();
|
|
51
|
+
return shared;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Whether a semantic provider exists here at all. */
|
|
55
|
+
export function semanticAvailable(): boolean {
|
|
56
|
+
return provided !== null || embeddingsAvailable();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The vectors already known for these documents, by document id. */
|
|
60
|
+
export function cachedVectors(docs: readonly FindDoc[]): ReadonlyMap<string, Float32Array> {
|
|
61
|
+
const vectors = new Map<string, Float32Array>();
|
|
62
|
+
for (const doc of docs) {
|
|
63
|
+
const vector = vectorCache.get(textHash(embedText(doc)));
|
|
64
|
+
if (vector) vectors.set(doc.id, vector);
|
|
65
|
+
}
|
|
66
|
+
return vectors;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Embeds the query, then the documents in batches (`ordered`, at most {@link EMBED_CAP}), calling `onQuery` with the
|
|
71
|
+
* query's vector and `onBatch` after each batch. Stops quietly when `cancelled()`; rejects when the embedder fails.
|
|
72
|
+
*/
|
|
73
|
+
export async function embedQuery(
|
|
74
|
+
provider: Embedder,
|
|
75
|
+
needle: string,
|
|
76
|
+
ordered: readonly FindDoc[],
|
|
77
|
+
hooks: { readonly cancelled: () => boolean; readonly onQuery: (vector: Float32Array) => void; readonly onBatch: () => void }
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
const [vector] = await provider.embed([needle]);
|
|
80
|
+
if (hooks.cancelled()) return;
|
|
81
|
+
if (!vector) throw new Error('The embedder returned nothing');
|
|
82
|
+
hooks.onQuery(vector);
|
|
83
|
+
const limited = ordered.slice(0, EMBED_CAP);
|
|
84
|
+
for (let start = 0; start < limited.length; start += 8) {
|
|
85
|
+
if (hooks.cancelled()) return;
|
|
86
|
+
const missing = limited.slice(start, start + 8).filter((doc) => !vectorCache.has(textHash(embedText(doc))));
|
|
87
|
+
if (missing.length > 0) {
|
|
88
|
+
const batch = await provider.embed(missing.map((doc) => embedText(doc)));
|
|
89
|
+
if (hooks.cancelled()) return;
|
|
90
|
+
missing.forEach((doc, at) => {
|
|
91
|
+
const found = batch[at];
|
|
92
|
+
if (found) vectorCache.set(textHash(embedText(doc)), found);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
hooks.onBatch();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** What {@link useSemantic} gives back. */
|
|
100
|
+
export type SemanticState = {
|
|
101
|
+
readonly status: SemanticStatus;
|
|
102
|
+
/** The vector of the query being shown, or null until it is embedded (never the vector of an earlier query). */
|
|
103
|
+
readonly queryVector: Float32Array | null;
|
|
104
|
+
readonly vectors: ReadonlyMap<string, Float32Array>;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Embeds `query` and the best candidates among `docs` in the background while `enabled`. `lexical` are the documents
|
|
109
|
+
* the words found (embedded first). The state says when the search is words only or still refining.
|
|
110
|
+
*/
|
|
111
|
+
export function useSemantic(enabled: boolean, query: string, docs: readonly FindDoc[], lexical: readonly FindDoc[]): SemanticState {
|
|
112
|
+
const needle = query.trim();
|
|
113
|
+
const [state, setState] = useState<{ query: string; status: SemanticStatus; vector: Float32Array | null }>({ query: '', status: 'idle', vector: null });
|
|
114
|
+
const [vectors, setVectors] = useState<ReadonlyMap<string, Float32Array>>(() => new Map());
|
|
115
|
+
const first = useRef(lexical);
|
|
116
|
+
first.current = lexical;
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!enabled || !needle) {
|
|
119
|
+
setState({ query: needle, status: 'idle', vector: null });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const provider = failure === null ? embedder() : null;
|
|
123
|
+
if (failure !== null) {
|
|
124
|
+
setState({ query: needle, status: 'failed', vector: null });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!provider) {
|
|
128
|
+
setState({ query: needle, status: 'unavailable', vector: null });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
let cancelled = false;
|
|
132
|
+
setState({ query: needle, status: 'working', vector: null });
|
|
133
|
+
const ranked = new Set(first.current.map((doc) => doc.id));
|
|
134
|
+
const ordered = [...first.current, ...docs.filter((doc) => !ranked.has(doc.id))];
|
|
135
|
+
void embedQuery(provider, needle, ordered, {
|
|
136
|
+
cancelled: () => cancelled,
|
|
137
|
+
onQuery: (vector) => setState({ query: needle, status: 'working', vector }),
|
|
138
|
+
onBatch: () => setVectors(cachedVectors(docs))
|
|
139
|
+
}).then(
|
|
140
|
+
() => {
|
|
141
|
+
if (!cancelled) setState((held) => (held.query === needle ? { ...held, status: 'ready' } : held));
|
|
142
|
+
},
|
|
143
|
+
(error: unknown) => {
|
|
144
|
+
failure = error instanceof Error ? error.message : 'The embedder failed';
|
|
145
|
+
if (!cancelled) setState({ query: needle, status: 'failed', vector: null });
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
return () => {
|
|
149
|
+
cancelled = true;
|
|
150
|
+
};
|
|
151
|
+
}, [enabled, needle, docs]);
|
|
152
|
+
return { status: state.query === needle ? state.status : enabled && needle ? 'working' : 'idle', queryVector: state.query === needle ? state.vector : null, vectors };
|
|
153
|
+
}
|
|
@@ -54,9 +54,9 @@ export function applySemantic(docs: readonly FindDoc[], lexical: readonly Ranked
|
|
|
54
54
|
if (!vector) continue;
|
|
55
55
|
const similarity = cosine(queryVector, vector);
|
|
56
56
|
if (similarity < SEMANTIC_FLOOR) continue;
|
|
57
|
-
blended.push({ doc, score: similarity * maxLexical * 0.5, snippet: '' });
|
|
57
|
+
blended.push({ doc, score: similarity * maxLexical * 0.5, snippet: '', semantic: true });
|
|
58
58
|
}
|
|
59
|
-
blended.sort((a, b) => b.score - a.score || (a.doc.title < b.doc.title ? -1 : a.doc.title > b.doc.title ? 1 : 0));
|
|
59
|
+
blended.sort((a, b) => Number(b.exact === true) - Number(a.exact === true) || b.score - a.score || (a.doc.title < b.doc.title ? -1 : a.doc.title > b.doc.title ? 1 : 0));
|
|
60
60
|
return blended.slice(0, limit);
|
|
61
61
|
}
|
|
62
62
|
|