@crvouga/atlas 1.9.0 → 1.11.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +15 -1
  2. package/dist/reports/src/review-collection-store.d.ts +47 -0
  3. package/dist/reports/src/review-collection-store.js +214 -0
  4. package/dist/reports/src/review-collection.d.ts +88 -0
  5. package/dist/reports/src/review-collection.js +144 -0
  6. package/dist/reports/src/test-catalog.d.ts +2 -0
  7. package/dist/reports/src/test-catalog.js +1 -1
  8. package/dist/schema/src/api.d.ts +574 -0
  9. package/dist/schema/src/api.js +152 -1
  10. package/dist/server/src/app.js +4 -1
  11. package/dist/server/src/openapi.js +46 -1
  12. package/dist/server/src/review-collections.d.ts +39 -0
  13. package/dist/server/src/review-collections.js +179 -0
  14. package/docs/extending.md +1 -1
  15. package/docs/reference/http-api.md +122 -0
  16. package/docs/reference/reports.md +294 -0
  17. package/docs/reference/schema.md +131 -0
  18. package/docs/reference/server.md +44 -0
  19. package/docs/rich-evidence.md +38 -0
  20. package/docs/server.md +41 -0
  21. package/docs/ui.md +67 -1
  22. package/llms-full.txt +269 -2
  23. package/package.json +1 -1
  24. package/packages/schema/schemas/review-collection.schema.json +398 -0
  25. package/packages/schema/schemas/review-manifest.schema.json +475 -0
  26. package/packages/server/openapi.json +6729 -4084
  27. package/packages/visualizer/src/app/router.tsx +6 -1
  28. package/packages/visualizer/src/evidence/applicability-view.tsx +233 -0
  29. package/packages/visualizer/src/evidence/applicability.test.ts +131 -0
  30. package/packages/visualizer/src/evidence/applicability.ts +241 -0
  31. package/packages/visualizer/src/evidence/artifact-tags.tsx +3 -2
  32. package/packages/visualizer/src/evidence/behavior-panel.tsx +11 -1
  33. package/packages/visualizer/src/evidence/feature-page.tsx +25 -4
  34. package/packages/visualizer/src/explorer/ArtifactsView.tsx +26 -2
  35. package/packages/visualizer/src/explorer/Explorer.tsx +7 -2
  36. package/packages/visualizer/src/explorer/RecordingTextSearch.tsx +355 -0
  37. package/packages/visualizer/src/explorer/RunsView.tsx +11 -1
  38. package/packages/visualizer/src/explorer/collection-model.test.ts +31 -0
  39. package/packages/visualizer/src/explorer/collection-model.ts +45 -0
  40. package/packages/visualizer/src/explorer/collections.tsx +513 -0
  41. package/packages/visualizer/src/explorer/playlist-entries.test.ts +51 -0
  42. package/packages/visualizer/src/explorer/playlist-entries.ts +163 -0
  43. package/packages/visualizer/src/explorer/playlist.tsx +500 -0
  44. package/packages/visualizer/src/explorer/recording-text.test.ts +149 -0
  45. package/packages/visualizer/src/explorer/recording-text.ts +490 -0
  46. package/packages/visualizer/src/explorer/search.ts +10 -2
@@ -12,6 +12,8 @@ import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
12
12
  import { Alert, Badge, Box, Group, List, Paper, Stack, Tabs, Text, Title } from '@mantine/core';
13
13
  import { useMemo, type ReactNode } from 'react';
14
14
 
15
+ import type { Assessment, ReleaseIdentity } from './applicability';
16
+ import { ApplicabilitySummary } from './applicability-view';
15
17
  import { attachmentSource, useCheckDetail } from './artifacts';
16
18
  import { COVERAGE, COVERAGE_ORDER, type Behavior, type CoverageCounts, type CoverageState, type Revision } from './behavior';
17
19
  import { PlainSteps } from './check-detail';
@@ -141,10 +143,16 @@ function Player({ result }: { result: EvidenceResult }) {
141
143
  export function BehaviorPanel({
142
144
  behavior,
143
145
  inspect,
144
- start
146
+ start,
147
+ applicability,
148
+ release = {}
145
149
  }: {
146
150
  behavior: Behavior;
147
151
  inspect: ReactNode;
152
+ /** How the evidence applies to the promise and the named release, shown with the recording and under Inspect. */
153
+ applicability?: Assessment;
154
+ /** The release the reader named, if any. */
155
+ release?: ReleaseIdentity;
148
156
  /** The tab to open on; by default Watch when there is something to watch, Inspect while a run is in progress (its progress lives there), else Read. */ start?:
149
157
  'read' | 'watch' | 'inspect';
150
158
  }) {
@@ -236,11 +244,13 @@ export function BehaviorPanel({
236
244
  </Group>
237
245
  )}
238
246
  <RevisionLine revision={revision} />
247
+ {applicability && <ApplicabilitySummary assessment={applicability} release={release} />}
239
248
  </Stack>
240
249
  </Tabs.Panel>
241
250
  <Tabs.Panel value="inspect">
242
251
  <Stack gap="sm" miw={0}>
243
252
  <RevisionLine revision={revision} />
253
+ {applicability && <ApplicabilitySummary assessment={applicability} release={release} />}
244
254
  {inspect}
245
255
  </Stack>
246
256
  </Tabs.Panel>
@@ -1,8 +1,10 @@
1
1
  import type { CheckSlot, SpecEvidence } from '@crvouga/atlas/reports/evidence-view';
2
- import { Alert, Box, Container, List, Stack, Text, Title } from '@mantine/core';
2
+ import { Alert, Box, Container, Group, List, Stack, Text, Title } from '@mantine/core';
3
3
  import { useMemo } from 'react';
4
4
  import { outlinePath, type SpecKind } from '../spec-kind';
5
5
  import { CheckDetail, PlainSteps, ResultRow, type CheckSelection, type SelectCheck } from './check-detail';
6
+ import { assessBehavior, hasRelease, needsAttention, type Assessment } from './applicability';
7
+ import { ApplicabilityControls, useApplicabilityState, VerdictBadge } from './applicability-view';
6
8
  import { behaviorOf, coverageCounts, scenarioBehaviors, type Behavior, type RevisionSource } from './behavior';
7
9
  import { BehaviorPanel, CoverageBadge, CoverageSummary } from './behavior-panel';
8
10
  import { useEvidence } from './evidence-context';
@@ -69,12 +71,23 @@ export function FeaturePage({ file, text, kind, entry, selection, select }: Prop
69
71
  return { byScenario, all: [...[...byScenario.values()].flat(), ...other] };
70
72
  }, [feature, filed, revisions]);
71
73
  const counts = useMemo(() => coverageCounts(behaviors.all), [behaviors]);
74
+ const applicability = useApplicabilityState();
75
+ const { release, onlyStale } = applicability;
76
+ const assessments = useMemo(
77
+ () => new Map<string, Assessment>(behaviors.all.map((behavior) => [behavior.key, assessBehavior(behavior, revisions, release)] as const)),
78
+ [behaviors, revisions, release]
79
+ );
80
+ const staleCount = useMemo(() => [...assessments.values()].filter(needsAttention).length, [assessments]);
81
+ const hidden = (key: string) => onlyStale && assessments.get(key) !== undefined && !needsAttention(assessments.get(key)!);
72
82
  const planned = behaviors.all.length;
83
+ const leftOver = filed.leftOver.filter((slot) => (!runningOnly || slot.status === 'running') && !hidden(slot.key));
73
84
  const toggle = (id: string) => select({ check: selection.check === id ? null : id, layer: null });
74
85
  const rows = (scenario: FeatureScenario) =>
75
86
  (behaviors.byScenario.get(scenario) ?? []).map((behavior) => {
76
87
  if (runningOnly && behavior.slot?.status !== 'running') return null;
88
+ if (hidden(behavior.key)) return null;
77
89
  const { slot } = behavior;
90
+ const assessed = assessments.get(behavior.key);
78
91
  const written = <PlainSteps steps={scenario.steps} examples={scenario.examples} />;
79
92
  const id = slot ? slot.id : `scenario:${scenario.name}${behavior.example ? `:${behavior.example}` : ''}`;
80
93
  const label = slot
@@ -87,12 +100,19 @@ export function FeaturePage({ file, text, kind, entry, selection, select }: Prop
87
100
  key={id}
88
101
  label={label}
89
102
  slot={slot}
90
- badge={<CoverageBadge state={behavior.state} />}
103
+ badge={
104
+ <Group gap="xs" wrap="wrap">
105
+ <CoverageBadge state={behavior.state} />
106
+ {assessed && (assessed.verdict === 'stale' || (assessed.verdict === 'unknown' && hasRelease(release))) && <VerdictBadge verdict={assessed.verdict} />}
107
+ </Group>
108
+ }
91
109
  open={selection.check === id}
92
110
  onToggle={() => toggle(id)}
93
111
  >
94
112
  <BehaviorPanel
95
113
  behavior={behavior}
114
+ applicability={assessed}
115
+ release={release}
96
116
  start={selection.check === id && selection.layer ? 'inspect' : undefined}
97
117
  inspect={
98
118
  slot ? (
@@ -132,6 +152,7 @@ export function FeaturePage({ file, text, kind, entry, selection, select }: Prop
132
152
  )}
133
153
  </SpecHeader>
134
154
  <CoverageSummary counts={counts} total={behaviors.all.length} />
155
+ {behaviors.all.length > 0 && <ApplicabilityControls state={applicability} stale={staleCount} total={behaviors.all.length} />}
135
156
  <SourceText text={text} file={file} withResults />
136
157
  <Box component="section" id="spec-results" aria-label="Recorded results" tabIndex={-1}>
137
158
  <TestFilter slots={slots} />
@@ -168,7 +189,7 @@ export function FeaturePage({ file, text, kind, entry, selection, select }: Prop
168
189
  </List>
169
190
  </Box>
170
191
  ))}
171
- {filed.leftOver.some((slot) => !runningOnly || slot.status === 'running') && (
192
+ {leftOver.length > 0 && (
172
193
  <Box component="section" mb="lg">
173
194
  <Title order={2} mb="sm">
174
195
  {feature ? 'Other recorded results' : 'Recorded results'}
@@ -179,7 +200,7 @@ export function FeaturePage({ file, text, kind, entry, selection, select }: Prop
179
200
  </Text>
180
201
  )}
181
202
  <List spacing="xs" listStyleType="none">
182
- <Bounded items={filed.leftOver.filter((slot) => !runningOnly || slot.status === 'running')} size={40} more="More results">
203
+ <Bounded items={leftOver} size={40} more="More results">
183
204
  {(slot) => (
184
205
  <ResultRow
185
206
  key={slot.key}
@@ -7,20 +7,26 @@
7
7
  * untagged. Each row opens that exact result in the test detail. An empty answer for a filter says nothing matches and
8
8
  * offers to clear it; it never says the project has nothing recorded.
9
9
  */
10
- import { IconVideo } from '@tabler/icons-react';
11
- import { Alert, Anchor, Box, Button, Code, Stack, Table, Text } from '@mantine/core';
10
+ import { IconLock, IconPlayerPlay, IconVideo } from '@tabler/icons-react';
11
+ import { Alert, Anchor, Box, Button, Code, Group, Stack, Table, Text } from '@mantine/core';
12
12
  import { videoCounts } from '@crvouga/atlas/reports/evidence-detail';
13
13
  import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
14
14
  import { Link } from '@tanstack/react-router';
15
15
  import { useMemo, useState } from 'react';
16
16
 
17
+ import { staticEvidence } from '../evidence/api';
17
18
  import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
18
19
  import { StatusMark } from '../evidence/marks';
19
20
  import { useArtifactList } from './catalog';
21
+ import { FreezeCollection } from './collections';
20
22
  import { FilterBar } from './filters';
21
23
  import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
22
24
  import { runName } from './parts';
25
+ import { entriesFromArtifacts } from './playlist-entries';
26
+ import { WatchPlaylist } from './playlist';
27
+ import { RecordingTextSearch } from './RecordingTextSearch';
23
28
  import { filtered, filterOf, NO_FILTERS } from './search';
29
+ import { filterParams } from '@crvouga/atlas/reports/artifact-query';
24
30
 
25
31
  const ARTIFACT_ROWS = 50;
26
32
 
@@ -33,6 +39,9 @@ export function ArtifactsView() {
33
39
  const answer = useArtifactList(filter);
34
40
  const now = useNow();
35
41
  const [shown, setShown] = useState(ARTIFACT_ROWS);
42
+ const [watching, setWatching] = useState(false);
43
+ const [freezing, setFreezing] = useState(false);
44
+ const entries = useMemo(() => (answer.data && !answer.data.unsupported ? entriesFromArtifacts(answer.data.list.results) : []), [answer.data]);
36
45
  if (answer.isPending)
37
46
  return (
38
47
  <Text role="status" component="div" c="dimmed">
@@ -77,6 +86,21 @@ export function ArtifactsView() {
77
86
  </Button>
78
87
  </Stack>
79
88
  )}
89
+ {list.results.length > 0 && (
90
+ <Group my="sm" gap="sm">
91
+ <Button type="button" variant="filled" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() => setWatching(true)}>
92
+ Watch these {plural(list.results.length, 'result', 'results')}
93
+ </Button>
94
+ {!staticEvidence && (
95
+ <Button type="button" leftSection={<IconLock size={16} aria-hidden="true" />} onClick={() => setFreezing(true)}>
96
+ Freeze as review collection
97
+ </Button>
98
+ )}
99
+ </Group>
100
+ )}
101
+ {watching && <WatchPlaylist entries={entries} total={list.total} onClose={() => setWatching(false)} />}
102
+ {freezing && <FreezeCollection results={list.results} total={list.total} filterQuery={filterParams(filter).toString()} onClose={() => setFreezing(false)} />}
103
+ {list.results.length > 0 && <RecordingTextSearch entries={entries} total={list.total} />}
80
104
  {list.results.length > 0 && (
81
105
  <Table.ScrollContainer minWidth={700}>
82
106
  <Table data-artifacts>
@@ -44,6 +44,7 @@ import {
44
44
  } from '@crvouga/atlas/reports/test-catalog';
45
45
  import { Link } from '@tanstack/react-router';
46
46
  import { useDeferredValue, useEffect, useMemo, useState } from 'react';
47
+ import { staticEvidence } from '../evidence/api';
47
48
  import { useEvidence } from '../evidence/evidence-context';
48
49
  import { ago, plural, useNow } from '../evidence/format';
49
50
  import { Meter, StatusMark } from '../evidence/marks';
@@ -57,6 +58,7 @@ import { groupTests, tally, type TestGroupView } from './groups';
57
58
  import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
58
59
  import { Badges, EnvironmentChips, projectTitle, runName } from './parts';
59
60
  import { ArtifactsView } from './ArtifactsView';
61
+ import { CollectionsView } from './collections';
60
62
  import { RunsView } from './RunsView';
61
63
  import { SearchNotes, TypedFilters } from './search-notes';
62
64
  import { EXPLORER_SORTS, joined, narrowed, queryOf, sortOf, TEST_GROUPS, toggled, values, type ExplorerSearch, type ExplorerSort, type TestGroup } from './search';
@@ -167,7 +169,7 @@ export function Explorer() {
167
169
  const model = catalog.model;
168
170
  const selected = search.test ? (model?.byKey.get(search.test) ?? null) : null;
169
171
  const empty = model !== null && model.tests.length === 0;
170
- const split = !!search.test && search.tab !== 'runs' && search.tab !== 'artifacts';
172
+ const split = !!search.test && search.tab !== 'runs' && search.tab !== 'artifacts' && search.tab !== 'collections';
171
173
  return (
172
174
  <Container size="xl" py="xl">
173
175
  <Box aria-label="Test explorer" component="article" miw={0}>
@@ -178,7 +180,7 @@ export function Explorer() {
178
180
  {model && (model.tests.length > 0 || model.summary.runs > 0) && (
179
181
  <Tabs
180
182
  value={search.tab ?? 'tests'}
181
- onChange={(value) => go({ tab: value === 'runs' || value === 'artifacts' ? value : undefined, test: undefined, openRun: undefined, layer: undefined, artifact: undefined })}
183
+ onChange={(value) => go({ tab: value === 'runs' || value === 'artifacts' || value === 'collections' ? value : undefined, test: undefined, openRun: undefined, layer: undefined, artifact: undefined, collection: undefined, rev: undefined })}
182
184
  >
183
185
  <Tabs.List aria-label="Explorer view">
184
186
  <Tabs.Tab
@@ -202,6 +204,7 @@ export function Explorer() {
202
204
  Runs
203
205
  </Tabs.Tab>
204
206
  <Tabs.Tab value="artifacts">Artifacts</Tabs.Tab>
207
+ {!staticEvidence && <Tabs.Tab value="collections">Collections</Tabs.Tab>}
205
208
  </Tabs.List>
206
209
  </Tabs>
207
210
  )}
@@ -266,6 +269,8 @@ export function Explorer() {
266
269
  <RunsView />
267
270
  ) : search.tab === 'artifacts' ? (
268
271
  <ArtifactsView />
272
+ ) : search.tab === 'collections' ? (
273
+ <CollectionsView />
269
274
  ) : empty ? null : (
270
275
  <>
271
276
  <InsightStrip model={model} scope={listing.scope} search={search} />
@@ -0,0 +1,355 @@
1
+ /**
2
+ * @module Search inside recordings
3
+ *
4
+ * Finds words that were spoken or shown on screen in the recordings of the Artifacts tab, from the text their producers
5
+ * supplied (WebVTT, SubRip, timed JSON, a transcript; see `recording-text.ts`). It is the shared search (`find/`) applied
6
+ * to the indexed segments, so phrases, misspellings, filters typed in the box and meaning-based matches behave as in every
7
+ * other search box, and a result always shows the words as indexed, never a reworded quotation.
8
+ *
9
+ * Nothing is read until words are typed. Then, a few at a time, each listed result's evidence is read through the same
10
+ * report API that plays its recording, and so under the same access rules: a file the viewer may not read is never
11
+ * indexed, appears in no snippet or count, and is reported as a recording that could not be searched. The index lives
12
+ * in this page only; there is no stored or cross-project index. "No match" is said only about recordings whose text was
13
+ * searched: recordings with no text, silent ones and unreadable text are listed apart, each in its own words. A
14
+ * match finds a moment; it says nothing about whether the behavior passed, and the result's own outcome stays beside it.
15
+ */
16
+ import { Accordion, Alert, Anchor, Badge, Button, Group, Highlight, List, Stack, Table, Text, TextInput, Title } from '@mantine/core';
17
+ import { IconPlayerPlay } from '@tabler/icons-react';
18
+ import { useQueryClient } from '@tanstack/react-query';
19
+ import { Link } from '@tanstack/react-router';
20
+ import { useEffect, useMemo, useRef, useState } from 'react';
21
+
22
+ import { ReportsError, reportText } from '../evidence/api';
23
+ import { attachmentSource } from '../evidence/artifacts';
24
+ import { plural, statusWord } from '../evidence/format';
25
+ import { StatusMark } from '../evidence/marks';
26
+ import { useDocSearch } from '../find/doc-search';
27
+ import { passesFilters } from '../find/query';
28
+ import type { Ranked } from '../find/rank';
29
+ import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
30
+ import { entryDetailQuery, WatchPlaylist } from './playlist';
31
+ import { recordingState, type PlaylistEntry } from './playlist-entries';
32
+ import {
33
+ coverageCounts,
34
+ coverageOf,
35
+ focusOf,
36
+ indexTrack,
37
+ rangeWord,
38
+ segmentDoc,
39
+ segmentsOf,
40
+ TEXT_KIND_WORD,
41
+ tracksOf,
42
+ trackReadable,
43
+ uncertaintyWord,
44
+ unreadTrack,
45
+ type IndexedSegment,
46
+ type RecordingFocus,
47
+ type TrackResult,
48
+ type VideoCoverage
49
+ } from './recording-text';
50
+ import { SearchNotes, TypedFilters } from './search-notes';
51
+ import type { CheckDetail } from '@crvouga/atlas/reports/evidence-detail';
52
+
53
+ /** Results listed before "more". */
54
+ const LISTED = 10;
55
+ /** Results read at once. */
56
+ const READERS = 4;
57
+
58
+ /** What was learned about one result's recordings. */
59
+ type Read =
60
+ | { readonly state: 'read'; readonly videos: readonly VideoCoverage[]; readonly segments: readonly IndexedSegment[] }
61
+ | { readonly state: 'unreadable'; readonly reason: string };
62
+
63
+ function problem(error: unknown): string {
64
+ if (error instanceof ReportsError) {
65
+ if (error.status === 401 || error.status === 403) return 'You are not permitted to read this, so it is not searched.';
66
+ if (error.status === 404) return 'It is not in the report store, so it is not searched.';
67
+ }
68
+ return error instanceof Error ? error.message : 'It could not be read.';
69
+ }
70
+
71
+ /**
72
+ * Reads, once `enabled`, the text tracks of each result that has a recording, a few results at a time. What is read lands
73
+ * as it arrives; `read` of `total` say how far it is.
74
+ */
75
+ function useRecordingText(entries: readonly PlaylistEntry[], enabled: boolean) {
76
+ const client = useQueryClient();
77
+ const cache = useRef(new Map<string, Read>());
78
+ const [reads, setReads] = useState<ReadonlyMap<string, Read>>(() => new Map());
79
+ const playable = useMemo(() => entries.filter((entry) => recordingState(entry) === 'playable' && entryDetailQuery(entry) !== null), [entries]);
80
+ const key = playable.map((entry) => entry.id).join('\n');
81
+ useEffect(() => {
82
+ if (!enabled) return;
83
+ let cancelled = false;
84
+ const wanted = playable.filter((entry) => !cache.current.has(entry.id));
85
+ let cursor = 0;
86
+ const readTrack = async (entry: PlaylistEntry, track: ReturnType<typeof tracksOf>[number]): Promise<TrackResult> => {
87
+ const readable = trackReadable(track);
88
+ if (!readable.readable) return { track, state: readable.state, reason: readable.reason, segments: [], timing: 'absent', truncated: false };
89
+ const attachment = track.attachment;
90
+ if (attachment.text !== undefined) return indexTrack(track, attachment.text);
91
+ const path = attachmentSource(attachment, entry.layer).path;
92
+ if (!path) return unreadTrack(track, 'The text names no stored file.');
93
+ try {
94
+ return indexTrack(track, await client.fetchQuery({ queryKey: ['artifact-text', path], queryFn: ({ signal }) => reportText(path, signal), staleTime: Number.POSITIVE_INFINITY, retry: false }));
95
+ } catch (error) {
96
+ return unreadTrack(track, problem(error));
97
+ }
98
+ };
99
+ const readEntry = async (entry: PlaylistEntry): Promise<Read> => {
100
+ let detail: CheckDetail;
101
+ try {
102
+ detail = await client.fetchQuery(entryDetailQuery(entry)!);
103
+ } catch (error) {
104
+ return { state: 'unreadable', reason: `Its evidence could not be read. ${problem(error)}` };
105
+ }
106
+ const results: TrackResult[] = [];
107
+ for (const track of tracksOf(detail)) results.push(await readTrack(entry, track));
108
+ const videos = coverageOf(detail, results);
109
+ return { state: 'read', videos, segments: segmentsOf(entry, detail, results, videos) };
110
+ };
111
+ const pull = async () => {
112
+ for (;;) {
113
+ const entry = wanted[cursor++];
114
+ if (entry === undefined || cancelled) return;
115
+ const done = await readEntry(entry);
116
+ if (cancelled) return;
117
+ cache.current.set(entry.id, done);
118
+ setReads(new Map(cache.current));
119
+ }
120
+ };
121
+ void Promise.all(Array.from({ length: READERS }, pull));
122
+ return () => {
123
+ cancelled = true;
124
+ };
125
+ }, [client, enabled, key, playable]);
126
+ const read = useMemo(() => playable.filter((entry) => reads.has(entry.id)).length, [playable, reads]);
127
+ return { reads, playable, read, total: playable.length };
128
+ }
129
+
130
+ /** Why a segment is in the results, in words that do not claim more than was matched. */
131
+ function why(hit: Ranked): string {
132
+ if (hit.exact && hit.literals?.length) return `Exact: ${hit.literals.map((literal) => `“${literal}”`).join(', ')}`;
133
+ if (hit.semantic) return 'Similar meaning to the indexed words';
134
+ if (hit.fuzzy) return 'Close spelling of the indexed words';
135
+ if (hit.partial) return 'Some of the words are in the indexed text';
136
+ return 'The words are in the indexed text';
137
+ }
138
+
139
+ const COVERAGE_WORD = { 'not-indexed': 'Not indexed', unsupported: 'Unsupported', failed: 'Failed', searched: 'Searched' } as const;
140
+ const COVERAGE_COLOR = { 'not-indexed': 'gray', unsupported: 'orange', failed: 'red', searched: 'green' } as const;
141
+
142
+ /** A recording that was not searched, and why. */
143
+ type Unsearched = { readonly key: string; readonly entry: PlaylistEntry; readonly name: string | null; readonly attempt: string; readonly state: 'not-indexed' | 'unsupported' | 'failed'; readonly reason: string };
144
+
145
+ /** The Artifacts tab's search of the words in its recordings. `entries` are the listed results; `total` how many matched the filters. */
146
+ export function RecordingTextSearch({ entries, total }: { entries: readonly PlaylistEntry[]; total: number }) {
147
+ const search = useExplorerSearch();
148
+ const go = useExplorerNavigate();
149
+ const [draft, setDraft] = useState(search.inside ?? '');
150
+ useEffect(() => setDraft((current) => (current.trim() === (search.inside ?? '').trim() ? current : (search.inside ?? ''))), [search.inside]);
151
+ const [shown, setShown] = useState(LISTED);
152
+ const [opened, setOpened] = useState<RecordingFocus | null>(null);
153
+ const query = search.inside;
154
+ const { reads, playable, read, total: readable } = useRecordingText(entries, !!query);
155
+ const items = useMemo(() => [...reads.values()].flatMap((item) => (item.state === 'read' ? item.segments : [])), [reads]);
156
+ const docs = useMemo(() => items.map(segmentDoc), [items]);
157
+ const byId = useMemo(() => new Map(items.map((item) => [item.id, item] as const)), [items]);
158
+ const found = useDocSearch(docs, query);
159
+ const matches = useMemo(() => (found.hits ? [...found.hits.values()].filter((hit) => passesFilters(hit.doc.facets, found.parsed.filters)) : null), [found.hits, found.parsed.filters]);
160
+ const unsearched = useMemo<Unsearched[]>(() => {
161
+ const out: Unsearched[] = [];
162
+ for (const entry of playable) {
163
+ const item = reads.get(entry.id);
164
+ if (!item) continue;
165
+ if (item.state === 'unreadable') out.push({ key: entry.id, entry, name: null, attempt: '', state: 'failed', reason: item.reason });
166
+ else
167
+ for (const video of item.videos)
168
+ if (video.state !== 'searched') out.push({ key: `${entry.id}|${video.name}|${video.attempt}`, entry, name: video.name, attempt: video.attempt === null ? '' : `Attempt ${video.attempt + 1}`, state: video.state, reason: video.reason });
169
+ // A recording the evidence lists no video for, though the catalog does, is counted as not indexed.
170
+ if (item.state === 'read' && item.videos.length === 0) out.push({ key: `${entry.id}|none`, entry, name: null, attempt: '', state: 'not-indexed', reason: 'The recording could not be matched to any text.' });
171
+ }
172
+ return out;
173
+ }, [playable, reads]);
174
+ const videos = useMemo(() => [...reads.values()].flatMap((item) => (item.state === 'read' ? item.videos : [])), [reads]);
175
+ const counts = useMemo(() => coverageCounts(videos), [videos]);
176
+ const waiting = !!query && read < readable;
177
+ const noRecording = entries.length - playable.length;
178
+ const results = matches?.slice(0, shown) ?? [];
179
+ const resultEntries = new Set((matches ?? []).map((hit) => byId.get(hit.doc.id)?.entry.id));
180
+ const set = (value: string) => {
181
+ setDraft(value);
182
+ setShown(LISTED);
183
+ go({ inside: value.trim() || undefined }, { replace: true });
184
+ };
185
+ return (
186
+ <Stack component="section" aria-label="Search inside recordings" gap="xs" my="md" data-recording-text>
187
+ <Title order={2}>Search inside recordings</Title>
188
+ <Text c="dimmed">
189
+ Find words that were spoken or shown on screen, from the transcripts, captions and screen text that producers supplied with the recordings listed here. A match finds a moment; it does not show that the behavior
190
+ passed.
191
+ </Text>
192
+ <Group gap="xs" align="center" wrap="wrap">
193
+ <TextInput type="search" aria-label="Words spoken or shown" placeholder="delivery address" value={draft} miw={{ base: '100%', sm: '20rem' }} onChange={(event) => set(event.target.value)} />
194
+ <TypedFilters parsed={found.parsed} query={draft} onChange={set} />
195
+ </Group>
196
+ <SearchNotes issues={found.parsed.issues} semantic={found.status} />
197
+ {!query && (
198
+ <Text component="div" c="dimmed">
199
+ Type words to search. Nothing is read from the recordings until you do, and only the text supplied with them is read, never the video.
200
+ </Text>
201
+ )}
202
+ {query && (
203
+ <Stack gap="xs">
204
+ <Text role="status" component="div" data-reading>
205
+ {waiting
206
+ ? `Reading the text of recordings: ${read} of ${readable}.`
207
+ : `Searched the text of ${plural(counts.searched, 'recording', 'recordings')}.`}
208
+ {total > entries.length ? ` Only the newest ${entries.length} of ${total.toLocaleString()} results are listed, so only they are searched.` : ''}
209
+ {noRecording > 0 ? ` ${plural(noRecording, 'result has', 'results have')} no playable recording and ${noRecording === 1 ? 'is' : 'are'} not searched.` : ''}
210
+ </Text>
211
+ <Group gap="xs" aria-label="Coverage" role="group" data-coverage>
212
+ {(['searched', 'not-indexed', 'unsupported', 'failed'] as const).map((state) => (
213
+ <Badge key={state} color={COVERAGE_COLOR[state]} variant="light" data-coverage-state={state}>
214
+ {COVERAGE_WORD[state]}: {counts[state] + (state === 'failed' ? unsearched.filter((item) => item.name === null && item.state === 'failed').length : 0)}
215
+ </Badge>
216
+ ))}
217
+ </Group>
218
+ </Stack>
219
+ )}
220
+ {query && matches === null && (
221
+ <Text component="div" c="dimmed">
222
+ Add words to search for. Filters alone do not match any text.
223
+ </Text>
224
+ )}
225
+ {query && matches && matches.length === 0 && (
226
+ <Alert role="status" color={waiting ? 'blue' : 'gray'} data-no-match={waiting ? undefined : true}>
227
+ {waiting
228
+ ? 'No match yet. Some recordings are still being read, so this is not the final answer.'
229
+ : counts.searched === 0
230
+ ? 'Nothing was searched: no listed recording has text that can be searched. Recordings without it are listed below.'
231
+ : `No indexed text matches. ${plural(counts.searched, 'recording was', 'recordings were')} searched.${unsearched.length > 0 ? ` ${plural(unsearched.length, 'recording was', 'recordings were')} not searched (listed below), so this says nothing about ${unsearched.length === 1 ? 'it' : 'them'}.` : ''}`}
232
+ </Alert>
233
+ )}
234
+ {matches && matches.length > 0 && (
235
+ <Stack gap="xs">
236
+ <Text role="status" component="div" data-match-count>
237
+ {plural(matches.length, 'match', 'matches')} in {plural(resultEntries.size, 'result', 'results')}. The outcome is the result&apos;s own; a match does not change it.
238
+ </Text>
239
+ <Table.ScrollContainer minWidth={760}>
240
+ <Table data-recording-matches>
241
+ <Table.Thead>
242
+ <Table.Tr>
243
+ <Table.Th scope="col">Indexed text</Table.Th>
244
+ <Table.Th scope="col">Source</Table.Th>
245
+ <Table.Th scope="col">Time</Table.Th>
246
+ <Table.Th scope="col">Result</Table.Th>
247
+ <Table.Th scope="col">Outcome</Table.Th>
248
+ <Table.Th scope="col">Open</Table.Th>
249
+ </Table.Tr>
250
+ </Table.Thead>
251
+ <Table.Tbody>
252
+ {results.map((hit) => {
253
+ const item = byId.get(hit.doc.id);
254
+ if (!item) return null;
255
+ return (
256
+ <Table.Tr key={hit.doc.id} data-match={hit.doc.id} data-source-kind={item.kind} data-status={item.entry.status}>
257
+ <Table.Td maw="28rem">
258
+ <Highlight highlight={[...found.parsed.words, ...found.parsed.phrases]} component="div">
259
+ {item.segment.text}
260
+ </Highlight>
261
+ <Text c="dimmed" size="xs" data-why>
262
+ {why(hit)}
263
+ </Text>
264
+ </Table.Td>
265
+ <Table.Td>
266
+ <Text component="div">{TEXT_KIND_WORD[item.kind]}</Text>
267
+ {item.generated && (
268
+ <Text c="orange.8" size="xs" component="div" data-generated>
269
+ {uncertaintyWord(item)}
270
+ </Text>
271
+ )}
272
+ {!item.generated && item.segment.confidence !== undefined && (
273
+ <Text c="dimmed" size="xs" component="div">
274
+ Confidence {Math.round(item.segment.confidence * 100)}%
275
+ </Text>
276
+ )}
277
+ </Table.Td>
278
+ <Table.Td data-time>{rangeWord(item)}</Table.Td>
279
+ <Table.Td>
280
+ <Anchor
281
+ aria-label={`Open ${item.entry.title} as recorded`}
282
+ renderRoot={(props) => (
283
+ <Link {...props} to="/" search={patched({ tab: undefined, openRun: undefined, test: item.entry.key, layer: item.entry.layer })} resetScroll={false} activeOptions={{ exact: true }} />
284
+ )}
285
+ >
286
+ {item.entry.title}
287
+ </Anchor>
288
+ <Text c="dimmed" size="xs" component="div">
289
+ {[item.entry.producer, item.videoName, item.attemptLabel].filter(Boolean).join(' · ')}
290
+ </Text>
291
+ </Table.Td>
292
+ <Table.Td>
293
+ <StatusMark status={item.entry.status} size={14} /> {statusWord(item.entry.status)}
294
+ </Table.Td>
295
+ <Table.Td>
296
+ <Button
297
+ type="button"
298
+ size="compact-sm"
299
+ variant="light"
300
+ leftSection={<IconPlayerPlay size={16} aria-hidden="true" />}
301
+ aria-label={`Open the recording of ${item.entry.title}${item.attemptLabel ? `, ${item.attemptLabel}` : ''} at ${rangeWord(item)}`}
302
+ onClick={() => setOpened(focusOf(item))}
303
+ >
304
+ {item.segment.startMs === null ? 'Open recording' : 'Open here'}
305
+ </Button>
306
+ </Table.Td>
307
+ </Table.Tr>
308
+ );
309
+ })}
310
+ </Table.Tbody>
311
+ </Table>
312
+ </Table.ScrollContainer>
313
+ {matches.length > results.length && (
314
+ <Button type="button" variant="subtle" onClick={() => setShown(shown + LISTED)}>
315
+ Show more ({matches.length - results.length} more)
316
+ </Button>
317
+ )}
318
+ </Stack>
319
+ )}
320
+ {query && unsearched.length > 0 && (
321
+ <Accordion variant="separated">
322
+ <Accordion.Item value="unsearched">
323
+ <Accordion.Control>Recordings that were not searched ({unsearched.length})</Accordion.Control>
324
+ <Accordion.Panel>
325
+ <Text c="dimmed" mb="xs">
326
+ These are not &quot;no match&quot;: their words were never searched.
327
+ </Text>
328
+ <List spacing="xs" listStyleType="none" aria-label="Recordings that were not searched">
329
+ {unsearched.map((item) => (
330
+ <List.Item key={item.key} data-unsearched={item.state}>
331
+ <Group gap="xs" wrap="wrap">
332
+ <Badge color={COVERAGE_COLOR[item.state]} variant="light">
333
+ {COVERAGE_WORD[item.state]}
334
+ </Badge>
335
+ <Text span inherit fw={600}>
336
+ {item.entry.title}
337
+ </Text>
338
+ <Text span inherit c="dimmed">
339
+ {[item.name, item.attempt].filter(Boolean).join(' · ')}
340
+ </Text>
341
+ </Group>
342
+ <Text c="dimmed" size="sm">
343
+ {item.reason}
344
+ </Text>
345
+ </List.Item>
346
+ ))}
347
+ </List>
348
+ </Accordion.Panel>
349
+ </Accordion.Item>
350
+ </Accordion>
351
+ )}
352
+ {opened && <WatchPlaylist entries={entries} total={total} focus={opened} onClose={() => setOpened(null)} />}
353
+ </Stack>
354
+ );
355
+ }
@@ -1,4 +1,4 @@
1
- import { IconAlertTriangle, IconArrowLeft, IconVideo } from '@tabler/icons-react';
1
+ import { IconAlertTriangle, IconArrowLeft, IconPlayerPlay, IconVideo } from '@tabler/icons-react';
2
2
  import { Alert, Anchor, Badge, Box, Button, Code, Flex, Group, List, NativeSelect, Stack, Table, Text, TextInput, Title, VisuallyHidden } from '@mantine/core';
3
3
  /**
4
4
  * @module Runs view
@@ -25,6 +25,8 @@ import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidenc
25
25
  import { Meter, StatusMark } from '../evidence/marks';
26
26
  import { useRunDetail, useRunList, useTestCatalog } from './catalog';
27
27
  import { RunArtifacts } from './RunArtifacts';
28
+ import { entriesFromRun } from './playlist-entries';
29
+ import { WatchPlaylist } from './playlist';
28
30
  import { FilterBar } from './filters';
29
31
  import { SearchNotes, TypedFilters } from './search-notes';
30
32
  import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
@@ -469,6 +471,8 @@ function RunDetailView({ runKey }: { runKey: string }) {
469
471
  ),
470
472
  [narrowedResults, status, statusFilters, model]
471
473
  );
474
+ const [watching, setWatching] = useState(false);
475
+ const playlist = useMemo(() => entriesFromRun(filtered, detail.data?.layers), [filtered, detail.data]);
472
476
  const bySpec = useMemo(() => {
473
477
  const groups = new Map<string, RunResult[]>();
474
478
  for (const result of filtered.slice(0, shown)) {
@@ -636,6 +640,12 @@ function RunDetailView({ runKey }: { runKey: string }) {
636
640
  No result matches.
637
641
  </Text>
638
642
  )}
643
+ {filtered.length > 0 && (
644
+ <Button type="button" variant="filled" my="sm" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() => setWatching(true)}>
645
+ Watch these {plural(filtered.length, 'result', 'results')}
646
+ </Button>
647
+ )}
648
+ {watching && <WatchPlaylist entries={playlist} onClose={() => setWatching(false)} />}
639
649
  {bySpec.map(([spec, list]) => (
640
650
  <Box key={spec} data-open aria-label={spec || 'No spec'} component="section" mb="lg">
641
651
  <Button data-static variant="default" fullWidth h="auto" py="sm" justify="flex-start">