diff --git a/solstone/apps/speakers/copy.py b/solstone/apps/speakers/copy.py index 3a9bc9870..5c3084eac 100644 --- a/solstone/apps/speakers/copy.py +++ b/solstone/apps/speakers/copy.py @@ -137,6 +137,8 @@ SPK_NOT_PERSON_DONE = "got it, not a person. sol will leave this one alone and w SPK_NOT_NOW_DONE = "no rush. it waits quietly in new voices." SPK_ACTION_WHO_IS_THIS = "who is this?" SPK_LOAD_ERROR = "sol couldn't load this voice. try again." +SPK_DISCOVERY_ERROR = "sol couldn't look for new voices right now." +SPK_DISCOVERY_DEGRADED_TEMPLATE = "some voice samples could not be used ({count})" SPK_SEARCH_ERROR = "sol couldn't search your people. try again." SPK_CHECK_NAME_ERROR = "sol couldn't check this name. try again." SPK_SAMPLE_UNAVAILABLE = "this sample isn't available" diff --git a/solstone/apps/speakers/tests/test_who_is_this_js.py b/solstone/apps/speakers/tests/test_who_is_this_js.py index 33ae8b425..9825665c9 100644 --- a/solstone/apps/speakers/tests/test_who_is_this_js.py +++ b/solstone/apps/speakers/tests/test_who_is_this_js.py @@ -382,6 +382,18 @@ function response(payload) { }; } +function apiError(payload, status = 503) { + const error = new Error(payload?.error || payload?.message || `Request failed (HTTP ${status})`); + error.name = 'ApiError'; + error.status = status; + error.statusText = ''; + error.serverMessage = payload?.error || payload?.message || error.message; + error.reasonCode = payload?.reason_code || null; + error.rawDetail = payload?.detail ?? null; + error.payload = payload; + return error; +} + function escapeHtml(value) { return String(value ?? '') .replaceAll('&', '&') @@ -683,6 +695,10 @@ function resolveApi(item, payload) { item.resolve(payload); } +function rejectApi(item, error) { + item.reject(error); +} + function queueApiResponse(queues, name, url) { const item = deferred(); item.url = url; @@ -714,6 +730,9 @@ function assertOwnerBannerHidden(document) { function speakerCopy() { return { SPK_ACTION_WHO_IS_THIS: 'who', + SPK_ACTION_RETRY: 'try again', + SPK_DISCOVERY_ERROR: 'discovery error', + SPK_DISCOVERY_DEGRADED_TEMPLATE: 'degraded {count}', SPK_GRID_BODY: 'grid', SPK_OVERVIEW_TODAY_LINK_LABEL: 'today', SPK_OVERVIEW_KNOWN_VOICES_SORTS: ['recent'], @@ -785,6 +804,85 @@ function reviewPayload(name = '') { }; } +function discoveryCluster(clusterId, name = `Cluster ${clusterId}`) { + return { + cluster_id: clusterId, + suggested_name: name, + size: 1, + segment_count: 1, + samples: [{ text: name }], + }; +} + +function discoveryOk(clusters) { + return { status: 'ok', clusters, issues: [] }; +} + +function discoveryDegraded(clusters, count = 2) { + return { + status: 'degraded', + clusters, + issues: [{ + reason_code: 'speaker_discovery_invalid_embeddings', + message: 'backend degraded message', + count, + }], + }; +} + +function discoveryFailure(message = 'scan failed', retryable = true) { + return apiError({ + error: message, + reason_code: 'speaker_discovery_failed', + detail: '', + retryable, + }, retryable ? 503 : 500); +} + +function discoverySurface(document, kind) { + return kind === 'overview' + ? document.getElementById('spkNewVoicesSection') + : document.getElementById('spkDiscoveryBanner'); +} + +function discoveryClusterHost(document, kind) { + return kind === 'overview' + ? document.getElementById('spkDiscoveryClusters') + : document.getElementById('spkDiscoveryBanner'); +} + +function discoveryNoticeHost(document, kind) { + return kind === 'overview' + ? document.getElementById('spkOverviewDiscoveryNotice') + : document.getElementById('spkDiscoveryBanner'); +} + +function discoveryRetryButton(document, kind) { + const host = discoveryNoticeHost(document, kind); + return host.querySelector(`#${kind === 'overview' ? 'spkOverviewDiscoveryRetry' : 'spkDayDiscoveryRetry'}`); +} + +function assertDiscoveryCluster(document, kind, clusterId) { + assert(discoveryClusterHost(document, kind).querySelector(`[data-cluster-id="${clusterId}"]`)); +} + +function assertNoDiscoveryCluster(document, kind, clusterId) { + assert.strictEqual(discoveryClusterHost(document, kind).querySelector(`[data-cluster-id="${clusterId}"]`), null); +} + +function assertDiscoveryNotice(document, kind, expectedText, expectsRetry = false) { + const host = discoveryNoticeHost(document, kind); + if (kind === 'overview') assert.strictEqual(host.hidden, false); + assert(host.textContent.includes(expectedText)); + if (expectsRetry) assert(discoveryRetryButton(document, kind)); +} + +function assertNoDiscoveryNotice(document, kind, textValue) { + const host = discoveryNoticeHost(document, kind); + assert(!host.textContent.includes(textValue)); + assert.strictEqual(discoveryRetryButton(document, kind), null); +} + function matchedSpeakers(name = '') { return { matched: name ? [{ entity_name: name, detected_name: name }] : [], @@ -824,6 +922,7 @@ function makeWorkspaceContext(kind, options = {}) { 'spkKnownSort', 'spkNewVoicesSection', 'spkStatementHandoffNotice', + 'spkOverviewDiscoveryNotice', 'spkDiscoveryClusters', 'spkTodayReview', 'spkDayGridCard', @@ -930,14 +1029,16 @@ function makeWorkspaceContext(kind, options = {}) { if (url.includes(`/api/speakers/${day}/`)) return queueResponse(queues, 'speakers', url); throw new Error(`unexpected fetch ${url}`); }; - window.apiJson = (url) => { - apiCalls.push({ url }); + window.apiJson = (url, options = {}) => { + apiCalls.push({ url, options }); if (url.includes('/api/owner/status')) { if (config.deferOwnerStatus) return queueApiResponse(queues, 'ownerStatus', url); if (config.ownerStatusError) return Promise.reject(config.ownerStatusError); return Promise.resolve(ownerStatus); } if (url.includes('/api/owner/detect')) return Promise.resolve({}); + if (url.includes('/api/discovery/cache')) return queueResponse(queues, 'discovery', url).then((r) => r.json()); + if (url.includes('/api/discovery/scan')) return queueResponse(queues, 'scan', url).then((r) => r.json()); if (url.includes('/api/review/')) return queueResponse(queues, 'review', url).then((r) => r.json()); if (url.includes('/api/segments/')) return queueResponse(queues, 'segments', url).then((r) => r.json()); throw new Error(`unexpected api ${url}`); @@ -1598,6 +1699,363 @@ def test_workspace_day_full_undo_refreshes_discovery_segments_and_active_review( ) +def test_workspace_day_discovery_failure_retry_preserves_cached_clusters() -> None: + _run_workspace_node( + """ + (async () => { + const harness = makeWorkspaceContext('day'); + const { document, queues, sheets } = harness; + await flush(); + await flush(); + + resolveFetch(queues.discovery[0], discoveryOk([discoveryCluster(7, 'Cluster A')])); + await flush(); + await flush(); + assertDiscoveryCluster(document, 'day', '7'); + assert.strictEqual(queues.scan.length, 1); + + rejectApi(queues.scan[0], discoveryFailure('scan failed', true)); + await flush(); + await flush(); + assertDiscoveryCluster(document, 'day', '7'); + assertDiscoveryNotice(document, 'day', 'discovery error', true); + + document.getElementById('spkDiscoveryBanner').querySelector('.spk-who-trigger').click(); + sheets[0].onFullyRestoredUndo().catch(() => false); + await flush(); + await flush(); + assert.strictEqual(queues.discovery.length, 2); + resolveFetch(queues.discovery[1], discoveryOk([discoveryCluster(7, 'Cluster A')])); + await flush(); + await flush(); + assertDiscoveryCluster(document, 'day', '7'); + assertDiscoveryNotice(document, 'day', 'discovery error', true); + + discoveryRetryButton(document, 'day').click(); + await flush(); + await flush(); + resolveFetch(queues.scan[queues.scan.length - 1], discoveryOk([discoveryCluster(8, 'Cluster B')])); + await flush(); + await flush(); + + assertNoDiscoveryCluster(document, 'day', '7'); + assertDiscoveryCluster(document, 'day', '8'); + assertNoDiscoveryNotice(document, 'day', 'discovery error'); + })().catch((error) => { console.error(error); process.exit(1); }); + """ + ) + + +def test_workspace_overview_discovery_failure_retry_preserves_cached_clusters() -> None: + _run_workspace_node( + """ + (async () => { + const harness = makeWorkspaceContext('overview'); + const { document, queues, sheets } = harness; + await flush(); + await flush(); + + resolveFetch(queues.quality[0], { + owner_voice: { bootstrap_state: 'ready' }, + tier_histogram: {}, + corrections_window_count: 0, + }); + resolveFetch(queues.known[0], { speakers: [] }); + resolveFetch(queues.discovery[0], discoveryOk([discoveryCluster(7, 'Cluster A')])); + await flush(); + await flush(); + assertDiscoveryCluster(document, 'overview', '7'); + assert.strictEqual(queues.scan.length, 1); + + rejectApi(queues.scan[0], discoveryFailure('scan failed', true)); + await flush(); + await flush(); + assertDiscoveryCluster(document, 'overview', '7'); + assertDiscoveryNotice(document, 'overview', 'discovery error', true); + + document.getElementById('spkDiscoveryClusters').querySelector('.spk-who-trigger').click(); + sheets[0].onFullyRestoredUndo().catch(() => false); + await flush(); + await flush(); + assert.strictEqual(queues.discovery.length, 2); + resolveFetch(queues.discovery[1], discoveryOk([discoveryCluster(7, 'Cluster A')])); + await flush(); + await flush(); + assertDiscoveryCluster(document, 'overview', '7'); + assertDiscoveryNotice(document, 'overview', 'discovery error', true); + + discoveryRetryButton(document, 'overview').click(); + await flush(); + await flush(); + resolveFetch(queues.scan[queues.scan.length - 1], discoveryOk([discoveryCluster(8, 'Cluster B')])); + await flush(); + await flush(); + + assertNoDiscoveryCluster(document, 'overview', '7'); + assertDiscoveryCluster(document, 'overview', '8'); + assertNoDiscoveryNotice(document, 'overview', 'discovery error'); + })().catch((error) => { console.error(error); process.exit(1); }); + """ + ) + + +def test_workspace_day_discovery_stale_failure_does_not_hide_newer_success() -> None: + _run_workspace_node( + """ + (async () => { + const harness = makeWorkspaceContext('day'); + const { document, queues, sheets } = harness; + await flush(); + await flush(); + + resolveFetch(queues.discovery[0], discoveryOk([discoveryCluster(7, 'Cluster A')])); + await flush(); + await flush(); + const staleScan = queues.scan[0]; + document.getElementById('spkDiscoveryBanner').querySelector('.spk-who-trigger').click(); + sheets[0].onFullyRestoredUndo().catch(() => false); + await flush(); + await flush(); + + resolveFetch(queues.discovery[1], discoveryOk([discoveryCluster(8, 'Cluster B')])); + await flush(); + await flush(); + resolveFetch(queues.scan[1], discoveryOk([discoveryCluster(8, 'Cluster B')])); + await flush(); + await flush(); + assertDiscoveryCluster(document, 'day', '8'); + assertNoDiscoveryNotice(document, 'day', 'discovery error'); + + rejectApi(staleScan, discoveryFailure('scan failed', true)); + await flush(); + await flush(); + assertDiscoveryCluster(document, 'day', '8'); + assertNoDiscoveryNotice(document, 'day', 'discovery error'); + })().catch((error) => { console.error(error); process.exit(1); }); + """ + ) + + +def test_workspace_overview_discovery_stale_failure_does_not_hide_newer_success() -> None: + _run_workspace_node( + """ + (async () => { + const harness = makeWorkspaceContext('overview'); + const { document, queues, sheets } = harness; + await flush(); + await flush(); + + resolveFetch(queues.quality[0], { + owner_voice: { bootstrap_state: 'ready' }, + tier_histogram: {}, + corrections_window_count: 0, + }); + resolveFetch(queues.known[0], { speakers: [] }); + resolveFetch(queues.discovery[0], discoveryOk([discoveryCluster(7, 'Cluster A')])); + await flush(); + await flush(); + const staleScan = queues.scan[0]; + document.getElementById('spkDiscoveryClusters').querySelector('.spk-who-trigger').click(); + sheets[0].onFullyRestoredUndo().catch(() => false); + await flush(); + await flush(); + + rejectApi(queues.discovery[1], discoveryFailure('cache failed', true)); + await flush(); + await flush(); + assertDiscoveryNotice(document, 'overview', 'discovery error', true); + discoveryRetryButton(document, 'overview').click(); + await flush(); + await flush(); + resolveFetch(queues.scan[1], discoveryOk([discoveryCluster(8, 'Cluster B')])); + await flush(); + await flush(); + assertDiscoveryCluster(document, 'overview', '8'); + assertNoDiscoveryNotice(document, 'overview', 'discovery error'); + + rejectApi(staleScan, discoveryFailure('scan failed', true)); + await flush(); + await flush(); + assertDiscoveryCluster(document, 'overview', '8'); + assertNoDiscoveryNotice(document, 'overview', 'discovery error'); + })().catch((error) => { console.error(error); process.exit(1); }); + """ + ) + + +def test_workspace_day_discovery_stale_success_does_not_erase_newer_error() -> None: + _run_workspace_node( + """ + (async () => { + const harness = makeWorkspaceContext('day'); + const { document, queues, sheets } = harness; + await flush(); + await flush(); + + resolveFetch(queues.discovery[0], discoveryOk([discoveryCluster(7, 'Cluster A')])); + await flush(); + await flush(); + const staleScan = queues.scan[0]; + document.getElementById('spkDiscoveryBanner').querySelector('.spk-who-trigger').click(); + sheets[0].onFullyRestoredUndo().catch(() => false); + await flush(); + await flush(); + + resolveFetch(queues.discovery[1], discoveryOk([discoveryCluster(7, 'Cluster A')])); + await flush(); + await flush(); + rejectApi(queues.scan[1], discoveryFailure('scan failed', true)); + await flush(); + await flush(); + assertDiscoveryCluster(document, 'day', '7'); + assertDiscoveryNotice(document, 'day', 'discovery error', true); + + resolveFetch(staleScan, discoveryOk([discoveryCluster(8, 'Cluster B')])); + await flush(); + await flush(); + assertDiscoveryCluster(document, 'day', '7'); + assertNoDiscoveryCluster(document, 'day', '8'); + assertDiscoveryNotice(document, 'day', 'discovery error', true); + })().catch((error) => { console.error(error); process.exit(1); }); + """ + ) + + +def test_workspace_overview_discovery_stale_success_does_not_erase_newer_error() -> ( + None +): + _run_workspace_node( + """ + (async () => { + const harness = makeWorkspaceContext('overview'); + const { document, queues, sheets } = harness; + await flush(); + await flush(); + + resolveFetch(queues.quality[0], { + owner_voice: { bootstrap_state: 'ready' }, + tier_histogram: {}, + corrections_window_count: 0, + }); + resolveFetch(queues.known[0], { speakers: [] }); + resolveFetch(queues.discovery[0], discoveryOk([discoveryCluster(7, 'Cluster A')])); + await flush(); + await flush(); + const staleScan = queues.scan[0]; + document.getElementById('spkDiscoveryClusters').querySelector('.spk-who-trigger').click(); + sheets[0].onFullyRestoredUndo().catch(() => false); + await flush(); + await flush(); + + rejectApi(queues.discovery[1], discoveryFailure('cache failed', true)); + await flush(); + await flush(); + assertDiscoveryCluster(document, 'overview', '7'); + assertDiscoveryNotice(document, 'overview', 'discovery error', true); + discoveryRetryButton(document, 'overview').click(); + await flush(); + await flush(); + rejectApi(queues.scan[1], discoveryFailure('scan failed', true)); + await flush(); + await flush(); + assertDiscoveryCluster(document, 'overview', '7'); + assertDiscoveryNotice(document, 'overview', 'discovery error', true); + + resolveFetch(staleScan, discoveryOk([discoveryCluster(8, 'Cluster B')])); + await flush(); + await flush(); + assertDiscoveryCluster(document, 'overview', '7'); + assertNoDiscoveryCluster(document, 'overview', '8'); + assertDiscoveryNotice(document, 'overview', 'discovery error', true); + })().catch((error) => { console.error(error); process.exit(1); }); + """ + ) + + +def test_workspace_day_discovery_degraded_notice_keeps_clusters_and_empty_scan() -> ( + None +): + _run_workspace_node( + """ + (async () => { + const withCluster = makeWorkspaceContext('day'); + await flush(); + await flush(); + resolveFetch(withCluster.queues.discovery[0], discoveryOk([])); + await flush(); + await flush(); + resolveFetch(withCluster.queues.scan[0], discoveryDegraded([discoveryCluster(7, 'Cluster A')], 2)); + await flush(); + await flush(); + assertDiscoveryCluster(withCluster.document, 'day', '7'); + assertDiscoveryNotice(withCluster.document, 'day', 'degraded 2', false); + assert.strictEqual(discoveryRetryButton(withCluster.document, 'day'), null); + + const empty = makeWorkspaceContext('day'); + await flush(); + await flush(); + resolveFetch(empty.queues.discovery[0], discoveryOk([])); + await flush(); + await flush(); + resolveFetch(empty.queues.scan[0], discoveryDegraded([], 3)); + await flush(); + await flush(); + assertDiscoveryNotice(empty.document, 'day', 'degraded 3', false); + assert.strictEqual(empty.document.getElementById('spkDiscoveryBanner').style.display, 'block'); + assertNoDiscoveryCluster(empty.document, 'day', '7'); + })().catch((error) => { console.error(error); process.exit(1); }); + """ + ) + + +def test_workspace_overview_discovery_degraded_notice_keeps_clusters_and_empty_scan() -> ( + None +): + _run_workspace_node( + """ + (async () => { + const withCluster = makeWorkspaceContext('overview'); + await flush(); + await flush(); + resolveFetch(withCluster.queues.quality[0], { + owner_voice: { bootstrap_state: 'ready' }, + tier_histogram: {}, + corrections_window_count: 0, + }); + resolveFetch(withCluster.queues.known[0], { speakers: [] }); + resolveFetch(withCluster.queues.discovery[0], discoveryOk([])); + await flush(); + await flush(); + resolveFetch(withCluster.queues.scan[0], discoveryDegraded([discoveryCluster(7, 'Cluster A')], 2)); + await flush(); + await flush(); + assertDiscoveryCluster(withCluster.document, 'overview', '7'); + assertDiscoveryNotice(withCluster.document, 'overview', 'degraded 2', false); + assert.strictEqual(discoveryRetryButton(withCluster.document, 'overview'), null); + + const empty = makeWorkspaceContext('overview'); + await flush(); + await flush(); + resolveFetch(empty.queues.quality[0], { + owner_voice: { bootstrap_state: 'ready' }, + tier_histogram: {}, + corrections_window_count: 0, + }); + resolveFetch(empty.queues.known[0], { speakers: [] }); + resolveFetch(empty.queues.discovery[0], discoveryOk([])); + await flush(); + await flush(); + resolveFetch(empty.queues.scan[0], discoveryDegraded([], 3)); + await flush(); + await flush(); + assertDiscoveryNotice(empty.document, 'overview', 'degraded 3', false); + assert.strictEqual(empty.document.getElementById('spkNewVoicesSection').hidden, false); + assertNoDiscoveryCluster(empty.document, 'overview', '7'); + })().catch((error) => { console.error(error); process.exit(1); }); + """ + ) + + def test_workspace_overview_this_is_me_focuses_your_voice_section() -> None: _run_workspace_node( """ diff --git a/solstone/apps/speakers/workspace.html b/solstone/apps/speakers/workspace.html index 2b7cc1d75..9d789f07b 100644 --- a/solstone/apps/speakers/workspace.html +++ b/solstone/apps/speakers/workspace.html @@ -451,6 +451,46 @@ color: #991b1b; } +.spk-discovery-notice { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.spk-discovery-notice-text { + display: grid; + gap: 4px; +} + +.spk-discovery-notice-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.spk-discovery-retry { + border: 1px solid currentColor; + border-radius: 4px; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; + padding: 3px 8px; +} + +.spk-discovery-retry:hover, +.spk-discovery-retry:focus { + background: rgba(255, 255, 255, 0.45); +} + +.spk-discovery-retry:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} + .spk-status-dismiss { background: none; border: none; @@ -1394,6 +1434,8 @@ let ownerTeachHandoffGeneration = 0; let ownerStatusRequestSeq = 0; let dayDiscoveryRequestSeq = 0; + let dayDiscoveryClusters = []; + let dayDiscoveryNotice = null; let segmentsRequestSeq = 0; let segmentSpeakersRequestSeq = 0; let reviewRequestSeq = 0; @@ -1535,11 +1577,8 @@ } function removeDiscoveryCluster(clusterId) { - const card = discoveryBanner.querySelector(`[data-cluster-id="${clusterId}"]`); - if (card) card.remove(); - if (!discoveryBanner.querySelector('.spk-discovery-cluster')) { - hideDiscovery(); - } + dayDiscoveryClusters = dayDiscoveryClusters.filter((cluster) => String(cluster.cluster_id) !== String(clusterId)); + renderDayDiscoveryBanner(); } async function refreshWhoIsThisDayContext() { @@ -2323,8 +2362,7 @@ dayDiscoveryRequestSeq = requestSeq; discoveryBanner.style.display = 'block'; discoveryBanner.innerHTML = '
Discovering new voices…
'; - return fetch('/app/speakers/api/discovery/cache', { cache: 'no-store' }) - .then(r => r.json()) + return window.apiJson('/app/speakers/api/discovery/cache', { cache: 'no-store' }) .then(data => { if (requestSeq !== dayDiscoveryRequestSeq) return false; renderDiscoveryPayload(data); @@ -2332,65 +2370,126 @@ }) .catch((err) => { if (requestSeq !== dayDiscoveryRequestSeq) return false; - hideDiscovery(); + dayDiscoveryNotice = dayDiscoveryErrorNotice(); + renderDayDiscoveryBanner(); if (throwOnError) throw err; return false; }); } function renderDiscoveryPayload(data) { - if (data.clusters && data.clusters.length > 0) { - renderDiscovery(data.clusters); - } else { - hideDiscovery(); - } + dayDiscoveryClusters = discoveryClusters(data); + renderDayDiscoveryBanner(); } - function refreshDayDiscoveryScan({ requestSeq, throwOnError }) { - return fetch('/app/speakers/api/discovery/scan', { method: 'POST' }) - .then(r => { - if (!r.ok) throw new Error('scan failed'); - return r.json(); - }) + function refreshDayDiscoveryScan({ requestSeq, throwOnError } = {}) { + const activeSeq = requestSeq || dayDiscoveryRequestSeq + 1; + dayDiscoveryRequestSeq = activeSeq; + return window.apiJson('/app/speakers/api/discovery/scan', { method: 'POST' }) .then(data => { - if (requestSeq !== dayDiscoveryRequestSeq) return false; - renderDiscoveryPayload(data); + if (activeSeq !== dayDiscoveryRequestSeq) return false; + dayDiscoveryClusters = discoveryClusters(data); + dayDiscoveryNotice = discoveryNoticeFromScan(data, SPK_COPY); + renderDayDiscoveryBanner(); return true; }) .catch((err) => { - if (requestSeq !== dayDiscoveryRequestSeq) return false; - hideDiscovery(); + if (activeSeq !== dayDiscoveryRequestSeq) return false; + dayDiscoveryNotice = dayDiscoveryErrorNotice(); + renderDayDiscoveryBanner(); if (throwOnError) throw err; return false; }); } - function renderDiscovery(clusters) { + function discoveryClusters(data) { + return Array.isArray(data?.clusters) ? data.clusters : []; + } + + function discoveryIssueCount(issue) { + const count = Number(issue?.count || 0); + return Number.isFinite(count) ? count : 0; + } + + function discoveryIssueMessage(issue, copy) { + const count = discoveryIssueCount(issue); + if (count > 0 && copy.SPK_DISCOVERY_DEGRADED_TEMPLATE) { + return copy.SPK_DISCOVERY_DEGRADED_TEMPLATE.replace('{count}', String(count)); + } + return issue?.message || (copy.SPK_DISCOVERY_DEGRADED_TEMPLATE || '').replace('{count}', String(count)); + } + + function discoveryNoticeFromScan(data, copy) { + const issues = Array.isArray(data?.issues) ? data.issues : []; + if (data?.status !== 'degraded' || !issues.length) return null; + return { + type: 'degraded', + messages: issues.map((issue) => discoveryIssueMessage(issue, copy)).filter(Boolean), + retry: false, + }; + } + + function dayDiscoveryErrorNotice() { + return { + type: 'error', + messages: [SPK_COPY.SPK_DISCOVERY_ERROR || ''], + retry: true, + }; + } + + function renderDayDiscoveryNotice(notice) { + if (!notice) return ''; + const statusClass = notice.type === 'error' ? 'spk-status-error' : 'spk-status-info'; + const retry = notice.retry + ? `` + : ''; + return ` + + `; + } + + function renderDayDiscoveryBanner() { + const clusters = dayDiscoveryClusters; + if (!clusters.length && !dayDiscoveryNotice) { + hideDiscovery(); + return; + } discoveryBanner.style.display = 'block'; discoveryBanner.innerHTML = `
-
new speakers found
-
- sol found ${clusters.length} recurring voice pattern${clusters.length > 1 ? 's' : ''} that ${clusters.length > 1 ? "don't" : "doesn't"} match anyone you've named. listen to a few samples and name them. -
-
- ${clusters.map(cluster => ` -
-
- Unknown speaker · ${cluster.size} samples · ${cluster.segment_count} segments -
- ${cluster.samples.map(sample => ` -
-
${escapeHtml(sample.text || '(no transcript)')}
- ${sample.audio_url ? `` : ''} + ${renderDayDiscoveryNotice(dayDiscoveryNotice)} + ${clusters.length ? ` +
new speakers found
+
+ sol found ${clusters.length} recurring voice pattern${clusters.length > 1 ? 's' : ''} that ${clusters.length > 1 ? "don't" : "doesn't"} match anyone you've named. listen to a few samples and name them. +
+
+ ${clusters.map(cluster => ` +
+
+ Unknown speaker · ${cluster.size} samples · ${cluster.segment_count} segments
- `).join('')} - -
- `).join('')} -
+ ${(cluster.samples || []).map(sample => ` +
+
${escapeHtml(sample.text || '(no transcript)')}
+ ${sample.audio_url ? `` : ''} +
+ `).join('')} + +
+ `).join('')} +
+ ` : ''}
`; @@ -2402,9 +2501,18 @@ trigger.addEventListener('click', () => openWhoIsThis(cluster, trigger)); } }); + document.getElementById('spkDayDiscoveryRetry')?.addEventListener('click', () => { + refreshDayDiscoveryScan({ requestSeq: dayDiscoveryRequestSeq + 1, throwOnError: false }); + }); + document.getElementById('spkDayDiscoveryDismiss')?.addEventListener('click', () => { + dayDiscoveryNotice = null; + renderDayDiscoveryBanner(); + }); } function hideDiscovery() { + dayDiscoveryClusters = []; + dayDiscoveryNotice = null; discoveryBanner.style.display = 'none'; discoveryBanner.innerHTML = ''; } @@ -3443,6 +3551,14 @@ font-size: 14px; } +.spk-overview-discovery-notice { + margin: 0 0 16px; +} + +.spk-overview-discovery-notice[hidden] { + display: none; +} + .spk-overview-actions { display: flex; flex-wrap: wrap; @@ -3615,6 +3731,7 @@

+
@@ -3637,6 +3754,7 @@ const sortSelect = document.getElementById('spkKnownSort'); const discoverySection = document.getElementById('spkNewVoicesSection'); const statementHandoffNotice = document.getElementById('spkStatementHandoffNotice'); + const overviewDiscoveryNoticeNode = document.getElementById('spkOverviewDiscoveryNotice'); const discoveryContainer = document.getElementById('spkDiscoveryClusters'); const todayLink = document.getElementById('spkTodayReview'); const dayGridCard = document.getElementById('spkDayGridCard'); @@ -3650,6 +3768,7 @@ let qualityRequestSeq = 0; let knownRequestSeq = 0; let overviewDiscoveryRequestSeq = 0; + let overviewDiscoveryNotice = null; let overviewHandoffSheetOpen = false; const overviewYourVoiceSection = document.getElementById('spkOverviewYourVoiceSection'); @@ -3967,7 +4086,7 @@ const card = discoveryContainer.querySelector(`[data-cluster-id="${clusterId}"]`); if (card) card.remove(); if (!discoveryContainer.querySelector('.spk-discovery-card')) { - discoverySection.hidden = true; + discoverySection.hidden = !overviewDiscoveryNotice; } } @@ -4038,12 +4157,83 @@ discoverySection.hidden = false; } + function discoveryClusters(data) { + return Array.isArray(data?.clusters) ? data.clusters : []; + } + + function discoveryIssueCount(issue) { + const count = Number(issue?.count || 0); + return Number.isFinite(count) ? count : 0; + } + + function discoveryIssueMessage(issue) { + const count = discoveryIssueCount(issue); + if (count > 0 && COPY.SPK_DISCOVERY_DEGRADED_TEMPLATE) { + return COPY.SPK_DISCOVERY_DEGRADED_TEMPLATE.replace('{count}', String(count)); + } + return issue?.message || (COPY.SPK_DISCOVERY_DEGRADED_TEMPLATE || '').replace('{count}', String(count)); + } + + function discoveryNoticeFromScan(data) { + const issues = Array.isArray(data?.issues) ? data.issues : []; + if (data?.status !== 'degraded' || !issues.length) return null; + return { + type: 'degraded', + messages: issues.map((issue) => discoveryIssueMessage(issue)).filter(Boolean), + retry: false, + }; + } + + function overviewDiscoveryErrorNotice() { + return { + type: 'error', + messages: [COPY.SPK_DISCOVERY_ERROR || ''], + retry: true, + }; + } + + function renderOverviewDiscoveryNotice() { + if (!overviewDiscoveryNotice) { + overviewDiscoveryNoticeNode.hidden = true; + overviewDiscoveryNoticeNode.innerHTML = ''; + return; + } + const statusClass = overviewDiscoveryNotice.type === 'error' ? 'spk-status-error' : 'spk-status-info'; + const retry = overviewDiscoveryNotice.retry + ? `` + : ''; + overviewDiscoveryNoticeNode.hidden = false; + overviewDiscoveryNoticeNode.innerHTML = ` + + `; + document.getElementById('spkOverviewDiscoveryRetry')?.addEventListener('click', () => { + refreshDiscoveryScan({ requestSeq: overviewDiscoveryRequestSeq + 1, throwOnError: false }); + }); + document.getElementById('spkOverviewDiscoveryDismiss')?.addEventListener('click', () => { + overviewDiscoveryNotice = null; + renderOverviewDiscoveryNotice(); + if (!discoveryContainer.querySelector('.spk-discovery-card') && statementHandoffNotice.hidden) { + discoverySection.hidden = true; + } + }); + } + function renderDiscovery(data) { - const clusters = data.clusters || []; + const clusters = discoveryClusters(data); hideStatementHandoffNotice(); + renderOverviewDiscoveryNotice(); discoveryClustersById = new Map(clusters.map((cluster) => [String(cluster.cluster_id), cluster])); if (!clusters.length) { - discoverySection.hidden = true; + discoveryContainer.innerHTML = ''; + discoverySection.hidden = !overviewDiscoveryNotice; return; } discoverySection.hidden = false; @@ -4069,11 +4259,7 @@ const throwOnError = Boolean(options?.throwOnError); const requestSeq = overviewDiscoveryRequestSeq + 1; overviewDiscoveryRequestSeq = requestSeq; - return fetch('/app/speakers/api/discovery/cache', {cache: 'no-store'}) - .then((response) => { - if (!response.ok) throw new Error('discovery cache failed'); - return response.json(); - }) + return window.apiJson('/app/speakers/api/discovery/cache', { cache: 'no-store' }) .then((data) => { if (requestSeq !== overviewDiscoveryRequestSeq) return false; renderDiscovery(data); @@ -4081,7 +4267,9 @@ }) .catch((err) => { if (requestSeq !== overviewDiscoveryRequestSeq) return false; - discoverySection.hidden = true; + overviewDiscoveryNotice = overviewDiscoveryErrorNotice(); + renderOverviewDiscoveryNotice(); + discoverySection.hidden = false; if (throwOnError) throw err; return false; }); @@ -4092,23 +4280,22 @@ return Promise.resolve(false); } const throwOnError = Boolean(options?.throwOnError); - const requestSeq = overviewDiscoveryRequestSeq + 1; + const requestSeq = options?.requestSeq || overviewDiscoveryRequestSeq + 1; overviewDiscoveryRequestSeq = requestSeq; - return fetch('/app/speakers/api/discovery/scan', {method: 'POST'}) - .then((response) => { - if (!response.ok) throw new Error('scan failed'); - return response.json(); - }) + return window.apiJson('/app/speakers/api/discovery/scan', { method: 'POST' }) .then((data) => { if (requestSeq !== overviewDiscoveryRequestSeq) return false; if (overviewHandoffSheetOpen) return false; + overviewDiscoveryNotice = discoveryNoticeFromScan(data); renderDiscovery(data); return true; }) .catch((err) => { if (requestSeq !== overviewDiscoveryRequestSeq) return false; if (overviewHandoffSheetOpen) return false; - discoverySection.hidden = true; + overviewDiscoveryNotice = overviewDiscoveryErrorNotice(); + renderOverviewDiscoveryNotice(); + discoverySection.hidden = false; if (throwOnError) throw err; return false; }); diff --git a/tests/js/speakers_deeplink_harness.js b/tests/js/speakers_deeplink_harness.js index 030528412..fa257ce89 100644 --- a/tests/js/speakers_deeplink_harness.js +++ b/tests/js/speakers_deeplink_harness.js @@ -557,6 +557,18 @@ function responseText(text, status = 200) { }; } +function apiError(payload, status = 503) { + const error = new Error(payload?.error || payload?.message || `Request failed (HTTP ${status})`); + error.name = 'ApiError'; + error.status = status; + error.statusText = ''; + error.serverMessage = payload?.error || payload?.message || error.message; + error.reasonCode = payload?.reason_code || null; + error.rawDetail = payload?.detail ?? null; + error.payload = payload; + return error; +} + function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -607,6 +619,8 @@ function speakerCopy() { 'SPK_CHECK_NAME_ERROR', 'SPK_SAMPLE_UNAVAILABLE', 'SPK_ACTION_RETRY', + 'SPK_DISCOVERY_ERROR', + 'SPK_DISCOVERY_DEGRADED_TEMPLATE', 'SPK_OVERVIEW_CARD_SEGMENTS_LABEL', 'SPK_OVERVIEW_CARD_SAMPLES_LABEL', 'SPK_OVERVIEW_CARD_LAST_HEARD_PREFIX', @@ -866,6 +880,15 @@ async function runRoute(scenario) { }); if (url === '/api/shell') return shell; if (String(url).startsWith('/app/speakers/api/state')) return statePayload(); + if (url === '/app/speakers/api/discovery/cache') { + return scenario.cachePayload || { status: 'ok', clusters: [] }; + } + if (url === '/app/speakers/api/discovery/scan') { + scanCalled = true; + const payload = scenario.scanPayload || { clusters: [clusterPayload(3)] }; + if (payload === null) throw apiError({ error: 'scan failed' }, 500); + return payload; + } if (String(url).startsWith('/app/speakers/api/discovery/resolve-statement')) { if (scenario.resolveStatement === 'dynamic-cache-hit-before-scan') { return scanCalled @@ -967,8 +990,9 @@ async function runRoute(scenario) { const renderedClusterIds = document .querySelectorAll('.spk-discovery-card') .map((node) => String(node.dataset.clusterId || '')); - const discoveryCall = fetchCalls.find((call) => call.url === '/app/speakers/api/discovery/scan'); - const discoveryCacheCall = fetchCalls.find((call) => call.url === '/app/speakers/api/discovery/cache'); + const transportCalls = [...fetchCalls, ...apiCalls]; + const discoveryCall = transportCalls.find((call) => call.url === '/app/speakers/api/discovery/scan'); + const discoveryCacheCall = transportCalls.find((call) => call.url === '/app/speakers/api/discovery/cache'); const presenceClusterIds = apiCalls .map((call) => String(call.url || '').match(/^\/app\/speakers\/api\/discovery\/cluster\/([^/]+)\/presence/)) .filter(Boolean)