diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index ed37a2c2..faa74367 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -1033,6 +1033,10 @@ public class DocumentService { return documentRepository.findByReceiversId(receiverId); } + public DocumentSearchResult searchDocumentsByPersonId(UUID personId, LocalDate from, LocalDate to, Pageable pageable) { + throw new UnsupportedOperationException("Implemented in Task 12 — findBySenderOrReceiver JPQL"); + } + public long getIncompleteCount() { return documentRepository.countByMetadataCompleteFalse(); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java new file mode 100644 index 00000000..5938fb5e --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java @@ -0,0 +1,160 @@ +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.Person; +import org.raddatz.familienarchiv.person.PersonService; +import org.raddatz.familienarchiv.tag.TagOperator; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.ArrayList; +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 MAX_CANDIDATES = 10; + + private final OllamaClient ollamaClient; + private final PersonService personService; + private final DocumentService documentService; + + public NlSearchResponse search(String query, Pageable pageable) { + if (query == null || query.length() < MIN_QUERY || query.length() > MAX_QUERY) { + throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, + "Query must be between " + MIN_QUERY + " and " + MAX_QUERY + " characters"); + } + + OllamaExtraction ext = ollamaClient.parse(query); + + List personNames = ext.personNames() != null ? ext.personNames() : List.of(); + List keywords = ext.keywords() != null ? ext.keywords() : List.of(); + + NameResolution resolution = resolveNames(personNames); + + if (!resolution.ambiguous().isEmpty()) { + NlQueryInterpretation interpretation = new NlQueryInterpretation( + List.of(), resolution.ambiguous(), + ext.dateFrom(), ext.dateTo(), + keywords, ext.rawQuery(), false); + return new NlSearchResponse(DocumentSearchResult.of(List.of()), interpretation); + } + + List resolved = resolution.resolved(); + List noMatchFragments = resolution.noMatchFragments(); + List extraFragments = resolution.extraFragments(); + + String text = buildText(keywords, noMatchFragments, extraFragments, ext.rawQuery()); + + 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, ext.rawQuery(), false); + return new NlSearchResponse(docs, interpretation); + } + + UUID sender = buildSender(resolved, ext.personRole()); + UUID receiver = buildReceiver(resolved, ext.personRole()); + + SearchFilters filters = new SearchFilters( + text.isBlank() ? null : text, + ext.dateFrom(), ext.dateTo(), + sender, receiver, + List.of(), null, + null, TagOperator.AND, 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, ext.rawQuery(), keywordsApplied); + 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; + } + List candidates = personService.findByDisplayNameContaining(name); + List capped = candidates.size() > MAX_CANDIDATES + ? candidates.subList(0, MAX_CANDIDATES) + : candidates; + + if (capped.isEmpty()) { + noMatchFragments.add(name); + } else if (capped.size() == 1) { + Person p = capped.get(0); + PersonHint hint = new PersonHint(p.getId(), p.getDisplayName()); + resolvedIndex++; + if (resolvedIndex <= 2) { + resolved.add(hint); + } else { + extraFragments.add(name); + } + } else { + capped.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName()))); + } + } + + return new NameResolution(resolved, ambiguous, noMatchFragments, extraFragments); + } + + private String buildText(List keywords, List noMatchFragments, + List extraFragments, String rawQuery) { + List parts = new ArrayList<>(); + parts.addAll(keywords); + parts.addAll(noMatchFragments); + parts.addAll(extraFragments); + String text = String.join(" ", parts).strip(); + if (text.isBlank() && 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 + ) {} +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java new file mode 100644 index 00000000..65d73685 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java @@ -0,0 +1,440 @@ +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.Person; +import org.raddatz.familienarchiv.person.PersonService; +import org.raddatz.familienarchiv.tag.TagOperator; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDate; +import java.util.ArrayList; +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 OllamaClient ollamaClient; + @Mock PersonService personService; + @Mock DocumentService documentService; + + NlQueryParserService service; + + static final Pageable PAGE = PageRequest.of(0, 20); + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + service = new NlQueryParserService(ollamaClient, personService, documentService); + when(documentService.searchDocuments(any(), any(), any(), any())) + .thenReturn(DocumentSearchResult.of(List.of())); + when(documentService.searchDocumentsByPersonId(any(), any(), any(), any())) + .thenReturn(DocumentSearchResult.of(List.of())); + } + + // --- Factory helpers --- + + private OllamaExtraction extraction(List names, String role, LocalDate from, LocalDate to, + List keywords) { + String raw = names.isEmpty() ? "test query" : String.join(" ", names); + return new OllamaExtraction(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 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(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of())); + when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter)); + + NlSearchResponse resp = service.search("Was hat Walter geschrieben?", 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()).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(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of())); + when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(a, b)); + + NlSearchResponse resp = service.search("Briefe von Walter", 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(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of("Emma"), "any", null, null, List.of())); + when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(a, b)); + + NlSearchResponse resp = service.search("Briefe an Emma", 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(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of("Karl"), "any", null, null, List.of())); + when(personService.findByDisplayNameContaining("Karl")).thenReturn(List.of()); + + service.search("Briefe von Karl", 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(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of("Walter"), "any", null, null, List.of())); + when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter)); + + NlSearchResponse resp = service.search("Briefe von Walter", 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(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of("Walter", "Emma"), "any", null, null, List.of())); + when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter)); + when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma)); + + NlSearchResponse resp = service.search("Briefe von Walter an Emma", 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(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of("Walter", "Emma"), "sender", null, null, List.of())); + when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter)); + when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma1, emma2)); + + NlSearchResponse resp = service.search("Briefe von Walter an Emma", 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(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of("Karl", "Emma"), "sender", null, null, List.of())); + when(personService.findByDisplayNameContaining("Karl")).thenReturn(List.of()); + when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma)); + + service.search("Briefe von Karl an Emma", 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(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of("Walter", "Emma", "Heinrich"), "any", null, null, List.of())); + when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter)); + when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma)); + when(personService.findByDisplayNameContaining("Heinrich")).thenReturn(List.of(heinrich)); + + service.search("Briefe von Walter an Emma über Heinrich", 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(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("Krieg", "Walter"))); + + service.search("Dokumente über den Krieg Walter", 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(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", from, to, List.of())); + + service.search("Briefe aus dem Jahr 1914", 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(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit"))); + + service.search("Hochzeitsbriefe", 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. Query under 3 chars → VALIDATION_ERROR before Ollama call --- + + @Test + void search_queryTooShort_throwsValidationError() { + assertThatThrownBy(() -> service.search("ab", PAGE)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.VALIDATION_ERROR); + + verify(ollamaClient, never()).parse(anyString()); + } + + // --- 14. Query over 500 chars → VALIDATION_ERROR --- + + @Test + void search_queryTooLong_throwsValidationError() { + String longQuery = "a".repeat(501); + assertThatThrownBy(() -> service.search(longQuery, PAGE)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.VALIDATION_ERROR); + + verify(ollamaClient, never()).parse(anyString()); + } + + // --- 15. Ollama returns empty names/keywords → raw query used as keyword fallback --- + + @Test + void search_ollamaReturnsEmpty_usesRawQueryAsTextFallback() { + String raw = "Briefe aus dem Krieg"; + when(ollamaClient.parse(anyString())) + .thenReturn(new OllamaExtraction(List.of(), "any", null, null, List.of(), raw)); + + service.search(raw, PAGE); + + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().text()).isEqualTo(raw); + } + + // --- 16. Null personNames/keywords from Ollama → no NPE --- + + @Test + void search_nullPersonNamesAndKeywords_handledWithoutNpe() { + OllamaExtraction ext = new OllamaExtraction(null, "any", null, null, null, "test query"); + when(ollamaClient.parse(anyString())).thenReturn(ext); + + NlSearchResponse resp = service.search("test query", PAGE); + + assertThat(resp).isNotNull(); + verify(documentService).searchDocuments(any(), any(), any(), any()); + } + + // --- 17. Unrecognized personRole → defaults to any-like behavior (no crash) --- + + @Test + void search_unrecognizedPersonRole_treatedLikeAny_withSingleResolvedPerson() { + Person walter = person(P1, "Walter", "Raddatz"); + // OllamaClient defensive parsing returns "any" for unknown roles, + // but NlQueryParserService must also be safe if something unexpected arrives. + when(ollamaClient.parse(anyString())) + .thenReturn(new OllamaExtraction(List.of("Walter"), "unknown_role", null, null, List.of(), "query")); + when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter)); + + NlSearchResponse resp = service.search("Briefe von Walter", PAGE); + + // Should not crash; "unknown_role" treated as fallback (neither sender nor receiver → any) + assertThat(resp).isNotNull(); + } + + // --- 18. Ollama throws SMART_SEARCH_UNAVAILABLE → propagates to caller --- + + @Test + void search_ollamaThrowsUnavailable_propagates() { + when(ollamaClient.parse(anyString())) + .thenThrow(DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_UNAVAILABLE, "offline")); + + assertThatThrownBy(() -> service.search("Was hat Walter geschrieben?", PAGE)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE); + } + + // --- 19. LLM-extracted name > 200 chars → skipped, PersonService never called --- + + @Test + void search_nameLongerThan200Chars_isSkippedBeforePersonServiceCall() { + String longName = "A".repeat(201); + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(longName), "sender", null, null, List.of())); + + service.search("Briefe von sehr langem Namen", PAGE); + + verify(personService, never()).findByDisplayNameContaining(anyString()); + } + + // --- 20. Max 10 candidates cap: 11 persons returned → only first 10 in ambiguousPersons --- + + @Test + void search_elevenCandidates_capsAtTen() { + List eleven = new ArrayList<>(); + for (int i = 0; i < 11; i++) { + eleven.add(person(UUID.randomUUID(), "Walter", "Person" + i)); + } + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of())); + when(personService.findByDisplayNameContaining("Walter")).thenReturn(eleven); + + NlSearchResponse resp = service.search("Briefe von Walter", PAGE); + + assertThat(resp.interpretation().ambiguousPersons()).hasSize(10); + verify(documentService, never()).searchDocuments(any(), any(), any(), any()); + } + + // --- 21. SearchFilters defaults: tagOperator=AND, status=null, undated=false, tags=empty --- + + @Test + void search_searchFiltersDefaults_areCorrect() { + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("Krieg"))); + + service.search("Dokumente über den Krieg", 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(); + } + + // --- 22. personRole=receiver + 1 resolved → receiver UUID set --- + + @Test + void search_personRoleReceiver_singleMatch_setsReceiver() { + Person emma = person(P2, "Emma", "Raddatz"); + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of("Emma"), "receiver", null, null, List.of())); + when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma)); + + service.search("Briefe an Emma", 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(); + } + + // --- 23. keywordsApplied=true when text is non-blank --- + + @Test + void search_keywordsApplied_trueWhenTextNonBlank() { + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("Feldpost"))); + + NlSearchResponse resp = service.search("Feldpost aus dem Krieg", PAGE); + + assertThat(resp.interpretation().keywordsApplied()).isTrue(); + } +}