diff --git a/docs/architecture/c4/l3-frontend-3b-document-workflows.puml b/docs/architecture/c4/l3-frontend-3b-document-workflows.puml
index 71407f27..4b521b0b 100644
--- a/docs/architecture/c4/l3-frontend-3b-document-workflows.puml
+++ b/docs/architecture/c4/l3-frontend-3b-document-workflows.puml
@@ -10,6 +10,11 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
Component(homePage, "/ (Home / Search)", "SvelteKit Route", "Loader: parses URL params (q, from, to, senderId, receiverId, tags), fetches /api/documents/search and /api/persons. Renders search form with full-text, date range, sender/receiver typeahead, and tag filters.")
Component(docsListPageTs, "/documents/+page.ts", "SvelteKit Client Loader", "Client-side load gated by matchMedia('(min-width: 1024px)') and ?view query. Fetches /api/documents/density only on desktop (Tailwind lg breakpoint) and outside calendar view; degrades to empty buckets on network failure.")
Component(timelineFilter, "TimelineDensityFilter.svelte", "Svelte Component", "Per-month density bars above the document list. Click selects a single month, emits onchange({from, to}) using YYYY-MM-DD boundaries. Hidden on mobile and tablet (below lg, 1024px) and in calendar view.")
+ Component(searchFilterBar, "SearchFilterBar.svelte", "Svelte Component", "Search/filter card on /documents. Hosts the keyword input, sort, advanced filters, and the smart-mode toggle. In smart mode submits the NL query on Enter via onSmartSearch instead of the live keyword search.")
+ Component(smartToggle, "search/SmartModeToggle.svelte", "Svelte Component", "Toggle pill (KI/Text) inside the search input. aria-pressed; switches between keyword and NL (smart) search modes.")
+ Component(chipRow, "search/InterpretationChipRow.svelte", "Svelte Component", "Renders NL interpretation chips (Absender / directional / Zeitraum / Stichwort). Removing a chip emits onRemoveChip; the page re-runs a keyword GET with the remaining params.")
+ Component(smartStatus, "search/SmartSearchStatus.svelte", "Svelte Component", "Full-area panels for NL search: loading (role=status), 503 SMART_SEARCH_UNAVAILABLE (with keyword fallback), 429 SMART_SEARCH_RATE_LIMITED.")
+ Component(disambig, "search/DisambiguationPicker.svelte", "Svelte Component", "Accessible single-select disclosure for ambiguous person names; selecting a candidate re-runs the search via GET.")
Component(docDetail, "/documents/[id]", "SvelteKit Route", "Loader: GET /api/documents/{id}. Page: metadata panel, inline file viewer, transcription editor, annotation layer, and comment thread.")
Component(docEdit, "/documents/[id]/edit", "SvelteKit Route", "Edit form with PersonTypeahead, TagInput, date/location fields. Form action: PUT /api/documents/{id}.")
Component(docNew, "/documents/new", "SvelteKit Route", "Upload form for a new document. Loader: GET /api/persons. Form action: POST /api/documents with multipart file.")
@@ -25,6 +30,12 @@ Rel(user, homePage, "Searches and browses", "HTTPS / Browser")
Rel(homePage, backend, "GET /api/documents/search, GET /api/persons", "HTTP / JSON")
Rel(docsListPageTs, backend, "GET /api/documents/density (desktop only, ≥1024px)", "HTTP / JSON")
Rel(homePage, timelineFilter, "Mounts above the result list")
+Rel(homePage, searchFilterBar, "Mounts the search/filter card")
+Rel(searchFilterBar, smartToggle, "Embeds the smart-mode toggle in the input")
+Rel(homePage, backend, "POST /api/search/nl (smart mode)", "HTTP / JSON")
+Rel(homePage, smartStatus, "Renders loading / 503 / 429 panels")
+Rel(homePage, chipRow, "Renders interpretation chips; handles chip removal")
+Rel(homePage, disambig, "Renders the picker when names are ambiguous")
Rel(docsListPageTs, timelineFilter, "Provides density / minDate / maxDate props")
Rel(docDetail, backend, "GET /api/documents/{id}, GET /api/documents/{id}/file", "HTTP / JSON + Binary")
Rel(docEdit, backend, "PUT /api/documents/{id}", "HTTP / Multipart")
diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md
index a6fa8df7..3301675e 100644
--- a/frontend/CLAUDE.md
+++ b/frontend/CLAUDE.md
@@ -28,6 +28,7 @@ src/
│ ├── +layout.server.ts # Loads current user, injects auth cookie
│ ├── +page.svelte # Home / document search dashboard
│ ├── documents/ # Document CRUD, detail, edit, upload
+│ ├── search/ # Smart (NL) search sub-components — SmartModeToggle, InterpretationChipRow, SmartSearchStatus, DisambiguationPicker (no +page; consumed by documents/ and SearchFilterBar)
│ ├── persons/ # Person directory (filtered, paginated), detail, edit, merge, review (triage)
│ ├── aktivitaeten/ # Unified activity feed (Chronik)
│ ├── admin/ # User, group, tag, OCR, system management
diff --git a/frontend/e2e/nl-search.spec.ts b/frontend/e2e/nl-search.spec.ts
new file mode 100644
index 00000000..bd869582
--- /dev/null
+++ b/frontend/e2e/nl-search.spec.ts
@@ -0,0 +1,83 @@
+import AxeBuilder from '@axe-core/playwright';
+import { test, expect } from '@playwright/test';
+
+// NL search is mocked at the network boundary — Ollama is not required in CI.
+// CSRF enforcement is bypassed by page.route (the real request is never sent),
+// so it is only verified in manual full-stack runs (see issue #739 DevOps notes).
+const interpretation = {
+ resolvedPersons: [
+ { id: '11111111-1111-1111-1111-111111111111', displayName: 'Walter Raddatz' },
+ { id: '22222222-2222-2222-2222-222222222222', displayName: 'Emma Raddatz' }
+ ],
+ ambiguousPersons: [],
+ dateFrom: '1914-01-01',
+ dateTo: '1918-12-31',
+ keywords: ['krieg'],
+ rawQuery: 'Was hat Walter an Emma im Krieg geschrieben?',
+ keywordsApplied: true
+};
+
+const nlResponse = {
+ result: {
+ items: [],
+ totalElements: 0,
+ pageNumber: 0,
+ pageSize: 20,
+ totalPages: 0,
+ undatedCount: 0
+ },
+ interpretation
+};
+
+test.describe('NL (smart) search — happy path', () => {
+ test('toggle → loading → chips → remove chip re-runs keyword search; axe clean light + dark', async ({
+ page
+ }) => {
+ // Deliberate delay so the loading state is assertable before the response arrives.
+ await page.route('**/api/search/nl', async (route) => {
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(nlResponse)
+ });
+ });
+
+ await page.goto('/documents');
+ await page.waitForSelector('[data-hydrated]');
+
+ // Switch to smart mode via the toggle pill (keyword label = "Text").
+ await page.getByRole('button', { name: /Text/ }).click();
+
+ const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen…');
+ await input.fill('Was hat Walter an Emma im Krieg geschrieben?');
+ await input.press('Enter');
+
+ // Loading panel announced to screen readers.
+ await expect(page.getByText(/Archiv wird befragt/)).toBeVisible();
+
+ // Directional chip (Walter → Emma) + keyword chip render once the fixture resolves.
+ await expect(page.getByText('→')).toBeVisible();
+ await expect(page.getByText('Stichwort: krieg')).toBeVisible();
+
+ // Accessibility — light mode.
+ const lightScan = await new AxeBuilder({ page })
+ .include('[data-testid="smart-search-results"]')
+ .analyze();
+ expect(lightScan.violations).toEqual([]);
+
+ // Accessibility — dark mode.
+ await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
+ const darkScan = await new AxeBuilder({ page })
+ .include('[data-testid="smart-search-results"]')
+ .analyze();
+ expect(darkScan.violations).toEqual([]);
+ await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'light'));
+
+ // Removing the keyword chip re-runs a keyword GET with the remaining resolved
+ // params (sender + receiver from the directional pair).
+ await page.getByRole('button', { name: 'Filter entfernen: Stichwort: krieg' }).click();
+ await page.waitForURL(/senderId=11111111-1111-1111-1111-111111111111/);
+ await expect(page).toHaveURL(/receiverId=22222222-2222-2222-2222-222222222222/);
+ });
+});
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index e4d29d12..1315a8c7 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -25,6 +25,28 @@
"error_smart_search_unavailable": "Die intelligente Suche ist momentan nicht verfügbar. Bitte nutzen Sie die normale Suche.",
"error_smart_search_rate_limited": "Sie haben die Suchfunktion zu häufig genutzt. Bitte warten Sie eine Minute.",
"smart_search_keywords_not_applied": "Schlüsselwörter konnten bei dieser Suche nicht berücksichtigt werden.",
+ "search_toggle_smart_label": "KI",
+ "search_toggle_smart_label_suffix": "-Suche",
+ "search_toggle_keyword_label": "Text",
+ "search_toggle_keyword_label_suffix": "suche",
+ "search_loading_nl": "Archiv wird befragt…",
+ "search_loading_nl_sub": "Die KI analysiert Ihre Anfrage. Das kann bis zu 15 Sekunden dauern.",
+ "search_error_unavailable": "Intelligente Suche nicht verfügbar",
+ "search_error_unavailable_body": "Die KI-Suche ist momentan nicht erreichbar. Sie können Ihre Anfrage als einfache Volltextsuche wiederholen.",
+ "search_switch_to_keyword": "Zur Volltextsuche wechseln",
+ "search_error_rate_limited": "Zu viele Anfragen",
+ "search_error_rate_limited_body": "Sie haben die intelligente Suche zu häufig genutzt. Bitte warten Sie eine Minute und versuchen Sie es erneut.",
+ "search_empty_nl": "Keine Ergebnisse",
+ "search_empty_retry_keyword": "Als Volltextsuche wiederholen",
+ "search_filter_remove_label": "Filter entfernen: {label}",
+ "search_chip_sender": "Absender",
+ "search_chip_date": "Zeitraum",
+ "search_chip_keyword": "Stichwort",
+ "search_chip_directional_label": "Von {from} zu {to}, Filter entfernen",
+ "search_disambiguation_trigger_label": "Mehrere Personen gefunden — zum Auswählen klicken",
+ "search_disambiguation_cue": "(auswählen…)",
+ "search_disambiguation_heading": "Person auswählen",
+ "search_disambiguation_select_label": "{name} auswählen",
"error_validation_error": "Die Eingabe ist ungültig.",
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
"nav_documents": "Dokumente",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 6eb58fca..a1315bdc 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -25,6 +25,28 @@
"error_smart_search_unavailable": "The smart search is currently unavailable. Please use the regular search.",
"error_smart_search_rate_limited": "You have used the search function too frequently. Please wait a minute.",
"smart_search_keywords_not_applied": "Keywords could not be applied to this search.",
+ "search_toggle_smart_label": "AI",
+ "search_toggle_smart_label_suffix": " search",
+ "search_toggle_keyword_label": "Text",
+ "search_toggle_keyword_label_suffix": " search",
+ "search_loading_nl": "Querying the archive…",
+ "search_loading_nl_sub": "The AI is analysing your request. This can take up to 15 seconds.",
+ "search_error_unavailable": "Smart search unavailable",
+ "search_error_unavailable_body": "The AI search is currently unreachable. You can repeat your request as a plain full-text search.",
+ "search_switch_to_keyword": "Switch to full-text search",
+ "search_error_rate_limited": "Too many requests",
+ "search_error_rate_limited_body": "You have used the smart search too frequently. Please wait a minute and try again.",
+ "search_empty_nl": "No results",
+ "search_empty_retry_keyword": "Repeat as full-text search",
+ "search_filter_remove_label": "Remove filter: {label}",
+ "search_chip_sender": "Sender",
+ "search_chip_date": "Period",
+ "search_chip_keyword": "Keyword",
+ "search_chip_directional_label": "From {from} to {to}, remove filter",
+ "search_disambiguation_trigger_label": "Several people found — click to choose",
+ "search_disambiguation_cue": "(choose…)",
+ "search_disambiguation_heading": "Choose a person",
+ "search_disambiguation_select_label": "Select {name}",
"error_validation_error": "The input is invalid.",
"error_internal_error": "An unexpected error occurred.",
"nav_documents": "Documents",
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index 92fb90af..86f2c52e 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -25,6 +25,28 @@
"error_smart_search_unavailable": "La búsqueda inteligente no está disponible en este momento. Por favor, usa la búsqueda normal.",
"error_smart_search_rate_limited": "Has utilizado la función de búsqueda demasiadas veces. Por favor, espera un minuto.",
"smart_search_keywords_not_applied": "Las palabras clave no pudieron aplicarse a esta búsqueda.",
+ "search_toggle_smart_label": "IA",
+ "search_toggle_smart_label_suffix": " búsqueda",
+ "search_toggle_keyword_label": "Texto",
+ "search_toggle_keyword_label_suffix": " búsqueda",
+ "search_loading_nl": "Consultando el archivo…",
+ "search_loading_nl_sub": "La IA está analizando su solicitud. Esto puede tardar hasta 15 segundos.",
+ "search_error_unavailable": "Búsqueda inteligente no disponible",
+ "search_error_unavailable_body": "La búsqueda con IA no está disponible en este momento. Puede repetir su solicitud como una búsqueda de texto completo.",
+ "search_switch_to_keyword": "Cambiar a búsqueda de texto completo",
+ "search_error_rate_limited": "Demasiadas solicitudes",
+ "search_error_rate_limited_body": "Ha utilizado la búsqueda inteligente con demasiada frecuencia. Espere un minuto e inténtelo de nuevo.",
+ "search_empty_nl": "Sin resultados",
+ "search_empty_retry_keyword": "Repetir como búsqueda de texto completo",
+ "search_filter_remove_label": "Eliminar filtro: {label}",
+ "search_chip_sender": "Remitente",
+ "search_chip_date": "Período",
+ "search_chip_keyword": "Palabra clave",
+ "search_chip_directional_label": "De {from} a {to}, eliminar filtro",
+ "search_disambiguation_trigger_label": "Se encontraron varias personas — haga clic para elegir",
+ "search_disambiguation_cue": "(elegir…)",
+ "search_disambiguation_heading": "Elegir una persona",
+ "search_disambiguation_select_label": "Seleccionar {name}",
"error_validation_error": "La entrada no es válida.",
"error_internal_error": "Se ha producido un error inesperado.",
"nav_documents": "Documentos",
diff --git a/frontend/src/routes/SearchFilterBar.svelte b/frontend/src/routes/SearchFilterBar.svelte
index 2b980974..779ab8f3 100644
--- a/frontend/src/routes/SearchFilterBar.svelte
+++ b/frontend/src/routes/SearchFilterBar.svelte
@@ -3,6 +3,7 @@ import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
import TagInput from '$lib/tag/TagInput.svelte';
import DateInput from '$lib/shared/primitives/DateInput.svelte';
import SortDropdown from '$lib/shared/primitives/SortDropdown.svelte';
+import SmartModeToggle from './search/SmartModeToggle.svelte';
import { slide } from 'svelte/transition';
import { m } from '$lib/paraglide/messages.js';
@@ -20,12 +21,15 @@ let {
sort = $bindable('DATE'),
dir = $bindable('desc'),
showAdvanced = $bindable(false),
+ smartMode = $bindable(false),
initialSenderName = '',
initialReceiverName = '',
navKey = 0,
isLoading = false,
onSearch,
onSearchImmediate,
+ onSmartSearch,
+ onModeToggle,
onfocus,
onblur
}: {
@@ -42,16 +46,28 @@ let {
sort?: string;
dir?: string;
showAdvanced?: boolean;
+ smartMode?: boolean;
initialSenderName?: string;
initialReceiverName?: string;
navKey?: number;
isLoading?: boolean;
onSearch: () => void;
onSearchImmediate?: () => void;
+ onSmartSearch?: () => void;
+ onModeToggle?: () => void;
onfocus?: () => void;
onblur?: () => void;
} = $props();
+// In smart mode the keyword search must not fire on every keystroke — the NL
+// query is submitted only on Enter (or an explicit button click).
+function onSearchKeydown(event: KeyboardEvent) {
+ if (smartMode && event.key === 'Enter') {
+ event.preventDefault();
+ onSmartSearch?.();
+ }
+}
+
// Plain (non-reactive) flag — not $state, so no reactive assignment inside $effect
let sortDirMounted = false;
@@ -76,14 +92,20 @@ $effect(() => {
-
+
+
{#if isLoading}
+
diff --git a/frontend/src/routes/SearchFilterBar.svelte.spec.ts b/frontend/src/routes/SearchFilterBar.svelte.spec.ts
index 446cd046..fa389d8c 100644
--- a/frontend/src/routes/SearchFilterBar.svelte.spec.ts
+++ b/frontend/src/routes/SearchFilterBar.svelte.spec.ts
@@ -195,3 +195,39 @@ describe('SearchFilterBar – tagQ live filter', () => {
vi.unstubAllGlobals();
});
});
+
+describe('SearchFilterBar – smart-mode chip lifecycle hooks', () => {
+ // The interpretation chips live in the result area (parent page). SearchFilterBar
+ // drives chip-clearing through callbacks: onModeToggle (mode switch) and
+ // onSmartSearch (new query). These tests pin that contract.
+ it('invokes onModeToggle when toggling back to keyword mode (parent clears chips)', async () => {
+ const onModeToggle = vi.fn();
+ render(SearchFilterBar, {
+ ...defaultProps,
+ sort: 'DATE',
+ dir: 'desc',
+ smartMode: true,
+ onModeToggle
+ });
+ await page.getByRole('button', { name: /KI/ }).click();
+ expect(onModeToggle).toHaveBeenCalledOnce();
+ });
+
+ it('invokes onSmartSearch when a new query is submitted in smart mode (parent resets chips)', async () => {
+ const onSmartSearch = vi.fn();
+ render(SearchFilterBar, {
+ ...defaultProps,
+ sort: 'DATE',
+ dir: 'desc',
+ smartMode: true,
+ onSmartSearch
+ });
+ const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen…');
+ await input.fill('Walter im Krieg');
+ await input.click();
+ (document.activeElement as HTMLElement).dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
+ );
+ await vi.waitFor(() => expect(onSmartSearch).toHaveBeenCalled());
+ });
+});
diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte
index 5006d9eb..b9a2ece5 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,143 @@ $effect(() => {
isLoading={navigating.to !== null}
onSearch={handleTextSearch}
onSearchImmediate={handleImmediateSearch}
+ onSmartSearch={runSmartSearch}
+ onModeToggle={onModeToggle}
onfocus={() => (qFocused = true)}
onblur={() => (qFocused = false)}
/>
-
- {
- 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);
- }}
- />
-
+ {#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}
-
+ {:else}
+
+ {
+ 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);
+ }}
+ />
+
+
+
+
+ {#if data.totalElements > 0}{m.docs_result_count({ count: data.totalElements })}{/if}
+
+ {#if data.canWrite}
+
- {#if editAllError}
-
- {editAllError}
-
- {/if}
-
- {/if}
-
+ {/if}
+
-
+
-
+
+ {/if}
diff --git a/frontend/src/routes/search/DisambiguationPicker.svelte b/frontend/src/routes/search/DisambiguationPicker.svelte
new file mode 100644
index 00000000..b99fea10
--- /dev/null
+++ b/frontend/src/routes/search/DisambiguationPicker.svelte
@@ -0,0 +1,86 @@
+
+
+
+
+
open && closePicker()}>
+
+ {names}
+ {m.search_disambiguation_cue()}
+
+
+ {#if open}
+
+ {#each persons as person (person.id)}
+ -
+ select(person)}
+ class="flex min-h-[44px] w-full items-center px-4 text-left text-sm text-ink outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-brand-navy"
+ >
+ {person.displayName}
+
+
+ {/each}
+
+ {/if}
+
diff --git a/frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts b/frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts
new file mode 100644
index 00000000..5b87a996
--- /dev/null
+++ b/frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it, vi, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import DisambiguationPicker from './DisambiguationPicker.svelte';
+import type { components } from '$lib/generated/api';
+
+type PersonHint = components['schemas']['PersonHint'];
+
+afterEach(() => cleanup());
+
+const persons: PersonHint[] = [
+ { id: 'w1', displayName: 'Walter Raddatz' },
+ { id: 'w2', displayName: 'Walter Müller' }
+];
+
+function pressEscape() {
+ (document.activeElement as HTMLElement).dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
+ );
+}
+
+describe('DisambiguationPicker', () => {
+ it('opens the picker and shows a select option per ambiguous person', async () => {
+ render(DisambiguationPicker, { persons, onSelect: vi.fn() });
+ await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
+ await expect
+ .element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
+ .toBeInTheDocument();
+ await expect
+ .element(page.getByRole('button', { name: 'Walter Müller auswählen' }))
+ .toBeInTheDocument();
+ });
+
+ it('moves focus into the picker list on open', async () => {
+ render(DisambiguationPicker, { persons, onSelect: vi.fn() });
+ await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
+ await expect
+ .element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
+ .toHaveFocus();
+ });
+
+ it('returns focus to the trigger when closed with Escape', async () => {
+ render(DisambiguationPicker, { persons, onSelect: vi.fn() });
+ const trigger = page.getByRole('button', { name: /Mehrere Personen gefunden/ });
+ await trigger.click();
+ await expect
+ .element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
+ .toHaveFocus();
+ pressEscape();
+ await expect.element(trigger).toHaveFocus();
+ });
+
+ it('does not call onSelect when dismissed without choosing', async () => {
+ const onSelect = vi.fn();
+ render(DisambiguationPicker, { persons, onSelect });
+ await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
+ await expect
+ .element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
+ .toHaveFocus();
+ pressEscape();
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it('calls onSelect with the chosen person', async () => {
+ const onSelect = vi.fn();
+ render(DisambiguationPicker, { persons, onSelect });
+ await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
+ await page.getByRole('button', { name: 'Walter Müller auswählen' }).click();
+ expect(onSelect).toHaveBeenCalledWith(persons[1]);
+ });
+});
diff --git a/frontend/src/routes/search/InterpretationChipRow.svelte b/frontend/src/routes/search/InterpretationChipRow.svelte
new file mode 100644
index 00000000..8e9cdbc0
--- /dev/null
+++ b/frontend/src/routes/search/InterpretationChipRow.svelte
@@ -0,0 +1,133 @@
+
+
+
+ {#each chips as chip (chip.key)}
+ {#if chip.type === 'directional'}
+
+ {chip.from}
+ →
+ {chip.to}
+ remove(chip)}
+ >
+ ×
+
+
+ {:else}
+
+ {chip.label}
+ remove(chip)}
+ >
+ ×
+
+
+ {/if}
+ {/each}
+
+
+{#if showKeywordsNotApplied}
+
{m.smart_search_keywords_not_applied()}
+{/if}
diff --git a/frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts b/frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts
new file mode 100644
index 00000000..f3164389
--- /dev/null
+++ b/frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts
@@ -0,0 +1,133 @@
+import { describe, expect, it, vi, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import InterpretationChipRow from './InterpretationChipRow.svelte';
+import type { components } from '$lib/generated/api';
+
+type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
+type PersonHint = components['schemas']['PersonHint'];
+
+afterEach(() => cleanup());
+
+const makePerson = (id: string, displayName: string): PersonHint => ({ id, displayName });
+
+const makeInterpretation = (
+ overrides: Partial
= {}
+): NlQueryInterpretation => ({
+ resolvedPersons: [],
+ ambiguousPersons: [],
+ keywords: [],
+ rawQuery: 'test',
+ keywordsApplied: true,
+ ...overrides
+});
+
+describe('InterpretationChipRow', () => {
+ it('renders type-prefixed labels for sender, date and keyword chips', async () => {
+ render(InterpretationChipRow, {
+ interpretation: makeInterpretation({
+ resolvedPersons: [makePerson('p1', 'Walter Raddatz')],
+ dateFrom: '1914-01-01',
+ dateTo: '1918-12-31',
+ keywords: ['krieg']
+ }),
+ onRemoveChip: vi.fn()
+ });
+ await expect.element(page.getByText('Absender: Walter Raddatz')).toBeInTheDocument();
+ await expect.element(page.getByText('Zeitraum: 1914–1918')).toBeInTheDocument();
+ await expect.element(page.getByText('Stichwort: krieg')).toBeInTheDocument();
+ });
+
+ it('calls onRemoveChip with "sender" when the sender chip × is clicked', async () => {
+ const onRemoveChip = vi.fn();
+ render(InterpretationChipRow, {
+ interpretation: makeInterpretation({
+ resolvedPersons: [makePerson('p1', 'Walter Raddatz')]
+ }),
+ onRemoveChip
+ });
+ await page.getByRole('button', { name: /Absender: Walter Raddatz/ }).click();
+ expect(onRemoveChip).toHaveBeenCalledWith('sender', undefined);
+ });
+
+ it('removes a chip from the DOM but keeps the rest when one × is clicked', async () => {
+ const { container } = render(InterpretationChipRow, {
+ interpretation: makeInterpretation({
+ resolvedPersons: [makePerson('p1', 'Walter Raddatz')],
+ dateFrom: '1914-01-01',
+ dateTo: '1918-12-31',
+ keywords: ['krieg']
+ }),
+ onRemoveChip: vi.fn()
+ });
+ expect(container.querySelectorAll('[data-chip-type]')).toHaveLength(3);
+ await page.getByRole('button', { name: /Absender/ }).click();
+ await vi.waitFor(() => expect(container.querySelectorAll('[data-chip-type]')).toHaveLength(2));
+ });
+
+ it('renders a single directional chip with an arrow for a 2-name query', async () => {
+ const { container } = render(InterpretationChipRow, {
+ interpretation: makeInterpretation({
+ resolvedPersons: [makePerson('p1', 'Walter Raddatz'), makePerson('p2', 'Emma Raddatz')]
+ }),
+ onRemoveChip: vi.fn()
+ });
+ expect(container.querySelectorAll('[data-chip-type="directional"]')).toHaveLength(1);
+ await expect.element(page.getByText(/→/)).toBeInTheDocument();
+ });
+
+ it('calls onRemoveChip with "directional" when the directional chip × is clicked', async () => {
+ const onRemoveChip = vi.fn();
+ render(InterpretationChipRow, {
+ interpretation: makeInterpretation({
+ resolvedPersons: [makePerson('p1', 'Walter Raddatz'), makePerson('p2', 'Emma Raddatz')]
+ }),
+ onRemoveChip
+ });
+ await page.getByRole('button', { name: /Walter Raddatz/ }).click();
+ expect(onRemoveChip).toHaveBeenCalledWith('directional', undefined);
+ });
+
+ it('does not render keyword chips when keywordsApplied is false', async () => {
+ const { container } = render(InterpretationChipRow, {
+ interpretation: makeInterpretation({
+ keywordsApplied: false,
+ keywords: ['krieg', 'brief']
+ }),
+ onRemoveChip: vi.fn()
+ });
+ expect(container.querySelectorAll('[data-chip-type="keyword"]')).toHaveLength(0);
+ });
+
+ it('renders no keyword chips when keywords is empty', async () => {
+ const { container } = render(InterpretationChipRow, {
+ interpretation: makeInterpretation({ keywordsApplied: true, keywords: [] }),
+ onRemoveChip: vi.fn()
+ });
+ expect(container.querySelectorAll('[data-chip-type="keyword"]')).toHaveLength(0);
+ });
+
+ it('renders exactly one keyword chip per keyword', async () => {
+ const { container } = render(InterpretationChipRow, {
+ interpretation: makeInterpretation({
+ keywordsApplied: true,
+ keywords: ['krieg', 'brief', 'front']
+ }),
+ onRemoveChip: vi.fn()
+ });
+ expect(container.querySelectorAll('[data-chip-type="keyword"]')).toHaveLength(3);
+ });
+
+ it('keeps the × button in the DOM when a display name is 100 characters', async () => {
+ const longName = 'W'.repeat(100);
+ render(InterpretationChipRow, {
+ interpretation: makeInterpretation({
+ resolvedPersons: [makePerson('p1', longName)]
+ }),
+ onRemoveChip: vi.fn()
+ });
+ await expect
+ .element(page.getByRole('button', { name: new RegExp('Absender') }))
+ .toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/routes/search/SmartModeToggle.svelte b/frontend/src/routes/search/SmartModeToggle.svelte
new file mode 100644
index 00000000..a7155bcc
--- /dev/null
+++ b/frontend/src/routes/search/SmartModeToggle.svelte
@@ -0,0 +1,38 @@
+
+
+
+
+
+ {label}{labelSuffix}
+
+
diff --git a/frontend/src/routes/search/SmartModeToggle.svelte.spec.ts b/frontend/src/routes/search/SmartModeToggle.svelte.spec.ts
new file mode 100644
index 00000000..01347271
--- /dev/null
+++ b/frontend/src/routes/search/SmartModeToggle.svelte.spec.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it, vi, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import SmartModeToggle from './SmartModeToggle.svelte';
+import SearchFilterBar from '../SearchFilterBar.svelte';
+
+afterEach(() => cleanup());
+
+const SEARCH_PLACEHOLDER = 'Titel, Personen, Tags durchsuchen…';
+
+describe('SmartModeToggle', () => {
+ it('renders aria-pressed="false" by default and toggles on click', async () => {
+ render(SmartModeToggle, { smartMode: false });
+ const btn = page.getByRole('button');
+ await expect.element(btn).toHaveAttribute('aria-pressed', 'false');
+ await btn.click();
+ await expect.element(btn).toHaveAttribute('aria-pressed', 'true');
+ await btn.click();
+ await expect.element(btn).toHaveAttribute('aria-pressed', 'false');
+ });
+
+ it('shows the smart label when smartMode is true', async () => {
+ render(SmartModeToggle, { smartMode: true });
+ const btn = page.getByRole('button');
+ await expect.element(btn).toHaveTextContent('KI');
+ });
+
+ it('shows the keyword label when smartMode is false', async () => {
+ render(SmartModeToggle, { smartMode: false });
+ const btn = page.getByRole('button');
+ await expect.element(btn).toHaveTextContent('Text');
+ });
+
+ it('applies the active pill style only in smart mode', async () => {
+ render(SmartModeToggle, { smartMode: true });
+ const btn = page.getByRole('button');
+ await expect.element(btn).toHaveClass(/bg-primary/);
+ });
+});
+
+describe('SmartModeToggle inside SearchFilterBar', () => {
+ it('adds maxlength="500" to the search input only in smart mode', async () => {
+ render(SearchFilterBar, { onSearch: vi.fn(), sort: 'DATE', dir: 'desc', smartMode: true });
+ await expect
+ .element(page.getByPlaceholder(SEARCH_PLACEHOLDER))
+ .toHaveAttribute('maxlength', '500');
+ });
+
+ it('omits maxlength from the search input in keyword mode', async () => {
+ render(SearchFilterBar, { onSearch: vi.fn(), sort: 'DATE', dir: 'desc', smartMode: false });
+ await expect
+ .element(page.getByPlaceholder(SEARCH_PLACEHOLDER))
+ .not.toHaveAttribute('maxlength');
+ });
+
+ it('does not fire the keyword search on input while in smart mode', async () => {
+ const onSearch = vi.fn();
+ render(SearchFilterBar, { onSearch, sort: 'DATE', dir: 'desc', smartMode: true });
+ await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill('Walter im Krieg');
+ expect(onSearch).not.toHaveBeenCalled();
+ });
+
+ it('fires the smart search callback on Enter in smart mode', async () => {
+ const onSmartSearch = vi.fn();
+ render(SearchFilterBar, {
+ onSearch: vi.fn(),
+ onSmartSearch,
+ sort: 'DATE',
+ dir: 'desc',
+ smartMode: true
+ });
+ const input = page.getByPlaceholder(SEARCH_PLACEHOLDER);
+ await input.fill('Walter im Krieg');
+ await input.click();
+ // Enter submits the NL query in smart mode
+ (document.activeElement as HTMLElement).dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
+ );
+ await vi.waitFor(() => expect(onSmartSearch).toHaveBeenCalled());
+ });
+});
diff --git a/frontend/src/routes/search/SmartSearchStatus.svelte b/frontend/src/routes/search/SmartSearchStatus.svelte
new file mode 100644
index 00000000..d6444a7e
--- /dev/null
+++ b/frontend/src/routes/search/SmartSearchStatus.svelte
@@ -0,0 +1,69 @@
+
+
+{#if status === 'loading'}
+
+
+
{m.search_loading_nl()}
+
+ {m.search_loading_nl_sub()}
+
+
+{:else if status === 'error'}
+
+
+ {#if isRateLimited}
+
+ {:else}
+
!
+ {/if}
+
+
{title}
+
{body}
+ {#if !isRateLimited}
+
+ {m.search_switch_to_keyword()}
+
+ {/if}
+
+{/if}
diff --git a/frontend/src/routes/search/SmartSearchStatus.svelte.spec.ts b/frontend/src/routes/search/SmartSearchStatus.svelte.spec.ts
new file mode 100644
index 00000000..605576d0
--- /dev/null
+++ b/frontend/src/routes/search/SmartSearchStatus.svelte.spec.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it, vi, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import SmartSearchStatus from './SmartSearchStatus.svelte';
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+describe('SmartSearchStatus', () => {
+ it('renders a role="status" loading panel with the loading title', async () => {
+ render(SmartSearchStatus, { status: 'loading' });
+ const status = page.getByRole('status');
+ await expect.element(status).toBeInTheDocument();
+ await expect.element(status).toHaveTextContent('Archiv wird befragt');
+ });
+
+ it('hides the loading panel once the status changes away from loading', async () => {
+ const { rerender } = render(SmartSearchStatus, { status: 'loading' });
+ await expect.element(page.getByRole('status')).toBeInTheDocument();
+ await rerender({ status: 'error', errorCode: 'SMART_SEARCH_UNAVAILABLE' });
+ await expect.element(page.getByRole('status')).not.toBeInTheDocument();
+ });
+
+ it('renders the 503 panel with title, body and a switch-to-keyword button', async () => {
+ render(SmartSearchStatus, {
+ status: 'error',
+ errorCode: 'SMART_SEARCH_UNAVAILABLE',
+ onSwitchToKeyword: vi.fn()
+ });
+ await expect.element(page.getByText('Intelligente Suche nicht verfügbar')).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('button', { name: /Volltextsuche wechseln/ }))
+ .toBeInTheDocument();
+ });
+
+ it('invokes onSwitchToKeyword when the 503 fallback button is clicked', async () => {
+ const onSwitchToKeyword = vi.fn();
+ render(SmartSearchStatus, {
+ status: 'error',
+ errorCode: 'SMART_SEARCH_UNAVAILABLE',
+ onSwitchToKeyword
+ });
+ await page.getByRole('button', { name: /Volltextsuche wechseln/ }).click();
+ expect(onSwitchToKeyword).toHaveBeenCalledOnce();
+ });
+
+ it('renders the 429 panel with title and body but no switch-to-keyword button', async () => {
+ render(SmartSearchStatus, {
+ status: 'error',
+ errorCode: 'SMART_SEARCH_RATE_LIMITED',
+ onSwitchToKeyword: vi.fn()
+ });
+ await expect.element(page.getByText('Zu viele Anfragen')).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('button', { name: /Volltextsuche wechseln/ }))
+ .not.toBeInTheDocument();
+ });
+});