refactor(search): remove NLP smart search from documents page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,23 +8,9 @@ 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 type { ChipType } from '../search/chip-types.js';
|
||||
import { buildThemeRemovalUrl } from './theme-chip-removal.js';
|
||||
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 { getLocale } from '$lib/paraglide/runtime';
|
||||
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();
|
||||
|
||||
@@ -48,27 +34,6 @@ 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<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 ambiguousPersons = $derived(nlInterpretation?.ambiguousPersons ?? []);
|
||||
const nlIsAmbiguous = $derived(ambiguousPersons.length > 0);
|
||||
// A 1-item picker is always a "did you mean …?" suggestion (a single direct match auto-selects
|
||||
// and never reaches the picker); ≥2 keeps the "choose a person" framing and the action cue.
|
||||
const disambiguationHeading = $derived(
|
||||
ambiguousPersons.length === 1
|
||||
? m.search_disambiguation_did_you_mean({ name: ambiguousPersons[0].displayName })
|
||||
: m.search_disambiguation_heading()
|
||||
);
|
||||
const showDisambiguationCue = $derived(ambiguousPersons.length >= 2);
|
||||
|
||||
function hasAdvancedFilters() {
|
||||
return (
|
||||
(data.tags?.length ?? 0) > 0 ||
|
||||
@@ -199,124 +164,6 @@ 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, lang: getLocale() })
|
||||
});
|
||||
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(' ') : ''
|
||||
};
|
||||
}
|
||||
|
||||
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(' ');
|
||||
} else if (type === 'theme' && value) {
|
||||
const url = buildThemeRemovalUrl(nlInterpretation, value);
|
||||
resetNlState();
|
||||
goto(url, { keepFocus: true, noScroll: true });
|
||||
return;
|
||||
}
|
||||
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(() => {
|
||||
@@ -421,7 +268,6 @@ $effect(() => {
|
||||
bind:tagQ={tagQ}
|
||||
bind:tagOperator={tagOperator}
|
||||
bind:undated={undated}
|
||||
bind:smartMode={smartMode}
|
||||
undatedCount={data.undatedCount ?? 0}
|
||||
initialSenderName={initialSenderName}
|
||||
initialReceiverName={initialReceiverName}
|
||||
@@ -429,71 +275,20 @@ $effect(() => {
|
||||
isLoading={navigating.to !== null}
|
||||
onSearch={handleTextSearch}
|
||||
onSearchImmediate={handleImmediateSearch}
|
||||
onSmartSearch={runSmartSearch}
|
||||
onModeToggle={onModeToggle}
|
||||
onfocus={() => (qFocused = true)}
|
||||
onblur={() => (qFocused = false)}
|
||||
/>
|
||||
|
||||
{#if showNlView}
|
||||
<!-- Smart-search results area: loading / error / chips + results / empty / disambiguation. -->
|
||||
<div data-testid="smart-search-results">
|
||||
{#if nlLoading}
|
||||
<SmartSearchStatus status="loading" />
|
||||
{:else if nlError}
|
||||
<SmartSearchStatus
|
||||
status="error"
|
||||
errorCode={nlError}
|
||||
onSwitchToKeyword={switchToKeywordMode}
|
||||
/>
|
||||
{:else if nlInterpretation}
|
||||
{#key nlInterpretation}
|
||||
<div class="mb-4">
|
||||
{#if nlIsAmbiguous}
|
||||
<DisambiguationPicker
|
||||
persons={nlInterpretation.ambiguousPersons}
|
||||
heading={disambiguationHeading}
|
||||
showCue={showDisambiguationCue}
|
||||
onSelect={selectDisambiguated}
|
||||
/>
|
||||
{:else}
|
||||
<InterpretationChipRow interpretation={nlInterpretation} onRemoveChip={removeChip} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !nlIsAmbiguous}
|
||||
{#if nlHasResults}
|
||||
<p class="mb-3 font-sans text-base text-ink-2">
|
||||
{m.docs_result_count({ count: nlResult?.totalElements ?? 0 })}
|
||||
</p>
|
||||
<DocumentList items={nlResult?.items ?? []} canWrite={data.canWrite} sort={sort} />
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||
<p class="text-sm font-bold text-ink">{m.search_empty_nl()}</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={switchToKeywordMode}
|
||||
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"
|
||||
>
|
||||
{m.search_empty_retry_keyword()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
{: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) => {
|
||||
<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).
|
||||
@@ -504,70 +299,69 @@ $effect(() => {
|
||||
triggerSearchKeepZoom();
|
||||
}
|
||||
}}
|
||||
onzoomchange={(event) => {
|
||||
onzoomchange={(event) => {
|
||||
triggerSearchWithZoom(event?.zoomFrom ?? null, event?.zoomTo ?? null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
</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"
|
||||
<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/Add/Add-General-MD.svg"
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{m.docs_btn_new()}
|
||||
</a>
|
||||
</div>
|
||||
{#if editAllError}
|
||||
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error">
|
||||
{editAllError}
|
||||
</p>
|
||||
{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
|
||||
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>
|
||||
{/if}
|
||||
</div>
|
||||
{#if editAllError}
|
||||
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error">
|
||||
{editAllError}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<DocumentList
|
||||
items={data.items}
|
||||
q={data.q}
|
||||
canWrite={data.canWrite}
|
||||
error={data.error}
|
||||
sort={sort}
|
||||
from={data.from}
|
||||
to={data.to}
|
||||
/>
|
||||
<DocumentList
|
||||
items={data.items}
|
||||
q={data.q}
|
||||
canWrite={data.canWrite}
|
||||
error={data.error}
|
||||
sort={sort}
|
||||
from={data.from}
|
||||
to={data.to}
|
||||
/>
|
||||
|
||||
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
|
||||
{/if}
|
||||
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
|
||||
</main>
|
||||
|
||||
<BulkSelectionBar canWrite={data.canWrite} />
|
||||
|
||||
Reference in New Issue
Block a user