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 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 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 { 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 { 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();
|
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 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 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() {
|
function hasAdvancedFilters() {
|
||||||
return (
|
return (
|
||||||
(data.tags?.length ?? 0) > 0 ||
|
(data.tags?.length ?? 0) > 0 ||
|
||||||
@@ -199,124 +164,6 @@ 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, 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.
|
// 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(() => {
|
||||||
@@ -421,7 +268,6 @@ $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}
|
||||||
@@ -429,71 +275,20 @@ $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)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if showNlView}
|
<div class="mt-3 mb-4 hidden lg:block">
|
||||||
<!-- Smart-search results area: loading / error / chips + results / empty / disambiguation. -->
|
<TimelineDensityFilter
|
||||||
<div data-testid="smart-search-results">
|
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) => {
|
||||||
/>
|
|
||||||
{: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) => {
|
|
||||||
from = event.from;
|
from = event.from;
|
||||||
to = event.to;
|
to = event.to;
|
||||||
// Drag commits filter + zoom atomically (Graylog-style range selector).
|
// Drag commits filter + zoom atomically (Graylog-style range selector).
|
||||||
@@ -504,70 +299,69 @@ $effect(() => {
|
|||||||
triggerSearchKeepZoom();
|
triggerSearchKeepZoom();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onzoomchange={(event) => {
|
onzoomchange={(event) => {
|
||||||
triggerSearchWithZoom(event?.zoomFrom ?? null, event?.zoomTo ?? null);
|
triggerSearchWithZoom(event?.zoomFrom ?? null, event?.zoomTo ?? null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3 flex items-center justify-between gap-4">
|
<div class="mb-3 flex items-center justify-between gap-4">
|
||||||
<p class="font-sans text-base text-ink-2">
|
<p class="font-sans text-base text-ink-2">
|
||||||
{#if data.totalElements > 0}{m.docs_result_count({ count: data.totalElements })}{/if}
|
{#if data.totalElements > 0}{m.docs_result_count({ count: data.totalElements })}{/if}
|
||||||
</p>
|
</p>
|
||||||
{#if data.canWrite}
|
{#if data.canWrite}
|
||||||
<div class="flex flex-col items-end gap-1">
|
<div class="flex flex-col items-end gap-1">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
{#if data.totalElements > 0}
|
{#if data.totalElements > 0}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={editAllMatching}
|
onclick={editAllMatching}
|
||||||
disabled={editingAll}
|
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"
|
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"
|
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/Add/Add-General-MD.svg"
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||||
alt=""
|
alt=""
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
{m.docs_btn_new()}
|
{m.bulk_edit_all_x({ count: data.totalElements })}
|
||||||
</a>
|
</button>
|
||||||
</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}
|
{#if editAllError}
|
||||||
</div>
|
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error">
|
||||||
|
{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