diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryInterpretation.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryInterpretation.java deleted file mode 100644 index 37611488..00000000 --- a/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryInterpretation.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.raddatz.familienarchiv.search; - -import io.swagger.v3.oas.annotations.media.Schema; - -import java.time.LocalDate; -import java.util.List; - -public record NlQueryInterpretation( - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - List resolvedPersons, - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - List ambiguousPersons, - LocalDate dateFrom, - LocalDate dateTo, - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - List keywords, - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - List resolvedTags, - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - String rawQuery, - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - boolean keywordsApplied, - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - boolean tagsApplied -) { -} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java deleted file mode 100644 index db8a9da9..00000000 --- a/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java +++ /dev/null @@ -1,211 +0,0 @@ -package org.raddatz.familienarchiv.search; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.raddatz.familienarchiv.document.DocumentSearchResult; -import org.raddatz.familienarchiv.document.DocumentService; -import org.raddatz.familienarchiv.document.DocumentSort; -import org.raddatz.familienarchiv.document.SearchFilters; -import org.raddatz.familienarchiv.exception.DomainException; -import org.raddatz.familienarchiv.exception.ErrorCode; -import org.raddatz.familienarchiv.person.NameMatches; -import org.raddatz.familienarchiv.person.Person; -import org.raddatz.familienarchiv.person.PersonService; -import org.raddatz.familienarchiv.tag.Tag; -import org.raddatz.familienarchiv.tag.TagOperator; -import org.raddatz.familienarchiv.tag.TagService; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; - -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.UUID; - -@Service -@RequiredArgsConstructor -@Slf4j -public class NlQueryParserService { - - private static final int MIN_QUERY = 3; - private static final int MAX_QUERY = 500; - private static final int MAX_NAME_LENGTH = 200; - private static final int MIN_TAG_TERM = 3; - private static final int MAX_RESOLVED_TAGS = 10; - - private final NlpClient nlpClient; - private final PersonService personService; - private final DocumentService documentService; - private final TagService tagService; - - public NlSearchResponse search(String query, String lang, Pageable pageable) { - NlpExtraction ext = nlpClient.parse(query, lang); - - List personNames = ext.personNames() != null ? ext.personNames() : List.of(); - List keywords = ext.keywords() != null ? ext.keywords() : List.of(); - - TagResolution tagResolution = resolveTags(keywords); - List resolvedTagHints = tagResolution.hints(); - List resolvedTagNames = tagResolution.tagNames(); - List remainingKeywords = tagResolution.remaining(); - - NameResolution resolution = resolveNames(personNames); - - if (!resolution.ambiguous().isEmpty()) { - NlQueryInterpretation interpretation = new NlQueryInterpretation( - List.of(), resolution.ambiguous(), - ext.dateFrom(), ext.dateTo(), - keywords, List.of(), ext.rawQuery(), false, false); - return new NlSearchResponse(DocumentSearchResult.of(List.of()), interpretation); - } - - List resolved = resolution.resolved(); - List noMatchFragments = resolution.noMatchFragments(); - List extraFragments = resolution.extraFragments(); - - boolean hadStructuredMatch = !resolvedTagHints.isEmpty() || !resolved.isEmpty(); - String text = buildText(remainingKeywords, noMatchFragments, extraFragments, ext.rawQuery(), hadStructuredMatch); - - if (resolved.size() == 1 && isAnyRole(ext.personRole())) { - UUID personId = resolved.get(0).id(); - DocumentSearchResult docs = documentService.searchDocumentsByPersonId( - personId, ext.dateFrom(), ext.dateTo(), pageable); - NlQueryInterpretation interpretation = new NlQueryInterpretation( - resolved, List.of(), ext.dateFrom(), ext.dateTo(), keywords, resolvedTagHints, ext.rawQuery(), false, false); - return new NlSearchResponse(docs, interpretation); - } - - UUID sender = buildSender(resolved, ext.personRole()); - UUID receiver = buildReceiver(resolved, ext.personRole()); - - boolean tagsApplied = !resolvedTagHints.isEmpty(); - TagOperator tagOperator = tagsApplied ? TagOperator.OR : TagOperator.AND; - - SearchFilters filters = new SearchFilters( - text.isBlank() ? null : text, - ext.dateFrom(), ext.dateTo(), - sender, receiver, - resolvedTagNames, null, - null, tagOperator, false); - - DocumentSearchResult docs = documentService.searchDocuments(filters, DocumentSort.DATE, "desc", pageable); - boolean keywordsApplied = !text.isBlank(); - NlQueryInterpretation interpretation = new NlQueryInterpretation( - resolved, List.of(), ext.dateFrom(), ext.dateTo(), keywords, resolvedTagHints, ext.rawQuery(), keywordsApplied, tagsApplied); - return new NlSearchResponse(docs, interpretation); - } - - private NameResolution resolveNames(List personNames) { - List resolved = new ArrayList<>(); - List ambiguous = new ArrayList<>(); - List noMatchFragments = new ArrayList<>(); - List extraFragments = new ArrayList<>(); - - int resolvedIndex = 0; - for (String name : personNames) { - if (name == null || name.length() > MAX_NAME_LENGTH) { - log.debug("Skipping name fragment (too long or null): length={}", name == null ? 0 : name.length()); - continue; - } - NameMatches matches = personService.resolveByName(name); - List direct = matches.direct(); - List partial = matches.partial(); - - if (direct.size() == 1) { - Person p = direct.get(0); - resolvedIndex++; - if (resolvedIndex <= 2) { - resolved.add(new PersonHint(p.getId(), p.getDisplayName())); - } else { - extraFragments.add(name); - } - } else if (direct.size() >= 2) { - direct.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName()))); - } else if (!partial.isEmpty()) { - partial.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName()))); - } else { - noMatchFragments.add(name); - } - } - - return new NameResolution(resolved, ambiguous, noMatchFragments, extraFragments); - } - - private TagResolution resolveTags(List keywords) { - LinkedHashSet seen = new LinkedHashSet<>(); - List remaining = new ArrayList<>(); - - for (String kw : keywords) { - if (kw == null || kw.length() < MIN_TAG_TERM) { - remaining.add(kw); - continue; - } - List matches = tagService.findByNameContaining(kw); - if (matches.isEmpty()) { - remaining.add(kw); - } else { - seen.addAll(matches); - } - } - - if (seen.size() > MAX_RESOLVED_TAGS) { - log.debug("Keyword matched {} tags; capping at {}", seen.size(), MAX_RESOLVED_TAGS); - } - List capped = seen.size() > MAX_RESOLVED_TAGS - ? new ArrayList<>(seen).subList(0, MAX_RESOLVED_TAGS) - : new ArrayList<>(seen); - - // safe: entities are detached here; mutation is for DTO projection only, no dirty-check fires - tagService.resolveEffectiveColors(capped); - - List hints = capped.stream() - .map(t -> new TagHint(t.getId(), t.getName(), t.getColor())) - .toList(); - List tagNames = capped.stream().map(Tag::getName).toList(); - - return new TagResolution(hints, tagNames, remaining); - } - - private String buildText(List keywords, List noMatchFragments, - List extraFragments, String rawQuery, boolean hadStructuredMatch) { - List parts = new ArrayList<>(); - parts.addAll(keywords); - parts.addAll(noMatchFragments); - parts.addAll(extraFragments); - String text = String.join(" ", parts).strip(); - if (text.isBlank() && !hadStructuredMatch && rawQuery != null && !rawQuery.isBlank()) { - return rawQuery; - } - return text; - } - - private boolean isAnyRole(String role) { - return role == null || "any".equals(role) || (!"sender".equals(role) && !"receiver".equals(role)); - } - - private UUID buildSender(List resolved, String role) { - if (resolved.size() >= 2) return resolved.get(0).id(); - if (resolved.size() == 1 && "sender".equals(role)) return resolved.get(0).id(); - return null; - } - - private UUID buildReceiver(List resolved, String role) { - if (resolved.size() >= 2) return resolved.get(1).id(); - if (resolved.size() == 1 && "receiver".equals(role)) return resolved.get(0).id(); - return null; - } - - private record NameResolution( - List resolved, - List ambiguous, - List noMatchFragments, - List extraFragments - ) {} - - private record TagResolution( - List hints, - List tagNames, - List remaining - ) {} -} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchController.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchController.java deleted file mode 100644 index 82391a72..00000000 --- a/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchController.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.raddatz.familienarchiv.search; - -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.raddatz.familienarchiv.security.Permission; -import org.raddatz.familienarchiv.security.RequirePermission; -import org.springframework.data.domain.Pageable; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/api/search/nl") -@RequiredArgsConstructor -public class NlSearchController { - - private final NlQueryParserService nlQueryParserService; - private final NlSearchRateLimiter rateLimiter; - - @PostMapping - @RequirePermission(Permission.READ_ALL) - public NlSearchResponse search(@Valid @RequestBody NlSearchRequest request, - Pageable pageable, - @AuthenticationPrincipal UserDetails principal) { - rateLimiter.checkAndConsume(principal.getUsername()); - return nlQueryParserService.search(request.query(), request.lang(), pageable); - } -} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchRateLimitProperties.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchRateLimitProperties.java deleted file mode 100644 index 3be4b3b0..00000000 --- a/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchRateLimitProperties.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.raddatz.familienarchiv.search; - -import lombok.Data; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -@Component -@ConfigurationProperties("app.nl-search.rate-limit") -@Data -public class NlSearchRateLimitProperties { - private int maxRequestsPerMinute = 20; -} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchRateLimiter.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchRateLimiter.java deleted file mode 100644 index 100296fa..00000000 --- a/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchRateLimiter.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.raddatz.familienarchiv.search; - -import com.github.benmanes.caffeine.cache.Caffeine; -import com.github.benmanes.caffeine.cache.LoadingCache; -import io.github.bucket4j.Bandwidth; -import io.github.bucket4j.Bucket; -import org.raddatz.familienarchiv.exception.DomainException; -import org.raddatz.familienarchiv.exception.ErrorCode; -import org.springframework.stereotype.Service; - -import java.time.Duration; -import java.util.concurrent.TimeUnit; - -@Service -public class NlSearchRateLimiter { - - private final LoadingCache byUser; - private final int maxRequestsPerMinute; - - public NlSearchRateLimiter(NlSearchRateLimitProperties props) { - this.maxRequestsPerMinute = props.getMaxRequestsPerMinute(); - this.byUser = Caffeine.newBuilder() - .expireAfterAccess(1, TimeUnit.MINUTES) - .build(key -> newBucket(maxRequestsPerMinute)); - } - - public void checkAndConsume(String userKey) { - if (!byUser.get(userKey).tryConsume(1)) { - throw DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_RATE_LIMITED, - "NL search rate limit exceeded for user: " + userKey, 60L); - } - } - - void resetForTest() { - byUser.invalidateAll(); - } - - private static Bucket newBucket(int limit) { - return Bucket.builder() - .addLimit(Bandwidth.builder() - .capacity(limit) - .refillGreedy(limit, Duration.ofMinutes(1)) - .build()) - .build(); - } -} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchRequest.java deleted file mode 100644 index f23241d0..00000000 --- a/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package org.raddatz.familienarchiv.search; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; - -public record NlSearchRequest( - @NotBlank - @Size(min = 3, max = 500) - String query, - @NotBlank - @Pattern(regexp = "de|en|es") - String lang -) { -} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchResponse.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchResponse.java deleted file mode 100644 index 04e51bff..00000000 --- a/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.raddatz.familienarchiv.search; - -import io.swagger.v3.oas.annotations.media.Schema; -import org.raddatz.familienarchiv.document.DocumentSearchResult; - -public record NlSearchResponse( - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - DocumentSearchResult result, - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - NlQueryInterpretation interpretation -) { -} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlpClient.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlpClient.java deleted file mode 100644 index c7889c7e..00000000 --- a/backend/src/main/java/org/raddatz/familienarchiv/search/NlpClient.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.raddatz.familienarchiv.search; - -public interface NlpClient { - NlpExtraction parse(String query, String lang); -} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlpExtraction.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlpExtraction.java deleted file mode 100644 index 73c36027..00000000 --- a/backend/src/main/java/org/raddatz/familienarchiv/search/NlpExtraction.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.raddatz.familienarchiv.search; - -import java.time.LocalDate; -import java.util.List; - -record NlpExtraction( - List personNames, - String personRole, - LocalDate dateFrom, - LocalDate dateTo, - List keywords, - String rawQuery -) { -} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlpHealthClient.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlpHealthClient.java deleted file mode 100644 index a02475c2..00000000 --- a/backend/src/main/java/org/raddatz/familienarchiv/search/NlpHealthClient.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.raddatz.familienarchiv.search; - -public interface NlpHealthClient { - boolean isHealthy(); -} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlpProperties.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlpProperties.java deleted file mode 100644 index 8b939e1e..00000000 --- a/backend/src/main/java/org/raddatz/familienarchiv/search/NlpProperties.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.raddatz.familienarchiv.search; - -import jakarta.validation.constraints.NotBlank; -import lombok.Data; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.validation.annotation.Validated; - -@ConfigurationProperties("app.nlp") -@Data -@Validated -public class NlpProperties { - @NotBlank - private String baseUrl; - private int timeoutSeconds = 5; - private int healthCheckTimeoutSeconds = 2; -} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/PersonHint.java b/backend/src/main/java/org/raddatz/familienarchiv/search/PersonHint.java deleted file mode 100644 index 61a0e0b9..00000000 --- a/backend/src/main/java/org/raddatz/familienarchiv/search/PersonHint.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.raddatz.familienarchiv.search; - -import io.swagger.v3.oas.annotations.media.Schema; - -import java.util.UUID; - -public record PersonHint( - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - UUID id, - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - String displayName -) { -} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/RestClientNlpClient.java b/backend/src/main/java/org/raddatz/familienarchiv/search/RestClientNlpClient.java deleted file mode 100644 index d058e470..00000000 --- a/backend/src/main/java/org/raddatz/familienarchiv/search/RestClientNlpClient.java +++ /dev/null @@ -1,145 +0,0 @@ -package org.raddatz.familienarchiv.search; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.extern.slf4j.Slf4j; -import org.raddatz.familienarchiv.exception.DomainException; -import org.raddatz.familienarchiv.exception.ErrorCode; -import org.springframework.http.MediaType; -import org.springframework.http.client.JdkClientHttpRequestFactory; -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestClient; - -import java.net.http.HttpClient; -import java.time.Duration; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; -import java.util.List; -import java.util.Set; - -@Service -@Slf4j -public class RestClientNlpClient implements NlpClient, NlpHealthClient { - - private static final Set VALID_ROLES = Set.of("sender", "receiver", "any"); - private static final int MAX_NAME_LENGTH = 200; - private static final int MAX_KEYWORD_LENGTH = 100; - - private final RestClient parseClient; - private final RestClient healthRestClient; - - public RestClientNlpClient(NlpProperties props) { - HttpClient parseHttp = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_1_1) - .connectTimeout(Duration.ofSeconds(10)) - .build(); - JdkClientHttpRequestFactory parseFactory = new JdkClientHttpRequestFactory(parseHttp); - parseFactory.setReadTimeout(Duration.ofSeconds(props.getTimeoutSeconds())); - this.parseClient = RestClient.builder() - .baseUrl(props.getBaseUrl()) - .requestFactory(parseFactory) - .build(); - - HttpClient healthHttp = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_1_1) - .connectTimeout(Duration.ofSeconds(props.getHealthCheckTimeoutSeconds())) - .build(); - JdkClientHttpRequestFactory healthFactory = new JdkClientHttpRequestFactory(healthHttp); - healthFactory.setReadTimeout(Duration.ofSeconds(props.getHealthCheckTimeoutSeconds())); - this.healthRestClient = RestClient.builder() - .baseUrl(props.getBaseUrl()) - .requestFactory(healthFactory) - .build(); - } - - @Override - public NlpExtraction parse(String query, String lang) { - try { - NlpParseRequest request = new NlpParseRequest(query, lang); - NlpParseResponse response = parseClient.post() - .uri("/parse") - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .retrieve() - .body(NlpParseResponse.class); - return toExtraction(response, query); - } catch (DomainException e) { - throw e; - } catch (Exception e) { - log.warn("NLP service inference failed: {}", e.getClass().getSimpleName()); - throw DomainException.serviceUnavailable(ErrorCode.SMART_SEARCH_UNAVAILABLE, - "NLP service unavailable: " + e.getClass().getSimpleName()); - } - } - - @Override - public boolean isHealthy() { - try { - NlpHealthResponse resp = healthRestClient.get() - .uri("/health") - .retrieve() - .body(NlpHealthResponse.class); - return resp != null && resp.personsLoaded() > 0; - } catch (Exception e) { - return false; - } - } - - private NlpExtraction toExtraction(NlpParseResponse response, String rawQuery) { - if (response == null) { - return fallbackExtraction(rawQuery); - } - List names = response.personNames() == null ? List.of() : response.personNames().stream() - .filter(n -> n != null && n.length() <= MAX_NAME_LENGTH) - .toList(); - List keywords = response.keywords() == null ? List.of() : response.keywords().stream() - .filter(k -> k != null && k.length() <= MAX_KEYWORD_LENGTH) - .toList(); - String role = sanitiseRole(response.personRole()); - LocalDate dateFrom = parseDate(response.dateFrom()); - LocalDate dateTo = parseDate(response.dateTo()); - return new NlpExtraction(names, role, dateFrom, dateTo, keywords, rawQuery); - } - - private NlpExtraction fallbackExtraction(String rawQuery) { - return new NlpExtraction(List.of(), "any", null, null, List.of(), rawQuery); - } - - private String sanitiseRole(String role) { - if (role != null && VALID_ROLES.contains(role)) { - return role; - } - log.warn("Unexpected personRole from NLP service: {}", role); - return "any"; - } - - private LocalDate parseDate(String raw) { - if (raw == null || raw.isBlank()) return null; - try { - return LocalDate.parse(raw, DateTimeFormatter.ISO_LOCAL_DATE); - } catch (DateTimeParseException ignored) { - } - return null; - } - - @JsonIgnoreProperties(ignoreUnknown = true) - private record NlpParseRequest(String query, String lang) { - } - - @JsonIgnoreProperties(ignoreUnknown = true) - private record NlpParseResponse( - @JsonProperty("personNames") List personNames, - @JsonProperty("personRole") String personRole, - @JsonProperty("dateFrom") String dateFrom, - @JsonProperty("dateTo") String dateTo, - @JsonProperty("keywords") List keywords - ) { - } - - @JsonIgnoreProperties(ignoreUnknown = true) - private record NlpHealthResponse( - @JsonProperty("persons_loaded") int personsLoaded - ) { - } -} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/TagHint.java b/backend/src/main/java/org/raddatz/familienarchiv/search/TagHint.java deleted file mode 100644 index c488796f..00000000 --- a/backend/src/main/java/org/raddatz/familienarchiv/search/TagHint.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.raddatz.familienarchiv.search; - -import io.swagger.v3.oas.annotations.media.Schema; - -import java.util.UUID; - -public record TagHint( - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - UUID id, - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - String name, - String color -) { -} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java deleted file mode 100644 index 76f8e88c..00000000 --- a/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java +++ /dev/null @@ -1,711 +0,0 @@ -package org.raddatz.familienarchiv.search; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.raddatz.familienarchiv.document.DocumentSearchResult; -import org.raddatz.familienarchiv.document.DocumentService; -import org.raddatz.familienarchiv.document.DocumentSort; -import org.raddatz.familienarchiv.document.SearchFilters; -import org.raddatz.familienarchiv.exception.DomainException; -import org.raddatz.familienarchiv.exception.ErrorCode; -import org.raddatz.familienarchiv.person.NameMatches; -import org.raddatz.familienarchiv.person.Person; -import org.raddatz.familienarchiv.person.PersonService; -import org.raddatz.familienarchiv.tag.Tag; -import org.raddatz.familienarchiv.tag.TagOperator; -import org.raddatz.familienarchiv.tag.TagService; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; - -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -class NlQueryParserServiceTest { - - @Mock NlpClient nlpClient; - @Mock PersonService personService; - @Mock DocumentService documentService; - @Mock TagService tagService; - - NlQueryParserService service; - - static final Pageable PAGE = PageRequest.of(0, 20); - - @BeforeEach - void setUp() { - MockitoAnnotations.openMocks(this); - service = new NlQueryParserService(nlpClient, personService, documentService, tagService); - when(documentService.searchDocuments(any(), any(), any(), any())) - .thenReturn(DocumentSearchResult.of(List.of())); - when(documentService.searchDocumentsByPersonId(any(), any(), any(), any())) - .thenReturn(DocumentSearchResult.of(List.of())); - when(tagService.findByNameContaining(anyString())).thenReturn(List.of()); - } - - // --- Factory helpers --- - - private NlpExtraction extraction(List names, String role, LocalDate from, LocalDate to, - List keywords) { - String raw = names.isEmpty() ? "test query" : String.join(" ", names); - return new NlpExtraction(names, role, from, to, keywords, raw); - } - - private Person person(UUID id, String firstName, String lastName) { - return Person.builder().id(id).firstName(firstName).lastName(lastName).build(); - } - - private NameMatches makeNameMatches() { - return new NameMatches(List.of(), List.of()); - } - - private NameMatches makeNameMatches(List direct) { - return new NameMatches(direct, List.of()); - } - - private NameMatches makeNameMatches(List direct, List partial) { - return new NameMatches(direct, partial); - } - - private static final UUID P1 = UUID.fromString("00000000-0000-0000-0000-000000000001"); - private static final UUID P2 = UUID.fromString("00000000-0000-0000-0000-000000000002"); - private static final UUID P3 = UUID.fromString("00000000-0000-0000-0000-000000000003"); - - // --- 1. Single resolved name + personRole=sender --- - - @Test - void search_resolvesSingleName_asSender() { - Person walter = person(P1, "Walter", "Raddatz"); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of())); - when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter))); - - NlSearchResponse resp = service.search("Was hat Walter geschrieben?", "de", PAGE); - - verify(nlpClient).parse(eq("Was hat Walter geschrieben?"), eq("de")); - ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); - verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE)); - assertThat(cap.getValue().sender()).isEqualTo(P1); - assertThat(cap.getValue().receiver()).isNull(); - assertThat(resp.interpretation().resolvedPersons()).hasSize(1); - assertThat(resp.interpretation().resolvedPersons().get(0).id()).isEqualTo(P1); - assertThat(resp.interpretation().ambiguousPersons()).isEmpty(); - } - - // --- 2. Multi-match name → ambiguous, search NOT executed --- - - @Test - void search_multiMatchName_populatesAmbiguous_andSkipsSearch() { - Person a = person(UUID.randomUUID(), "Walter", "Braun"); - Person b = person(UUID.randomUUID(), "Walter", "Schmidt"); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of())); - when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(a, b))); - - NlSearchResponse resp = service.search("Briefe von Walter", "de", PAGE); - - verify(documentService, never()).searchDocuments(any(), any(), any(), any()); - verify(documentService, never()).searchDocumentsByPersonId(any(), any(), any(), any()); - assertThat(resp.interpretation().ambiguousPersons()).hasSize(2); - assertThat(resp.interpretation().resolvedPersons()).isEmpty(); - } - - // --- 3. Multi-match + personRole=any → still ambiguous, search NOT executed --- - - @Test - void search_multiMatchName_withPersonRoleAny_stillSkipsSearch() { - Person a = person(UUID.randomUUID(), "Emma", "Braun"); - Person b = person(UUID.randomUUID(), "Emma", "Raddatz"); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of("Emma"), "any", null, null, List.of())); - when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(a, b))); - - NlSearchResponse resp = service.search("Briefe an Emma", "de", PAGE); - - verify(documentService, never()).searchDocuments(any(), any(), any(), any()); - verify(documentService, never()).searchDocumentsByPersonId(any(), any(), any(), any()); - assertThat(resp.interpretation().ambiguousPersons()).hasSize(2); - } - - // --- 4. No-match name → folded into text --- - - @Test - void search_noMatchName_isFoldedIntoText() { - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of("Karl"), "any", null, null, List.of())); - when(personService.resolveByName("Karl")).thenReturn(makeNameMatches()); - - service.search("Briefe von Karl", "de", PAGE); - - ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); - verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); - assertThat(cap.getValue().text()).contains("Karl"); - assertThat(cap.getValue().sender()).isNull(); - assertThat(cap.getValue().receiver()).isNull(); - } - - // --- 5. personRole=any + 1 resolved → searchDocumentsByPersonId called --- - - @Test - void search_personRoleAny_singleMatch_callsSearchDocumentsByPersonId() { - Person walter = person(P1, "Walter", "Raddatz"); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of("Walter"), "any", null, null, List.of())); - when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter))); - - NlSearchResponse resp = service.search("Briefe von Walter", "de", PAGE); - - verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE)); - verify(documentService, never()).searchDocuments(any(), any(), any(), any()); - assertThat(resp.interpretation().keywordsApplied()).isFalse(); - } - - // --- 6. 2 names both resolve → sender=person1, receiver=person2 --- - - @Test - void search_twoNamesResolve_assignsSenderAndReceiver() { - Person walter = person(P1, "Walter", "Raddatz"); - Person emma = person(P2, "Emma", "Raddatz"); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of("Walter", "Emma"), "any", null, null, List.of())); - when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter))); - when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma))); - - NlSearchResponse resp = service.search("Briefe von Walter an Emma", "de", PAGE); - - ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); - verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE)); - assertThat(cap.getValue().sender()).isEqualTo(P1); - assertThat(cap.getValue().receiver()).isEqualTo(P2); - assertThat(resp.interpretation().resolvedPersons().get(0).id()).isEqualTo(P1); - assertThat(resp.interpretation().resolvedPersons().get(1).id()).isEqualTo(P2); - } - - // --- 7. 2 names, first resolves, second ambiguous → search NOT executed --- - - @Test - void search_twoNames_secondAmbiguous_skipsSearch() { - Person walter = person(P1, "Walter", "Raddatz"); - Person emma1 = person(P2, "Emma", "Braun"); - Person emma2 = person(P3, "Emma", "Schmidt"); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of("Walter", "Emma"), "sender", null, null, List.of())); - when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter))); - when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma1, emma2))); - - NlSearchResponse resp = service.search("Briefe von Walter an Emma", "de", PAGE); - - verify(documentService, never()).searchDocuments(any(), any(), any(), any()); - assertThat(resp.interpretation().ambiguousPersons()).hasSize(2); - } - - // --- 8. 2 names, first no match → folded into text, second used as single person --- - - @Test - void search_twoNames_firstNoMatch_secondResolved_foldFirstIntoText() { - Person emma = person(P2, "Emma", "Raddatz"); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of("Karl", "Emma"), "sender", null, null, List.of())); - when(personService.resolveByName("Karl")).thenReturn(makeNameMatches()); - when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma))); - - service.search("Briefe von Karl an Emma", "de", PAGE); - - ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); - verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); - assertThat(cap.getValue().text()).contains("Karl"); - assertThat(cap.getValue().sender()).isEqualTo(P2); - } - - // --- 9. 3+ names all resolve → first two as sender/receiver, third folded into text --- - - @Test - void search_threeNamesResolve_extraFoldedIntoText() { - Person walter = person(P1, "Walter", "Raddatz"); - Person emma = person(P2, "Emma", "Raddatz"); - Person heinrich = person(P3, "Heinrich", "Braun"); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of("Walter", "Emma", "Heinrich"), "any", null, null, List.of())); - when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter))); - when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma))); - when(personService.resolveByName("Heinrich")).thenReturn(makeNameMatches(List.of(heinrich))); - - service.search("Briefe von Walter an Emma über Heinrich", "de", PAGE); - - ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); - verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); - assertThat(cap.getValue().sender()).isEqualTo(P1); - assertThat(cap.getValue().receiver()).isEqualTo(P2); - assertThat(cap.getValue().text()).contains("Heinrich"); - } - - // --- 10. Keywords space-joined into text --- - - @Test - void search_keywords_areJoinedIntoText() { - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of(), "any", null, null, List.of("Krieg", "Walter"))); - - service.search("Dokumente über den Krieg Walter", "de", PAGE); - - ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); - verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); - assertThat(cap.getValue().text()).isEqualTo("Krieg Walter"); - } - - // --- 11. Date range passed through --- - - @Test - void search_dateRange_passedIntoSearchFilters() { - LocalDate from = LocalDate.of(1914, 1, 1); - LocalDate to = LocalDate.of(1914, 12, 31); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of(), "any", from, to, List.of())); - - service.search("Briefe aus dem Jahr 1914", "de", PAGE); - - ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); - verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); - assertThat(cap.getValue().from()).isEqualTo(from); - assertThat(cap.getValue().to()).isEqualTo(to); - } - - // --- 12. Null dates → null in SearchFilters (not an error) --- - - @Test - void search_nullDates_passedAsNullIntoFilters() { - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit"))); - - service.search("Hochzeitsbriefe", "de", PAGE); - - ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); - verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); - assertThat(cap.getValue().from()).isNull(); - assertThat(cap.getValue().to()).isNull(); - } - - // --- 13. NLP service returns empty names/keywords → raw query used as keyword fallback --- - - @Test - void search_nlpReturnsEmpty_usesRawQueryAsTextFallback() { - String raw = "Briefe aus dem Krieg"; - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(new NlpExtraction(List.of(), "any", null, null, List.of(), raw)); - - service.search(raw, "de", PAGE); - - ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); - verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); - assertThat(cap.getValue().text()).isEqualTo(raw); - } - - // --- 14. Null personNames/keywords → no NPE --- - - @Test - void search_nullPersonNamesAndKeywords_handledWithoutNpe() { - NlpExtraction ext = new NlpExtraction(null, "any", null, null, null, "test query"); - when(nlpClient.parse(anyString(), anyString())).thenReturn(ext); - - NlSearchResponse resp = service.search("test query", "de", PAGE); - - assertThat(resp).isNotNull(); - verify(documentService).searchDocuments(any(), any(), any(), any()); - } - - // --- 15. Unrecognized personRole → defaults to any-like behavior (no crash) --- - - @Test - void search_unrecognizedPersonRole_treatedLikeAny_withSingleResolvedPerson() { - Person walter = person(P1, "Walter", "Raddatz"); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(new NlpExtraction(List.of("Walter"), "unknown_role", null, null, List.of(), "query")); - when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter))); - - NlSearchResponse resp = service.search("Briefe von Walter", "de", PAGE); - - assertThat(resp).isNotNull(); - } - - // --- 16. NLP service throws SMART_SEARCH_UNAVAILABLE → propagates to caller --- - - @Test - void search_nlpThrowsUnavailable_propagates() { - when(nlpClient.parse(anyString(), anyString())) - .thenThrow(DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_UNAVAILABLE, "offline")); - - assertThatThrownBy(() -> service.search("Was hat Walter geschrieben?", "de", PAGE)) - .isInstanceOf(DomainException.class) - .extracting(e -> ((DomainException) e).getCode()) - .isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE); - } - - // --- 17. LLM-extracted name > 200 chars → skipped, PersonService never called --- - - @Test - void search_nameLongerThan200Chars_isSkippedBeforePersonServiceCall() { - String longName = "A".repeat(201); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of(longName), "sender", null, null, List.of())); - - service.search("Briefe von sehr langem Namen", "de", PAGE); - - verify(personService, never()).resolveByName(anyString()); - } - - // --- 18. Cap: 10 direct matches → all shown as ambiguous --- - - @Test - void search_tenDirectMatches_allShownAsAmbiguous() { - List ten = new ArrayList<>(); - for (int i = 0; i < 10; i++) { - ten.add(person(UUID.randomUUID(), "Walter", "Person" + i)); - } - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of())); - when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(ten)); - - NlSearchResponse resp = service.search("Briefe von Walter", "de", PAGE); - - assertThat(resp.interpretation().ambiguousPersons()).hasSize(10); - verify(documentService, never()).searchDocuments(any(), any(), any(), any()); - } - - // --- 19. SearchFilters defaults: tagOperator=AND, status=null, undated=false, tags=empty --- - - @Test - void search_searchFiltersDefaults_areCorrect() { - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of(), "any", null, null, List.of("Krieg"))); - - service.search("Dokumente über den Krieg", "de", PAGE); - - ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); - verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE)); - SearchFilters f = cap.getValue(); - assertThat(f.tagOperator()).isEqualTo(TagOperator.AND); - assertThat(f.status()).isNull(); - assertThat(f.undated()).isFalse(); - assertThat(f.tags()).isEmpty(); - assertThat(f.tagQ()).isNull(); - } - - // --- 20. personRole=receiver + 1 resolved → receiver UUID set --- - - @Test - void search_personRoleReceiver_singleMatch_setsReceiver() { - Person emma = person(P2, "Emma", "Raddatz"); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of("Emma"), "receiver", null, null, List.of())); - when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma))); - - service.search("Briefe an Emma", "de", PAGE); - - ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); - verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); - assertThat(cap.getValue().receiver()).isEqualTo(P2); - assertThat(cap.getValue().sender()).isNull(); - } - - // --- 21. keywordsApplied=true when text is non-blank --- - - @Test - void search_keywordsApplied_trueWhenTextNonBlank() { - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of(), "any", null, null, List.of("Feldpost"))); - - NlSearchResponse resp = service.search("Feldpost aus dem Krieg", "de", PAGE); - - assertThat(resp.interpretation().keywordsApplied()).isTrue(); - } - - // --- 22. Partial-only, one candidate → ambiguous (1-item picker), search skipped --- - - @Test - void search_partialOnly_oneCandidate_populatesAmbiguous() { - Person cramer = person(P1, "Clara", "Cramer"); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of("Clara Cram"), "any", null, null, List.of())); - when(personService.resolveByName("Clara Cram")).thenReturn(makeNameMatches(List.of(), List.of(cramer))); - - NlSearchResponse resp = service.search("Briefe von Clara Cram", "de", PAGE); - - assertThat(resp.interpretation().ambiguousPersons()).hasSize(1); - verify(documentService, never()).searchDocuments(any(), any(), any(), any()); - } - - // --- 23. Partial-only, two candidates → ambiguous (multi-item picker) --- - - @Test - void search_partialOnly_twoCandidates_populatesAmbiguous() { - Person cramer = person(P1, "Clara", "Cramer"); - Person crammond = person(P2, "Clara", "Crammond"); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of("Clara Cram"), "any", null, null, List.of())); - when(personService.resolveByName("Clara Cram")) - .thenReturn(makeNameMatches(List.of(), List.of(cramer, crammond))); - - NlSearchResponse resp = service.search("Briefe von Clara Cram", "de", PAGE); - - assertThat(resp.interpretation().ambiguousPersons()).hasSize(2); - } - - // --- 24. Exactly one direct match → search executes, no picker --- - - @Test - void search_oneDirect_executesSearch() { - Person clara = person(P1, "Clara", "Cram"); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of("Clara Cram"), "any", null, null, List.of())); - when(personService.resolveByName("Clara Cram")).thenReturn(makeNameMatches(List.of(clara))); - - NlSearchResponse resp = service.search("Briefe von Clara Cram", "de", PAGE); - - verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE)); - assertThat(resp.interpretation().ambiguousPersons()).isEmpty(); - } - - // --- Tag resolution helpers --- - - private Tag tag(UUID id, String name) { - return Tag.builder().id(id).name(name).build(); - } - - private Tag tag(UUID id, String name, String color) { - return Tag.builder().id(id).name(name).color(color).build(); - } - - private TagHint tagHint(UUID id, String name, String color) { - return new TagHint(id, name, color); - } - - private static final UUID T1 = UUID.fromString("00000000-0000-0000-0001-000000000001"); - - // --- 25. Single keyword resolves to one tag → tag filter applied --- - - @Test - void search_singleKeywordResolvesToTag_appliesTagFilter() { - Tag hochzeit = tag(T1, "Hochzeit"); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit"))); - when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit)); - - NlSearchResponse resp = service.search("Briefe über Hochzeit", "de", PAGE); - - ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); - verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); - assertThat(cap.getValue().tags()).containsExactly("Hochzeit"); - assertThat(cap.getValue().tagOperator()).isEqualTo(TagOperator.OR); - assertThat(resp.interpretation().resolvedTags()).hasSize(1); - assertThat(resp.interpretation().resolvedTags().get(0).name()).isEqualTo("Hochzeit"); - assertThat(resp.interpretation().tagsApplied()).isTrue(); - assertThat(cap.getValue().text()).isNull(); - } - - private static final UUID T2 = UUID.fromString("00000000-0000-0000-0001-000000000002"); - - // --- 26. Keyword matches multiple tags → all in resolvedTags, OR-union --- - - @Test - void search_keywordMatchesMultipleTags_allIncluded() { - Tag hochzeit1 = tag(T1, "Hochzeit Raddatz"); - Tag hochzeit2 = tag(T2, "Hochzeit Braun"); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit"))); - when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit1, hochzeit2)); - - NlSearchResponse resp = service.search("Briefe über Hochzeit", "de", PAGE); - - ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); - verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); - assertThat(cap.getValue().tags()).containsExactlyInAnyOrder("Hochzeit Raddatz", "Hochzeit Braun"); - assertThat(cap.getValue().tagOperator()).isEqualTo(TagOperator.OR); - assertThat(resp.interpretation().resolvedTags()).hasSize(2); - } - - // --- 27. Keyword no tag match → stays as FTS text, resolvedTags empty --- - - @Test - void search_keywordNoTagMatch_staysAsFtsText() { - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of(), "any", null, null, List.of("Feldpost"))); - - NlSearchResponse resp = service.search("Feldpost Briefe", "de", PAGE); - - ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); - verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); - assertThat(cap.getValue().text()).contains("Feldpost"); - assertThat(cap.getValue().tags()).isEmpty(); - assertThat(resp.interpretation().resolvedTags()).isEmpty(); - assertThat(resp.interpretation().tagsApplied()).isFalse(); - } - - // --- 28. Mixed: one keyword resolves, one doesn't → tag filter + FTS text --- - - @Test - void search_mixedKeywords_oneResolves_oneStaysAsText() { - Tag hochzeit = tag(T1, "Hochzeit"); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit", "Feldpost"))); - when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit)); - - NlSearchResponse resp = service.search("Hochzeit und Feldpost", "de", PAGE); - - ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); - verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); - assertThat(cap.getValue().tags()).containsExactly("Hochzeit"); - assertThat(cap.getValue().tagOperator()).isEqualTo(TagOperator.OR); - assertThat(cap.getValue().text()).contains("Feldpost"); - assertThat(resp.interpretation().resolvedTags()).hasSize(1); - assertThat(resp.interpretation().tagsApplied()).isTrue(); - } - - // --- 29. personRole=any + 1 person + resolvable keyword → personId search, tagsApplied=false --- - - @Test - void search_personRoleAny_singlePerson_resolvableKeyword_tagsAppliedFalse() { - Person walter = person(P1, "Walter", "Raddatz"); - Tag hochzeit = tag(T1, "Hochzeit"); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of("Walter"), "any", null, null, List.of("Hochzeit"))); - when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter))); - when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit)); - - NlSearchResponse resp = service.search("Briefe von Walter über Hochzeit", "de", PAGE); - - verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE)); - verify(documentService, never()).searchDocuments(any(), any(), any(), any()); - assertThat(resp.interpretation().tagsApplied()).isFalse(); - assertThat(resp.interpretation().resolvedTags()).hasSize(1); - assertThat(resp.interpretation().resolvedTags().get(0).name()).isEqualTo("Hochzeit"); - } - - // --- 30. Cap: keyword matches > 10 tags → capped at 10 --- - - @Test - void search_keywordMatchesMoreThanMaxTags_cappedAtTen() { - List eleven = new ArrayList<>(); - for (int i = 0; i < 11; i++) { - eleven.add(tag(UUID.randomUUID(), "Thema " + i)); - } - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of(), "any", null, null, List.of("Thema"))); - when(tagService.findByNameContaining("Thema")).thenReturn(eleven); - - NlSearchResponse resp = service.search("Dokumente zum Thema", "de", PAGE); - - assertThat(resp.interpretation().resolvedTags()).hasSize(10); - ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); - verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); - assertThat(cap.getValue().tags()).hasSize(10); - } - - // --- 31. Short keyword (< 3 chars) → skipped, not passed to TagService --- - - @Test - void search_shortKeyword_skippedByTagResolution() { - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of(), "any", null, null, List.of("ab", "Krieg"))); - - service.search("ab Krieg", "de", PAGE); - - verify(tagService, never()).findByNameContaining("ab"); - verify(tagService).findByNameContaining("Krieg"); - ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); - verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); - assertThat(cap.getValue().text()).contains("ab"); - } - - // --- 32. Dedup: same tag matched by two keywords → appears once --- - - @Test - void search_sameTagMatchedByTwoKeywords_deduplicatedToOne() { - Tag hochzeit = tag(T1, "Hochzeit"); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit", "hoch"))); - when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit)); - when(tagService.findByNameContaining("hoch")).thenReturn(List.of(hochzeit)); - - NlSearchResponse resp = service.search("Hochzeit hoch", "de", PAGE); - - assertThat(resp.interpretation().resolvedTags()).hasSize(1); - ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); - verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); - assertThat(cap.getValue().tags()).hasSize(1); - } - - // --- 33. All keywords resolve → rawQuery fallback suppressed, text=null --- - - @Test - void search_allKeywordsResolveToTags_rawQueryFallbackSuppressed() { - Tag hochzeit = tag(T1, "Hochzeit"); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(new NlpExtraction(List.of(), "any", null, null, List.of("Hochzeit"), "raw query text")); - when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit)); - - NlSearchResponse resp = service.search("Hochzeit", "de", PAGE); - - ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); - verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); - assertThat(cap.getValue().text()).isNull(); - assertThat(cap.getValue().tags()).containsExactly("Hochzeit"); - } - - // --- 34. Flag independence: keywordsApplied=false AND tagsApplied=true --- - - @Test - void search_allKeywordsResolveToTags_keywordsAppliedFalse_tagsAppliedTrue() { - Tag hochzeit = tag(T1, "Hochzeit"); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit"))); - when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit)); - - NlSearchResponse resp = service.search("Hochzeit Briefe", "de", PAGE); - - assertThat(resp.interpretation().keywordsApplied()).isFalse(); - assertThat(resp.interpretation().tagsApplied()).isTrue(); - } - - // --- 35. Color carried through from resolveEffectiveColors --- - - @Test - void search_tagHint_carriesColorSetByResolveEffectiveColors() { - Tag hochzeit = tag(T1, "Hochzeit"); - doAnswer(invocation -> { - Collection tags = invocation.getArgument(0); - tags.forEach(t -> t.setColor("sage")); - return null; - }).when(tagService).resolveEffectiveColors(any()); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit"))); - when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit)); - - NlSearchResponse resp = service.search("Hochzeit", "de", PAGE); - - assertThat(resp.interpretation().resolvedTags().get(0).color()).isEqualTo("sage"); - } - - // --- 36. Color stays null when resolveEffectiveColors leaves it unset --- - - @Test - void search_tagHint_colorIsNull_whenNoColorResolved() { - Tag hochzeit = tag(T1, "Hochzeit"); - when(nlpClient.parse(anyString(), anyString())) - .thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit"))); - when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit)); - - NlSearchResponse resp = service.search("Hochzeit", "de", PAGE); - - assertThat(resp.interpretation().resolvedTags().get(0).color()).isNull(); - } -} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchControllerTest.java deleted file mode 100644 index 8630a2cb..00000000 --- a/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchControllerTest.java +++ /dev/null @@ -1,161 +0,0 @@ -package org.raddatz.familienarchiv.search; - -import tools.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.raddatz.familienarchiv.document.DocumentSearchResult; -import org.raddatz.familienarchiv.exception.DomainException; -import org.raddatz.familienarchiv.exception.ErrorCode; -import org.raddatz.familienarchiv.security.SecurityConfig; -import org.raddatz.familienarchiv.security.PermissionAspect; -import org.raddatz.familienarchiv.user.CustomUserDetailsService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; -import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; -import org.springframework.context.annotation.Import; -import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -import java.util.List; -import java.util.UUID; - -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@WebMvcTest(NlSearchController.class) -@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class, - NlSearchRateLimiter.class, NlSearchRateLimitProperties.class}) -class NlSearchControllerTest { - - @Autowired MockMvc mockMvc; - private final ObjectMapper objectMapper = new ObjectMapper(); - - @MockitoBean NlQueryParserService nlQueryParserService; - @MockitoBean CustomUserDetailsService customUserDetailsService; - @Autowired NlSearchRateLimiter rateLimiter; - - @BeforeEach - void resetRateLimiter() { - rateLimiter.resetForTest(); - } - - private NlSearchResponse makeResponse() { - PersonHint hint = new PersonHint(UUID.randomUUID(), "Walter Raddatz"); - NlQueryInterpretation interp = new NlQueryInterpretation( - List.of(hint), List.of(), null, null, - List.of("Krieg"), List.of(), "Briefe von Walter im Krieg", true, false); - return new NlSearchResponse(DocumentSearchResult.of(List.of()), interp); - } - - // --- 1. Happy path --- - - @Test - @WithMockUser(username = "user@test.com", authorities = {"READ_ALL"}) - void search_returns200_withNlSearchResponse() throws Exception { - when(nlQueryParserService.search(anyString(), anyString(), any())).thenReturn(makeResponse()); - - mockMvc.perform(post("/api/search/nl").with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"query\":\"Briefe von Walter im Krieg\",\"lang\":\"de\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.interpretation.rawQuery").value("Briefe von Walter im Krieg")) - .andExpect(jsonPath("$.interpretation.resolvedPersons[0].displayName").value("Walter Raddatz")) - .andExpect(jsonPath("$.interpretation.keywordsApplied").value(true)); - } - - // --- 2. ambiguousPersons in response shape --- - - @Test - @WithMockUser(username = "user@test.com", authorities = {"READ_ALL"}) - void search_returns200_withAmbiguousPersons() throws Exception { - PersonHint a = new PersonHint(UUID.randomUUID(), "Walter Braun"); - PersonHint b = new PersonHint(UUID.randomUUID(), "Walter Schmidt"); - NlQueryInterpretation interp = new NlQueryInterpretation( - List.of(), List.of(a, b), null, null, - List.of(), List.of(), "Briefe von Walter", false, false); - NlSearchResponse resp = new NlSearchResponse(DocumentSearchResult.of(List.of()), interp); - when(nlQueryParserService.search(anyString(), anyString(), any())).thenReturn(resp); - - mockMvc.perform(post("/api/search/nl").with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"query\":\"Briefe von Walter\",\"lang\":\"de\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.interpretation.ambiguousPersons").isArray()) - .andExpect(jsonPath("$.interpretation.ambiguousPersons[0].displayName").value("Walter Braun")) - .andExpect(jsonPath("$.interpretation.ambiguousPersons[1].id").isNotEmpty()); - } - - // --- 3. Unauthenticated → 401 --- - - @Test - void search_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/search/nl").with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"query\":\"Briefe von Walter\",\"lang\":\"de\"}")) - .andExpect(status().isUnauthorized()); - } - - // --- 4. Query < 3 chars → 400 --- - - @Test - @WithMockUser(username = "user@test.com", authorities = {"READ_ALL"}) - void search_returns400_whenQueryTooShort() throws Exception { - mockMvc.perform(post("/api/search/nl").with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"query\":\"ab\",\"lang\":\"de\"}")) - .andExpect(status().isBadRequest()); - } - - // --- 5. Query > 500 chars → 400 --- - - @Test - @WithMockUser(username = "user@test.com", authorities = {"READ_ALL"}) - void search_returns400_whenQueryTooLong() throws Exception { - String longQuery = "a".repeat(501); - mockMvc.perform(post("/api/search/nl").with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"query\":\"" + longQuery + "\",\"lang\":\"de\"}")) - .andExpect(status().isBadRequest()); - } - - // --- 6. NLP service unavailable → 503 --- - - @Test - @WithMockUser(username = "user@test.com", authorities = {"READ_ALL"}) - void search_returns503_whenNlpServiceUnavailable() throws Exception { - when(nlQueryParserService.search(anyString(), anyString(), any())) - .thenThrow(DomainException.serviceUnavailable(ErrorCode.SMART_SEARCH_UNAVAILABLE, "NLP service offline")); - - mockMvc.perform(post("/api/search/nl").with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"query\":\"Briefe von Walter\",\"lang\":\"de\"}")) - .andExpect(status().isServiceUnavailable()) - .andExpect(jsonPath("$.code").value("SMART_SEARCH_UNAVAILABLE")); - } - - // --- 7. 21st request in 1 minute → 429 (rate limit = 20/min default) --- - - @Test - @WithMockUser(username = "user@test.com", authorities = {"READ_ALL"}) - void search_returns429_on21stRequestWithinRateLimit() throws Exception { - when(nlQueryParserService.search(anyString(), anyString(), any())).thenReturn(makeResponse()); - - for (int i = 0; i < 20; i++) { - mockMvc.perform(post("/api/search/nl").with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"query\":\"Briefe von Walter\",\"lang\":\"de\"}")) - .andExpect(status().isOk()); - } - - mockMvc.perform(post("/api/search/nl").with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"query\":\"Briefe von Walter\",\"lang\":\"de\"}")) - .andExpect(status().isTooManyRequests()) - .andExpect(jsonPath("$.code").value("SMART_SEARCH_RATE_LIMITED")); - } -} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchRateLimiterTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchRateLimiterTest.java deleted file mode 100644 index 43a2bbe0..00000000 --- a/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchRateLimiterTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package org.raddatz.familienarchiv.search; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.raddatz.familienarchiv.exception.DomainException; -import org.raddatz.familienarchiv.exception.ErrorCode; - -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class NlSearchRateLimiterTest { - - private NlSearchRateLimiter rateLimiter; - - @BeforeEach - void setUp() { - NlSearchRateLimitProperties props = new NlSearchRateLimitProperties(); - props.setMaxRequestsPerMinute(5); - rateLimiter = new NlSearchRateLimiter(props); - } - - @Test - void checkAndConsume_allowsRequestsWithinLimit() { - for (int i = 0; i < 5; i++) { - assertThatCode(() -> rateLimiter.checkAndConsume("user@example.com")) - .doesNotThrowAnyException(); - } - } - - @Test - void checkAndConsume_throwsRateLimited_onSixthRequest() { - for (int i = 0; i < 5; i++) { - rateLimiter.checkAndConsume("user@example.com"); - } - - assertThatThrownBy(() -> rateLimiter.checkAndConsume("user@example.com")) - .isInstanceOf(DomainException.class) - .extracting(e -> ((DomainException) e).getCode()) - .isEqualTo(ErrorCode.SMART_SEARCH_RATE_LIMITED); - } - - @Test - void checkAndConsume_limitsAreIndependentPerUser() { - for (int i = 0; i < 5; i++) { - rateLimiter.checkAndConsume("alice@example.com"); - } - assertThatCode(() -> rateLimiter.checkAndConsume("bob@example.com")) - .doesNotThrowAnyException(); - } - - @Test - void resetForTest_clearsAllBuckets() { - for (int i = 0; i < 5; i++) { - rateLimiter.checkAndConsume("user@example.com"); - } - - rateLimiter.resetForTest(); - - assertThatCode(() -> rateLimiter.checkAndConsume("user@example.com")) - .doesNotThrowAnyException(); - } -} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchTagResolutionIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchTagResolutionIntegrationTest.java deleted file mode 100644 index bbe0a88b..00000000 --- a/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchTagResolutionIntegrationTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package org.raddatz.familienarchiv.search; - -import org.junit.jupiter.api.Test; -import org.raddatz.familienarchiv.PostgresContainerConfig; -import org.raddatz.familienarchiv.config.FlywayConfig; -import org.raddatz.familienarchiv.tag.Tag; -import org.raddatz.familienarchiv.tag.TagRepository; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; -import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; -import org.springframework.context.annotation.Import; - -import java.util.List; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; - -@DataJpaTest -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@Import({PostgresContainerConfig.class, FlywayConfig.class}) -class NlSearchTagResolutionIntegrationTest { - - @Autowired - private TagRepository tagRepository; - - @Test - void findDescendantIdsByName_parentName_includesChildId() { - Tag parent = tagRepository.save(Tag.builder().name("Krieg").build()); - Tag child = tagRepository.save(Tag.builder().name("Weltkrieg").parentId(parent.getId()).build()); - - List ids = tagRepository.findDescendantIdsByName("Krieg"); - - assertThat(ids).containsExactlyInAnyOrder(parent.getId(), child.getId()); - } - - @Test - void findDescendantIdsByName_childName_returnsOnlyChild() { - Tag parent = tagRepository.save(Tag.builder().name("Krieg").build()); - Tag child = tagRepository.save(Tag.builder().name("Weltkrieg").parentId(parent.getId()).build()); - - List ids = tagRepository.findDescendantIdsByName("Weltkrieg"); - - assertThat(ids).containsExactly(child.getId()); - assertThat(ids).doesNotContain(parent.getId()); - } - - @Test - void findByNameContainingIgnoreCase_parentSubstring_matchesParentOnly() { - Tag parent = tagRepository.save(Tag.builder().name("Krieg").build()); - tagRepository.save(Tag.builder().name("Weltkrieg").parentId(parent.getId()).build()); - - List found = tagRepository.findByNameContainingIgnoreCase("Krieg"); - - assertThat(found).extracting(Tag::getName).containsExactlyInAnyOrder("Krieg", "Weltkrieg"); - } -} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlpPropertiesTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlpPropertiesTest.java deleted file mode 100644 index bb527066..00000000 --- a/backend/src/test/java/org/raddatz/familienarchiv/search/NlpPropertiesTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.raddatz.familienarchiv.search; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; - -import static org.assertj.core.api.Assertions.assertThat; - -class NlpPropertiesTest { - - @EnableConfigurationProperties(NlpProperties.class) - static class TestConfig {} - - private final ApplicationContextRunner runner = new ApplicationContextRunner() - .withUserConfiguration(TestConfig.class); - - @Test - void failsWhenBaseUrlMissing() { - runner.run(context -> assertThat(context).hasFailed()); - } - - @Test - void bindsBaseUrlAndDefaults() { - runner.withPropertyValues("app.nlp.base-url=http://nlp:8001") - .run(context -> { - assertThat(context).hasNotFailed(); - NlpProperties props = context.getBean(NlpProperties.class); - assertThat(props.getBaseUrl()).isEqualTo("http://nlp:8001"); - assertThat(props.getTimeoutSeconds()).isEqualTo(5); - assertThat(props.getHealthCheckTimeoutSeconds()).isEqualTo(2); - }); - } -} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/RestClientNlpClientTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/RestClientNlpClientTest.java deleted file mode 100644 index 0198f6c0..00000000 --- a/backend/src/test/java/org/raddatz/familienarchiv/search/RestClientNlpClientTest.java +++ /dev/null @@ -1,124 +0,0 @@ -package org.raddatz.familienarchiv.search; - -import com.github.tomakehurst.wiremock.WireMockServer; -import com.github.tomakehurst.wiremock.core.WireMockConfiguration; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.raddatz.familienarchiv.exception.DomainException; -import org.raddatz.familienarchiv.exception.ErrorCode; - -import static com.github.tomakehurst.wiremock.client.WireMock.*; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class RestClientNlpClientTest { - - private WireMockServer wireMock; - private RestClientNlpClient client; - - @BeforeEach - void setUp() { - wireMock = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort()); - wireMock.start(); - - NlpProperties props = new NlpProperties(); - props.setBaseUrl("http://localhost:" + wireMock.port()); - props.setTimeoutSeconds(5); - props.setHealthCheckTimeoutSeconds(2); - - client = new RestClientNlpClient(props); - } - - @AfterEach - void tearDown() { - wireMock.stop(); - } - - private String makeParseResponseJson(String personNamesJson, String personRole, - String dateFrom, String dateTo, String keywordsJson, - String rawQuery) { - return String.format( - "{\"personNames\":%s,\"personRole\":\"%s\",\"dateFrom\":%s,\"dateTo\":%s,\"keywords\":%s,\"rawQuery\":\"%s\"}", - personNamesJson, personRole, - dateFrom == null ? "null" : "\"" + dateFrom + "\"", - dateTo == null ? "null" : "\"" + dateTo + "\"", - keywordsJson, rawQuery - ); - } - - @Test - void parse_returnsExtraction_whenNlpServiceReturnsValidJson() { - String body = makeParseResponseJson("[\"Walter\"]", "sender", "1914-01-01", "1914-12-31", - "[\"Krieg\"]", "Briefe von Walter im Krieg"); - wireMock.stubFor(post(urlEqualTo("/parse")) - .willReturn(aResponse() - .withStatus(200) - .withHeader("Content-Type", "application/json") - .withBody(body))); - - NlpExtraction result = client.parse("Briefe von Walter im Krieg", "de"); - - assertThat(result.personNames()).containsExactly("Walter"); - assertThat(result.personRole()).isEqualTo("sender"); - assertThat(result.keywords()).containsExactly("Krieg"); - assertThat(result.dateFrom()).isNotNull(); - assertThat(result.dateTo()).isNotNull(); - } - - @Test - void parse_throwsSmartSearchUnavailable_whenNlpServiceReturns500() { - wireMock.stubFor(post(urlEqualTo("/parse")) - .willReturn(aResponse().withStatus(500))); - - assertThatThrownBy(() -> client.parse("some query", "de")) - .isInstanceOf(DomainException.class) - .extracting(e -> ((DomainException) e).getCode()) - .isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE); - } - - @Test - void parse_throwsSmartSearchUnavailable_whenNlpServiceExceedsTimeout() { - wireMock.stubFor(post(urlEqualTo("/parse")) - .willReturn(aResponse() - .withStatus(200) - .withHeader("Content-Type", "application/json") - .withFixedDelay(6000) - .withBody("{\"personNames\":[],\"personRole\":\"any\",\"dateFrom\":null,\"dateTo\":null,\"keywords\":[],\"rawQuery\":\"q\"}"))); - - assertThatThrownBy(() -> client.parse("some query", "de")) - .isInstanceOf(DomainException.class) - .extracting(e -> ((DomainException) e).getCode()) - .isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE); - } - - @Test - void isHealthy_returnsTrue_whenPersonsLoadedIsPositive() { - wireMock.stubFor(get(urlEqualTo("/health")) - .willReturn(aResponse() - .withStatus(200) - .withHeader("Content-Type", "application/json") - .withBody("{\"status\":\"ok\",\"persons_loaded\":42}"))); - - assertThat(client.isHealthy()).isTrue(); - } - - @Test - void isHealthy_returnsFalse_whenPersonsLoadedIsZero() { - wireMock.stubFor(get(urlEqualTo("/health")) - .willReturn(aResponse() - .withStatus(200) - .withHeader("Content-Type", "application/json") - .withBody("{\"status\":\"ok\",\"persons_loaded\":0}"))); - - assertThat(client.isHealthy()).isFalse(); - } - - @Test - void isHealthy_returnsFalse_whenNlpServiceIsDown() { - wireMock.stubFor(get(urlEqualTo("/health")) - .willReturn(aResponse().withStatus(503))); - - assertThat(client.isHealthy()).isFalse(); - } -} diff --git a/docs/superpowers/plans/2026-06-07-remove-nlp-search.md b/docs/superpowers/plans/2026-06-07-remove-nlp-search.md new file mode 100644 index 00000000..635a4b66 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-remove-nlp-search.md @@ -0,0 +1,768 @@ +# Remove NLP/Smart Search Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the NLP/smart-search feature entirely from the codebase — backend search package, frontend components, i18n keys, infrastructure config, and the nlp-service microservice. + +**Architecture:** Pure deletion + targeted edits. No new code. Each task deletes a self-contained layer, then verifies compilation passes before committing. Order: backend first (most isolated), then frontend, then infrastructure, then docs. + +**Tech Stack:** Spring Boot 4 (Java 21, Maven), SvelteKit 2 / Svelte 5, Docker Compose, Paraglide i18n. + +--- + +## File Map + +### Delete entirely +- `backend/src/main/java/org/raddatz/familienarchiv/search/` — 14 Java source files +- `backend/src/test/java/org/raddatz/familienarchiv/search/` — 6 Java test files +- `frontend/src/routes/search/SmartModeToggle.svelte` + `.spec.ts` +- `frontend/src/routes/search/SmartSearchStatus.svelte` + `.spec.ts` +- `frontend/src/routes/search/InterpretationChipRow.svelte` + `.spec.ts` +- `frontend/src/routes/search/DisambiguationPicker.svelte` + `.spec.ts` +- `frontend/src/routes/search/chip-types.ts` +- `frontend/src/routes/documents/theme-chip-removal.ts` + `.spec.ts` +- `infra/observability/grafana/provisioning/dashboards/ollama.json` +- `nlp-service/` (entire directory) +- `docs/adr/028-nl-search-ollama.md` +- `docs/adr/028-ollama-docker-compose-service.md` +- `docs/adr/034-ollama-production-deployment-and-keep-alive.md` +- `docs/adr/035-rule-based-nlp-service.md` + +### Modify +- `backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java` — remove 2 enum values +- `backend/src/main/resources/application.yaml` — remove `nlp` + `nl-search` config blocks +- `backend/src/main/resources/application-dev.yaml` — remove `nlp` config block +- `frontend/src/routes/SearchFilterBar.svelte` — remove SmartModeToggle, smartMode prop, smart callbacks +- `frontend/src/routes/SearchFilterBar.svelte.spec.ts` — remove smart-mode describe block +- `frontend/src/routes/documents/+page.svelte` — remove all NL state, functions, template block +- `frontend/src/lib/shared/errors.ts` — remove 2 error codes + their switch cases +- `frontend/messages/de.json` — remove 8 smart-search keys +- `frontend/messages/en.json` — remove 8 smart-search keys +- `frontend/messages/es.json` — remove 8 smart-search keys +- `docker-compose.yml` — remove nlp-service block + backend depends_on + env var +- `docker-compose.prod.yml` — remove nlp-service block + backend depends_on + env var +- `infra/observability/prometheus/prometheus.yml` — remove ollama scrape job +- `CLAUDE.md` — remove search package reference + error code entries +- `backend/CLAUDE.md` — no change needed (search package already absent from structure) +- `frontend/CLAUDE.md` — update routes/search/ description + +--- + +### Task 1: Delete backend search package + +**Files:** +- Delete: `backend/src/main/java/org/raddatz/familienarchiv/search/` (14 files) +- Delete: `backend/src/test/java/org/raddatz/familienarchiv/search/` (6 files) + +- [ ] **Step 1: Delete all source files** + +```bash +rm -rf backend/src/main/java/org/raddatz/familienarchiv/search +rm -rf backend/src/test/java/org/raddatz/familienarchiv/search +``` + +- [ ] **Step 2: Verify backend compiles** + +```bash +cd backend && . ~/.sdkman/candidates/java/current/bin/../.. && source ~/.sdkman/bin/sdkman-init.sh && ./mvnw compile -q +``` + +Expected: BUILD SUCCESS with no errors. + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "refactor(search): delete backend NLP search package" +``` + +--- + +### Task 2: Remove ErrorCode entries and backend config + +**Files:** +- Modify: `backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java:138-142` +- Modify: `backend/src/main/resources/application.yaml:133-138` +- Modify: `backend/src/main/resources/application-dev.yaml:15-17` + +- [ ] **Step 1: Remove NL Search enum values from ErrorCode.java** + +Remove these lines (138–142): +```java + // --- NL Search --- + /** Ollama is unreachable or timed out. 503 */ + SMART_SEARCH_UNAVAILABLE, + /** NL search rate limit exceeded (5 requests per user per minute). 429 */ + SMART_SEARCH_RATE_LIMITED, +``` + +The block between `TAG_MERGE_INVALID_TARGET,` and `// --- Generic ---` becomes empty. + +- [ ] **Step 2: Remove nlp and nl-search config from application.yaml** + +Remove these lines (133–138): +```yaml + nlp: + base-url: http://nlp-service:8001 + + nl-search: + rate-limit: + max-requests-per-minute: 20 +``` + +- [ ] **Step 3: Remove nlp config from application-dev.yaml** + +Remove these lines (15–17): +```yaml +app: + nlp: + base-url: http://localhost:8001 +``` + +Note: only remove the `nlp:` sub-key under `app:`, preserving any other `app:` config above it. + +- [ ] **Step 4: Verify backend still compiles** + +```bash +cd backend && source ~/.sdkman/bin/sdkman-init.sh && ./mvnw compile -q +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java \ + backend/src/main/resources/application.yaml \ + backend/src/main/resources/application-dev.yaml +git commit -m "refactor(search): remove NLP error codes and application config" +``` + +--- + +### Task 3: Delete frontend NL search components and utilities + +**Files:** +- Delete: `frontend/src/routes/search/SmartModeToggle.svelte` + `.spec.ts` +- Delete: `frontend/src/routes/search/SmartSearchStatus.svelte` + `.spec.ts` +- Delete: `frontend/src/routes/search/InterpretationChipRow.svelte` + `.spec.ts` +- Delete: `frontend/src/routes/search/DisambiguationPicker.svelte` + `.spec.ts` +- Delete: `frontend/src/routes/search/chip-types.ts` +- Delete: `frontend/src/routes/documents/theme-chip-removal.ts` + `.spec.ts` + +- [ ] **Step 1: Delete all NL search components, specs, and utilities** + +```bash +rm frontend/src/routes/search/SmartModeToggle.svelte \ + frontend/src/routes/search/SmartModeToggle.svelte.spec.ts \ + frontend/src/routes/search/SmartSearchStatus.svelte \ + frontend/src/routes/search/SmartSearchStatus.svelte.spec.ts \ + frontend/src/routes/search/InterpretationChipRow.svelte \ + frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts \ + frontend/src/routes/search/DisambiguationPicker.svelte \ + frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts \ + frontend/src/routes/search/chip-types.ts \ + frontend/src/routes/documents/theme-chip-removal.ts \ + frontend/src/routes/documents/theme-chip-removal.spec.ts +``` + +- [ ] **Step 2: Commit** + +```bash +git add -A +git commit -m "refactor(search): delete frontend NLP search components and utilities" +``` + +--- + +### Task 4: Remove NL search from SearchFilterBar + +**Files:** +- Modify: `frontend/src/routes/SearchFilterBar.svelte` +- Modify: `frontend/src/routes/SearchFilterBar.svelte.spec.ts:199-233` + +- [ ] **Step 1: Rewrite SearchFilterBar.svelte** + +Replace the entire ` +``` + +- [ ] **Step 2: Update the search input element in the template** + +Replace the `` element (lines 92–105) with: +```svelte + +``` + +- [ ] **Step 3: Remove the SmartModeToggle component from the template** + +Delete this line (135): +```svelte + +``` + +- [ ] **Step 4: Remove smart-mode describe block from SearchFilterBar.svelte.spec.ts** + +Delete lines 199–233 (the entire final `describe` block): +```typescript +describe('SearchFilterBar – smart-mode chip lifecycle hooks', () => { + // ... +}); +``` + +- [ ] **Step 5: Run the SearchFilterBar tests to verify they pass** + +```bash +cd frontend && source ~/.nvm/nvm.sh && npm run test -- --project=client src/routes/SearchFilterBar.svelte.spec.ts +``` + +Expected: all tests pass, no failures. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/routes/SearchFilterBar.svelte \ + frontend/src/routes/SearchFilterBar.svelte.spec.ts +git commit -m "refactor(search): remove smart mode from SearchFilterBar" +``` + +--- + +### Task 5: Remove NL search from documents/+page.svelte + +**Files:** +- Modify: `frontend/src/routes/documents/+page.svelte` + +This is the largest edit. Remove all NL search state, derived values, functions, and the NL results template block. + +- [ ] **Step 1: Remove NL search imports (lines 11–16, 23–27)** + +Remove these import lines: +```typescript +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'; +``` + +Remove these type aliases: +```typescript +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'; +``` + +Also remove the `import { csrfFetch } from '$lib/shared/cookies';` line — it is only used by `runSmartSearch`. + +- [ ] **Step 2: Remove all NL state and derived values (lines 51–70)** + +Remove these declarations: +```typescript +// 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 ambiguousPersons = $derived(nlInterpretation?.ambiguousPersons ?? []); +const nlIsAmbiguous = $derived(ambiguousPersons.length > 0); +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); +``` + +- [ ] **Step 3: Remove all NL search functions (lines 202–318)** + +Remove these functions entirely: +- `resetNlState()` +- `onModeToggle()` +- `runSmartSearch()` +- `switchToKeywordMode()` +- `applyResolvedAndSearch()` +- `paramsFromInterpretation()` +- `removeChip()` +- `selectDisambiguated()` + +- [ ] **Step 4: Update SearchFilterBar usage in the template** + +Replace the SearchFilterBar call with (removing `bind:smartMode`, `onSmartSearch`, `onModeToggle`): +```svelte + (qFocused = true)} + onblur={() => (qFocused = false)} + /> +``` + +- [ ] **Step 5: Remove the NL results template block** + +Replace the entire `{#if showNlView}...{:else}...{/if}` block with just the content of the `{:else}` branch — the `