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:
@@ -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} />
|
||||||
|
|||||||
Reference in New Issue
Block a user