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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-06 17:54:07 +02:00
parent 5945824b54
commit f2f42ed415

View File

@@ -8,9 +8,20 @@ import DocumentList from '../DocumentList.svelte';
import Pagination from '$lib/shared/primitives/Pagination.svelte'; import Pagination from '$lib/shared/primitives/Pagination.svelte';
import BulkSelectionBar from '$lib/document/BulkSelectionBar.svelte'; import BulkSelectionBar from '$lib/document/BulkSelectionBar.svelte';
import TimelineDensityFilter from '$lib/document/TimelineDensityFilter.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 { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
import { getErrorMessage, parseBackendError } from '$lib/shared/errors'; import { getErrorMessage, parseBackendError } from '$lib/shared/errors';
import { csrfFetch } from '$lib/shared/cookies';
import * as m from '$lib/paraglide/messages.js'; 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(); 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 tagOperator = $state<'AND' | 'OR'>(untrack(() => (data.tagOp as 'AND' | 'OR') || 'AND'));
let undated = $state(untrack(() => data.undated ?? false)); 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<SmartSearchErrorCode | null>(null);
let nlInterpretation = $state<NlQueryInterpretation | null>(null);
let nlResult = $state<DocumentSearchResult | null>(null);
const showNlView = $derived(smartMode && nlSubmitted);
const nlHasResults = $derived((nlResult?.items.length ?? 0) > 0);
const nlIsAmbiguous = $derived((nlInterpretation?.ambiguousPersons.length ?? 0) > 0);
function hasAdvancedFilters() { function hasAdvancedFilters() {
return ( return (
(data.tags?.length ?? 0) > 0 || (data.tags?.length ?? 0) > 0 ||
@@ -164,6 +187,121 @@ function handleImmediateSearch() {
triggerSearchKeepZoom(); 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. // Trigger search reactively when the tag list changes.
let prevTagStr = untrack(() => tagNames.map((t) => t.name).join(',')); let prevTagStr = untrack(() => tagNames.map((t) => t.name).join(','));
$effect(() => { $effect(() => {
@@ -268,6 +406,7 @@ $effect(() => {
bind:tagQ={tagQ} bind:tagQ={tagQ}
bind:tagOperator={tagOperator} bind:tagOperator={tagOperator}
bind:undated={undated} bind:undated={undated}
bind:smartMode={smartMode}
undatedCount={data.undatedCount ?? 0} undatedCount={data.undatedCount ?? 0}
initialSenderName={initialSenderName} initialSenderName={initialSenderName}
initialReceiverName={initialReceiverName} initialReceiverName={initialReceiverName}
@@ -275,93 +414,141 @@ $effect(() => {
isLoading={navigating.to !== null} isLoading={navigating.to !== null}
onSearch={handleTextSearch} onSearch={handleTextSearch}
onSearchImmediate={handleImmediateSearch} onSearchImmediate={handleImmediateSearch}
onSmartSearch={runSmartSearch}
onModeToggle={onModeToggle}
onfocus={() => (qFocused = true)} onfocus={() => (qFocused = true)}
onblur={() => (qFocused = false)} onblur={() => (qFocused = false)}
/> />
<div class="mt-3 mb-4 hidden lg:block"> {#if showNlView}
<TimelineDensityFilter <!-- Smart-search results area: loading / error / chips + results / empty / disambiguation. -->
density={data.density} {#if nlLoading}
minDate={data.minDate} <SmartSearchStatus status="loading" />
maxDate={data.maxDate} {:else if nlError}
zoomFrom={data.zoomFrom} <SmartSearchStatus
zoomTo={data.zoomTo} status="error"
from={from} errorCode={nlError}
to={to} onSwitchToKeyword={switchToKeywordMode}
onchange={(event) => { />
from = event.from; {:else if nlInterpretation}
to = event.to; {#key nlInterpretation}
// Drag commits filter + zoom atomically (Graylog-style range selector). <div class="mb-4">
// Single click and clear omit zoomFrom/zoomTo so existing zoom is preserved. {#if nlIsAmbiguous}
if ('zoomFrom' in event) { <DisambiguationPicker
triggerSearchWithZoom(event.zoomFrom ?? null, event.zoomTo ?? null); persons={nlInterpretation.ambiguousPersons}
} else { onSelect={selectDisambiguated}
triggerSearchKeepZoom(); />
} {:else}
}} <InterpretationChipRow interpretation={nlInterpretation} onRemoveChip={removeChip} />
onzoomchange={(event) => { {/if}
triggerSearchWithZoom(event?.zoomFrom ?? null, event?.zoomTo ?? null); </div>
}}
/>
</div>
<div class="mb-3 flex items-center justify-between gap-4"> {#if !nlIsAmbiguous}
<p class="font-sans text-base text-ink-2"> {#if nlHasResults}
{#if data.totalElements > 0}{m.docs_result_count({ count: data.totalElements })}{/if} <p class="mb-3 font-sans text-base text-ink-2">
</p> {m.docs_result_count({ count: nlResult?.totalElements ?? 0 })}
{#if data.canWrite} </p>
<div class="flex flex-col items-end gap-1"> <DocumentList items={nlResult?.items ?? []} canWrite={data.canWrite} sort={sort} />
<div class="flex items-center gap-4"> {:else}
{#if data.totalElements > 0} <div class="flex flex-col items-center justify-center gap-3 py-16 text-center">
<button <p class="text-sm font-bold text-ink">{m.search_empty_nl()}</p>
type="button" <button
onclick={editAllMatching} type="button"
disabled={editingAll} onclick={switchToKeywordMode}
class="inline-flex cursor-pointer items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink disabled:opacity-50" class="inline-flex min-h-[44px] items-center rounded px-3 py-2 text-sm font-bold text-primary underline underline-offset-4 outline-none hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy"
data-testid="bulk-edit-all-x" >
{m.search_empty_retry_keyword()}
</button>
</div>
{/if}
{/if}
{/key}
{/if}
{:else}
<div class="mt-3 mb-4 hidden lg:block">
<TimelineDensityFilter
density={data.density}
minDate={data.minDate}
maxDate={data.maxDate}
zoomFrom={data.zoomFrom}
zoomTo={data.zoomTo}
from={from}
to={to}
onchange={(event) => {
from = event.from;
to = event.to;
// Drag commits filter + zoom atomically (Graylog-style range selector).
// Single click and clear omit zoomFrom/zoomTo so existing zoom is preserved.
if ('zoomFrom' in event) {
triggerSearchWithZoom(event.zoomFrom ?? null, event.zoomTo ?? null);
} else {
triggerSearchKeepZoom();
}
}}
onzoomchange={(event) => {
triggerSearchWithZoom(event?.zoomFrom ?? null, event?.zoomTo ?? null);
}}
/>
</div>
<div class="mb-3 flex items-center justify-between gap-4">
<p class="font-sans text-base text-ink-2">
{#if data.totalElements > 0}{m.docs_result_count({ count: data.totalElements })}{/if}
</p>
{#if data.canWrite}
<div class="flex flex-col items-end gap-1">
<div class="flex items-center gap-4">
{#if data.totalElements > 0}
<button
type="button"
onclick={editAllMatching}
disabled={editingAll}
class="inline-flex cursor-pointer items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink disabled:opacity-50"
data-testid="bulk-edit-all-x"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
{m.bulk_edit_all_x({ count: data.totalElements })}
</button>
{/if}
<a
href="/documents/new"
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
> >
<img <img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg" src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
alt="" alt=""
aria-hidden="true" aria-hidden="true"
class="h-4 w-4" class="h-4 w-4"
/> />
{m.bulk_edit_all_x({ count: data.totalElements })} {m.docs_btn_new()}
</button> </a>
</div>
{#if editAllError}
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error">
{editAllError}
</p>
{/if} {/if}
<a
href="/documents/new"
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
{m.docs_btn_new()}
</a>
</div> </div>
{#if editAllError} {/if}
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error"> </div>
{editAllError}
</p>
{/if}
</div>
{/if}
</div>
<DocumentList <DocumentList
items={data.items} items={data.items}
q={data.q} q={data.q}
canWrite={data.canWrite} canWrite={data.canWrite}
error={data.error} error={data.error}
sort={sort} sort={sort}
from={data.from} from={data.from}
to={data.to} to={data.to}
/> />
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} /> <Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
{/if}
</main> </main>
<BulkSelectionBar canWrite={data.canWrite} /> <BulkSelectionBar canWrite={data.canWrite} />