diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java index 41d3dbd2..81218e2d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java @@ -8,6 +8,7 @@ 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; @@ -30,7 +31,6 @@ 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 static final int MIN_TAG_TERM = 3; private static final int MAX_RESOLVED_TAGS = 10; @@ -113,24 +113,24 @@ public class NlQueryParserService { 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; + NameMatches matches = personService.resolveByName(name); + List direct = matches.direct(); + List partial = matches.partial(); - if (capped.isEmpty()) { - noMatchFragments.add(name); - } else if (capped.size() == 1) { - Person p = capped.get(0); - PersonHint hint = new PersonHint(p.getId(), p.getDisplayName()); + if (direct.size() == 1) { + Person p = direct.get(0); resolvedIndex++; if (resolvedIndex <= 2) { - resolved.add(hint); + 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 { - capped.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName()))); + noMatchFragments.add(name); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java index d1e9c970..61c00b6a 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java @@ -11,6 +11,7 @@ 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; @@ -64,6 +65,18 @@ class NlQueryParserServiceTest { 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"); @@ -75,7 +88,7 @@ class NlQueryParserServiceTest { 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)); + when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter))); NlSearchResponse resp = service.search("Was hat Walter geschrieben?", PAGE); @@ -96,7 +109,7 @@ class NlQueryParserServiceTest { 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)); + when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(a, b))); NlSearchResponse resp = service.search("Briefe von Walter", PAGE); @@ -114,7 +127,7 @@ class NlQueryParserServiceTest { 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)); + when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(a, b))); NlSearchResponse resp = service.search("Briefe an Emma", PAGE); @@ -129,7 +142,7 @@ class NlQueryParserServiceTest { 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()); + when(personService.resolveByName("Karl")).thenReturn(makeNameMatches()); service.search("Briefe von Karl", PAGE); @@ -147,7 +160,7 @@ class NlQueryParserServiceTest { 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)); + when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter))); NlSearchResponse resp = service.search("Briefe von Walter", PAGE); @@ -164,8 +177,8 @@ class NlQueryParserServiceTest { 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)); + 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", PAGE); @@ -186,8 +199,8 @@ class NlQueryParserServiceTest { 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)); + 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", PAGE); @@ -202,8 +215,8 @@ class NlQueryParserServiceTest { 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)); + when(personService.resolveByName("Karl")).thenReturn(makeNameMatches()); + when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma))); service.search("Briefe von Karl an Emma", PAGE); @@ -222,9 +235,9 @@ class NlQueryParserServiceTest { 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)); + 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", PAGE); @@ -343,7 +356,7 @@ class NlQueryParserServiceTest { // 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)); + when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter))); NlSearchResponse resp = service.search("Briefe von Walter", PAGE); @@ -374,20 +387,21 @@ class NlQueryParserServiceTest { service.search("Briefe von sehr langem Namen", PAGE); - verify(personService, never()).findByDisplayNameContaining(anyString()); + verify(personService, never()).resolveByName(anyString()); } - // --- 20. Max 10 candidates cap: 11 persons returned → only first 10 in ambiguousPersons --- + // --- 20. Cap lives in resolveByName (after classification): a pre-capped 10-direct result + // maps straight to ambiguousPersons; the search layer adds no second cap. --- @Test - void search_elevenCandidates_capsAtTen() { - List eleven = new ArrayList<>(); - for (int i = 0; i < 11; i++) { - eleven.add(person(UUID.randomUUID(), "Walter", "Person" + i)); + void search_tenDirectMatches_allShownAsAmbiguous() { + List ten = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + ten.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); + when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(ten)); NlSearchResponse resp = service.search("Briefe von Walter", PAGE); @@ -421,7 +435,7 @@ class NlQueryParserServiceTest { 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)); + when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma))); service.search("Briefe an Emma", PAGE); @@ -443,6 +457,52 @@ class NlQueryParserServiceTest { assertThat(resp.interpretation().keywordsApplied()).isTrue(); } + // --- 23a. Partial-only, one candidate → ambiguous (1-item picker), search skipped --- + + @Test + void search_partialOnly_oneCandidate_populatesAmbiguous() { + Person cramer = person(P1, "Clara", "Cramer"); + when(ollamaClient.parse(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", PAGE); + + assertThat(resp.interpretation().ambiguousPersons()).hasSize(1); + verify(documentService, never()).searchDocuments(any(), any(), any(), any()); + } + + // --- 23b. 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(ollamaClient.parse(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", PAGE); + + assertThat(resp.interpretation().ambiguousPersons()).hasSize(2); + } + + // --- 23c. Exactly one direct match → search executes, no picker --- + + @Test + void search_oneDirect_executesSearch() { + Person clara = person(P1, "Clara", "Cram"); + when(ollamaClient.parse(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", 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) { @@ -546,7 +606,7 @@ class NlQueryParserServiceTest { Tag hochzeit = tag(T1, "Hochzeit"); when(ollamaClient.parse(anyString())) .thenReturn(extraction(List.of("Walter"), "any", null, null, List.of("Hochzeit"))); - when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter)); + 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", PAGE);