refactor(search): remove NLP/smart-search feature entirely (#772)
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m23s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 3m46s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 25s
CI / Compose Bucket Idempotency (push) Successful in 1m8s

## Summary

- Removes the NLP/smart-search feature completely — the feature was too unreliable and slow; users get better results with the regular search filters
- Deletes the entire backend `search/` package (NlSearchController, NlQueryParserService, NlpClient, NlSearchRateLimiter — 14 classes + 6 test classes)
- Deletes the `nlp-service/` Python microservice (FastAPI, rapidfuzz, DB-backed person matching)
- Removes all frontend NL search components: SmartModeToggle, SmartSearchStatus, InterpretationChipRow, DisambiguationPicker, chip-types, theme-chip-removal
- Strips smart-mode logic from SearchFilterBar and documents/+page.svelte
- Removes `SMART_SEARCH_UNAVAILABLE` / `SMART_SEARCH_RATE_LIMITED` error codes from backend, frontend types, and all three i18n files (de/en/es)
- Removes `nlp-service` container and `APP_NLP_BASE_URL` from both docker-compose files
- Removes Ollama/NLP Prometheus scrape job and Grafana dashboard
- Deletes ADRs 028 (×2), 034, 035

## Test plan

- [ ] Backend compiles: `cd backend && ./mvnw compile -q` → BUILD SUCCESS
- [ ] Frontend server tests pass: `cd frontend && npm run test -- --project=server`
- [ ] No NLP/smart-search references remain in source: `grep -r "SmartSearch\|NlSearch\|nlp-service\|SMART_SEARCH" backend/src frontend/src`
- [ ] `docker compose config` validates both compose files
- [ ] Search page loads, filter bar works, no smart-mode toggle visible

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #772
This commit was merged in pull request #772.
This commit is contained in:
2026-06-08 10:57:00 +02:00
parent 8e63867ad8
commit d650b6c066
60 changed files with 126 additions and 4364 deletions

View File

@@ -1,113 +0,0 @@
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'],
resolvedTags: [{ id: '33333333-3333-3333-3333-333333333333', name: 'Weltkrieg', color: 'sage' }],
rawQuery: 'Was hat Walter an Emma im Krieg geschrieben?',
keywordsApplied: true,
tagsApplied: 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 + theme chip render once the fixture resolves.
await expect(page.getByText('→')).toBeVisible();
await expect(page.getByText('Stichwort: krieg')).toBeVisible();
await expect(page.getByText(/Thema:.*Weltkrieg/)).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/);
});
test('removing the last theme chip drops tag/tagOp but keeps person params', async ({ page }) => {
await page.route('**/api/search/nl', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(nlResponse)
});
});
await page.goto('/documents');
await page.waitForSelector('[data-hydrated]');
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');
await expect(page.getByText(/Thema:.*Weltkrieg/)).toBeVisible();
// Remove the single theme chip — URL must carry sender UUID but no tag/tagOp.
await page.getByRole('button', { name: 'Filter entfernen: Thema: Weltkrieg' }).click();
await page.waitForURL(/senderId=11111111-1111-1111-1111-111111111111/);
const url = page.url();
expect(url).not.toMatch(/tag=/);
expect(url).not.toMatch(/tagOp=/);
});
});