From f2f42ed41571440e3ea3ca411e16606c5bade713 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 17:54:07 +0200 Subject: [PATCH] feat(search): orchestrate NL search on the documents page (#739) Lift smartMode to documents/+page.svelte and drive the full smart-search lifecycle: POST /api/search/nl via csrfFetch, loading/error panels, chip row, single-select disambiguation, and a transparent empty state. Chip removal and disambiguation selection map the interpretation to keyword params and re-run via GET (Option A in-page fallback). Mode toggle and new queries reset prior interpretation. Co-Authored-By: Claude Opus 4.8 --- frontend/src/routes/documents/+page.svelte | 331 ++++++++++++++++----- 1 file changed, 259 insertions(+), 72 deletions(-) diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index 5006d9eb..2d287140 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -8,9 +8,20 @@ import DocumentList from '../DocumentList.svelte'; import Pagination from '$lib/shared/primitives/Pagination.svelte'; import BulkSelectionBar from '$lib/document/BulkSelectionBar.svelte'; import TimelineDensityFilter from '$lib/document/TimelineDensityFilter.svelte'; +import SmartSearchStatus from '../search/SmartSearchStatus.svelte'; +import InterpretationChipRow from '../search/InterpretationChipRow.svelte'; +import DisambiguationPicker from '../search/DisambiguationPicker.svelte'; import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte'; import { getErrorMessage, parseBackendError } from '$lib/shared/errors'; +import { csrfFetch } from '$lib/shared/cookies'; import * as m from '$lib/paraglide/messages.js'; +import type { components } from '$lib/generated/api'; + +type NlQueryInterpretation = components['schemas']['NlQueryInterpretation']; +type NlSearchResponse = components['schemas']['NlSearchResponse']; +type DocumentSearchResult = components['schemas']['DocumentSearchResult']; +type PersonHint = components['schemas']['PersonHint']; +type SmartSearchErrorCode = 'SMART_SEARCH_UNAVAILABLE' | 'SMART_SEARCH_RATE_LIMITED'; let { data } = $props(); @@ -34,6 +45,18 @@ let tagQ = $state(untrack(() => data.tagQ || '')); let tagOperator = $state<'AND' | 'OR'>(untrack(() => (data.tagOp as 'AND' | 'OR') || 'AND')); let undated = $state(untrack(() => data.undated ?? false)); +// Smart (NL) search — UI-local state, resets on real page navigation (away + back). +let smartMode = $state(false); +let nlSubmitted = $state(false); +let nlLoading = $state(false); +let nlError = $state(null); +let nlInterpretation = $state(null); +let nlResult = $state(null); + +const showNlView = $derived(smartMode && nlSubmitted); +const nlHasResults = $derived((nlResult?.items.length ?? 0) > 0); +const nlIsAmbiguous = $derived((nlInterpretation?.ambiguousPersons.length ?? 0) > 0); + function hasAdvancedFilters() { return ( (data.tags?.length ?? 0) > 0 || @@ -164,6 +187,121 @@ function handleImmediateSearch() { triggerSearchKeepZoom(); } +function resetNlState() { + nlSubmitted = false; + nlLoading = false; + nlError = null; + nlInterpretation = null; + nlResult = null; +} + +/** Toggling the mode (either direction) always clears any prior NL interpretation. */ +function onModeToggle() { + resetNlState(); +} + +/** Submit the natural-language query to the server-side parser. */ +async function runSmartSearch() { + const query = q.trim(); + if (query.length < 3) return; + nlSubmitted = true; + nlLoading = true; + nlError = null; + nlInterpretation = null; + nlResult = null; + try { + const res = await csrfFetch('/api/search/nl', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }) + }); + if (!res.ok) { + const backend = await parseBackendError(res); + nlError = + backend?.code === 'SMART_SEARCH_RATE_LIMITED' + ? 'SMART_SEARCH_RATE_LIMITED' + : 'SMART_SEARCH_UNAVAILABLE'; + return; + } + const body: NlSearchResponse = await res.json(); + nlInterpretation = body.interpretation; + nlResult = body.result; + } catch { + nlError = 'SMART_SEARCH_UNAVAILABLE'; + } finally { + nlLoading = false; + } +} + +/** Option A empty/error fallback: drop NL mode, keep the raw query, run a keyword search. */ +function switchToKeywordMode() { + resetNlState(); + smartMode = false; + handleImmediateSearch(); +} + +/** Applies a resolved param set to the keyword filters and re-runs via GET. */ +function applyResolvedAndSearch(p: { + senderId: string; + receiverId: string; + from: string; + to: string; + q: string; +}) { + resetNlState(); + smartMode = false; + senderId = p.senderId; + receiverId = p.receiverId; + from = p.from; + to = p.to; + q = p.q; + handleImmediateSearch(); +} + +function paramsFromInterpretation(interp: NlQueryInterpretation) { + const resolved = interp.resolvedPersons; + return { + senderId: resolved.length >= 1 ? resolved[0].id : '', + receiverId: resolved.length >= 2 ? resolved[1].id : '', + from: interp.dateFrom ?? '', + to: interp.dateTo ?? '', + q: interp.keywordsApplied ? interp.keywords.join(' ') : '' + }; +} + +type ChipType = 'sender' | 'directional' | 'date' | 'keyword'; + +function removeChip(type: ChipType, value?: string) { + if (!nlInterpretation) return; + const p = paramsFromInterpretation(nlInterpretation); + if (type === 'sender') { + p.senderId = ''; + } else if (type === 'directional') { + p.senderId = ''; + p.receiverId = ''; + } else if (type === 'date') { + p.from = ''; + p.to = ''; + } else if (type === 'keyword' && value) { + const remaining = nlInterpretation.keywords.filter((keyword) => keyword !== value); + p.q = remaining.join(' '); + } + applyResolvedAndSearch(p); +} + +/** Single-select disambiguation: resolved person becomes sender, chosen becomes receiver. */ +function selectDisambiguated(person: PersonHint) { + if (!nlInterpretation) return; + const resolved = nlInterpretation.resolvedPersons; + applyResolvedAndSearch({ + senderId: resolved.length >= 1 ? resolved[0].id : person.id, + receiverId: resolved.length >= 1 ? person.id : '', + from: nlInterpretation.dateFrom ?? '', + to: nlInterpretation.dateTo ?? '', + q: nlInterpretation.keywordsApplied ? nlInterpretation.keywords.join(' ') : '' + }); +} + // Trigger search reactively when the tag list changes. let prevTagStr = untrack(() => tagNames.map((t) => t.name).join(',')); $effect(() => { @@ -268,6 +406,7 @@ $effect(() => { bind:tagQ={tagQ} bind:tagOperator={tagOperator} bind:undated={undated} + bind:smartMode={smartMode} undatedCount={data.undatedCount ?? 0} initialSenderName={initialSenderName} initialReceiverName={initialReceiverName} @@ -275,93 +414,141 @@ $effect(() => { isLoading={navigating.to !== null} onSearch={handleTextSearch} onSearchImmediate={handleImmediateSearch} + onSmartSearch={runSmartSearch} + onModeToggle={onModeToggle} onfocus={() => (qFocused = true)} onblur={() => (qFocused = false)} /> - + {#if showNlView} + + {#if nlLoading} + + {:else if nlError} + + {:else if nlInterpretation} + {#key nlInterpretation} +
+ {#if nlIsAmbiguous} + + {:else} + + {/if} +
-
-

- {#if data.totalElements > 0}{m.docs_result_count({ count: data.totalElements })}{/if} -

- {#if data.canWrite} -
-
- {#if data.totalElements > 0} - +
+ {/if} + {/if} + {/key} + {/if} + {:else} + + +
+

+ {#if data.totalElements > 0}{m.docs_result_count({ count: data.totalElements })}{/if} +

+ {#if data.canWrite} +
+
+ {#if data.totalElements > 0} + + {/if} + - {m.bulk_edit_all_x({ count: data.totalElements })} - + {m.docs_btn_new()} + +
+ {#if editAllError} + {/if} - - - {m.docs_btn_new()} -
- {#if editAllError} - - {/if} -
- {/if} -
+ {/if} +
- + - + + {/if}