merge: resolve conflicts with origin/main (#763 person name-match integration)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m31s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
CI / Unit & Component Tests (push) Successful in 3m20s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m48s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m8s

- Drop unused MAX_CANDIDATES constant (not referenced in service)
- Keep detached-entity safety comment in resolveTags()
- Add 3 new partial-name match tests (23a/b/c) from #763
- Use resolveByName() API in test 28 (replaces findByDisplayNameContaining)
- Add NameMatches glossary entry from #763

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #765.
This commit is contained in:
Marcel
2026-06-07 08:50:48 +02:00
21 changed files with 740 additions and 71 deletions

View File

@@ -0,0 +1,13 @@
package org.raddatz.familienarchiv.person;
import java.util.List;
/**
* Result of {@link PersonService#resolveByName(String)}: candidate persons split by name-match
* strength. {@code direct} = every query token is a whole-token match across the person's name
* components (alias/maiden-name aware); {@code partial} = matched the substring fetch but is not
* direct. The vocabulary is deliberately name-match strength ({@code direct}/{@code partial}), not
* the search layer's resolved/ambiguous buckets — the caller maps these into its own outcome.
*/
public record NameMatches(List<Person> direct, List<Person> partial) {
}

View File

@@ -19,7 +19,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
"LOWER(CONCAT(COALESCE(p.firstName, ''),' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
"LOWER(CONCAT(p.lastName, ' ', COALESCE(p.firstName, ''))) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
"LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) " +
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
"LOWER(a.firstName) LIKE LOWER(CONCAT('%', :query, '%')) " +
"ORDER BY p.lastName ASC, p.firstName ASC")
List<Person> searchByName(@Param("query") String query);

View File

@@ -1,9 +1,15 @@
package org.raddatz.familienarchiv.person;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.springframework.lang.Nullable;
@@ -24,11 +30,20 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Service
@RequiredArgsConstructor
@Slf4j
public class PersonService {
// Co-located with the fetch loop that owns them (issue #763). MAX_TOKENS caps the number of
// unindexed leading-wildcard LIKE scans per name — a DoS control, not just perf. MAX_CANDIDATES
// bounds each result bucket and is applied AFTER classification so a direct match that sorts
// past position 10 among partials is never discarded.
private static final int MAX_TOKENS = 8;
private static final int MAX_CANDIDATES = 10;
private final PersonRepository personRepository;
private final PersonNameAliasRepository aliasRepository;
@@ -103,6 +118,92 @@ public class PersonService {
return personRepository.searchByName(fragment);
}
// Name-match tokenizer (issue #763): lowercase, split on whitespace/hyphen/apostrophe,
// drop empties. Applied symmetrically to the query and to every candidate name component so
// that "Anna-Maria" and "Anna Maria" tokenize alike. Order-preserving for deterministic tests.
static Set<String> tokenize(String raw) {
if (raw == null || raw.isBlank()) {
return Set.of();
}
LinkedHashSet<String> tokens = new LinkedHashSet<>();
for (String part : raw.toLowerCase(Locale.ROOT).split("[\\s\\-']+")) {
if (!part.isEmpty()) {
tokens.add(part);
}
}
return tokens;
}
/**
* Resolves an extracted person name into {@link NameMatches} by name-match strength.
* Orchestrates tokenize → cap → fetch pool → classify → cap-after-classify. Read-only
* transaction keeps the Hibernate session open so each candidate's lazy {@code nameAliases}
* are reachable during classification (see ADR-022).
*/
@Transactional(readOnly = true)
public NameMatches resolveByName(String name) {
Set<String> queryTokens = capTokens(tokenize(name));
if (queryTokens.isEmpty()) {
log.debug("resolveByName outcome=no-match tokens=0");
return new NameMatches(List.of(), List.of());
}
return classify(fetchPool(queryTokens), queryTokens);
}
private Set<String> capTokens(Set<String> tokens) {
return tokens.stream().limit(MAX_TOKENS).collect(Collectors.toCollection(LinkedHashSet::new));
}
private List<Person> fetchPool(Set<String> queryTokens) {
LinkedHashMap<UUID, Person> pool = new LinkedHashMap<>();
for (String token : queryTokens) {
for (Person candidate : findByDisplayNameContaining(token)) {
pool.putIfAbsent(candidate.getId(), candidate);
}
}
return new ArrayList<>(pool.values());
}
private NameMatches classify(List<Person> pool, Set<String> queryTokens) {
List<Person> direct = new ArrayList<>();
List<Person> partial = new ArrayList<>();
for (Person candidate : pool) {
if (personTokens(candidate).containsAll(queryTokens)) {
direct.add(candidate);
} else {
partial.add(candidate);
}
}
List<Person> cappedDirect = cap(direct);
List<Person> cappedPartial = cap(partial);
log.debug("resolveByName outcome={} tokens={}", outcome(cappedDirect, cappedPartial), queryTokens.size());
return new NameMatches(cappedDirect, cappedPartial);
}
private static Set<String> personTokens(Person person) {
Set<String> tokens = new LinkedHashSet<>();
tokens.addAll(tokenize(person.getFirstName()));
tokens.addAll(tokenize(person.getLastName()));
tokens.addAll(tokenize(person.getAlias()));
tokens.addAll(tokenize(person.getTitle()));
for (PersonNameAlias alias : person.getNameAliases()) {
tokens.addAll(tokenize(alias.getFirstName()));
tokens.addAll(tokenize(alias.getLastName()));
}
return tokens;
}
private static List<Person> cap(List<Person> people) {
return people.size() > MAX_CANDIDATES ? people.subList(0, MAX_CANDIDATES) : people;
}
private static String outcome(List<Person> direct, List<Person> partial) {
if (direct.size() == 1) return "direct=1";
if (direct.size() >= 2) return "direct>=2";
if (!partial.isEmpty()) return "partial-only";
return "no-match";
}
public List<Person> findAllFamilyMembers() {
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
}

View File

@@ -21,6 +21,7 @@ Features: person CRUD, name alias management, person merge (deduplication), fami
| `getAllById(List<UUID>)` | document | Bulk fetch for sender/receiver resolution |
| `findAll(String q)` | document, dashboard | List all persons |
| `findByName(String firstName, String lastName)` | document | Filename-based **sender resolution** in `storeDocument`: exact-case match → single case-insensitive match → else **empty** (ambiguous names leave the sender unset; a null first name never matches). See ADR-033. |
| `resolveByName(String name)` | search | NL-search name resolution returning `NameMatches` (direct vs partial). Token/word-boundary, alias-aware matching so a single direct match auto-selects even when looser substring hits coexist ("Clara Cram" vs "Clara Cramer"). See #763. |
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally. Resolves exact-case → lowest-id case-insensitive sibling → create — never throws on case-colliding aliases. See ADR-033. |
| `findAllFamilyMembers()` | dashboard | Family member list for stats |
| `findCorrespondents()` | document | Correspondent list for conversation filter |

View File

@@ -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<Person> candidates = personService.findByDisplayNameContaining(name);
List<Person> capped = candidates.size() > MAX_CANDIDATES
? candidates.subList(0, MAX_CANDIDATES)
: candidates;
NameMatches matches = personService.resolveByName(name);
List<Person> direct = matches.direct();
List<Person> 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);
}
}

View File

@@ -133,7 +133,9 @@ app:
ollama:
base-url: http://ollama:11434
model: qwen2.5:7b-instruct-q4_K_M
timeout-seconds: 30
# CPU inference: ~18s warm. Higher ceiling absorbs the cold model load on the
# first query after an Ollama (re)start before OLLAMA_KEEP_ALIVE pins it.
timeout-seconds: 60
health-check-timeout-seconds: 2
nl-search:

View File

@@ -428,6 +428,67 @@ class PersonRepositoryTest {
assertThat(results).hasSize(1);
}
@Test
void searchByName_findsByAliasFirstName() {
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
aliasRepository.save(PersonNameAlias.builder()
.person(clara).firstName("Wilhelmina").lastName("de Gruyter")
.type(PersonNameAliasType.BIRTH).sortOrder(0).build());
List<Person> results = personRepository.searchByName("Wilhelmina");
assertThat(results).hasSize(1);
assertThat(results.get(0).getLastName()).isEqualTo("Cram");
}
@Test
void searchByName_ordersByLastNameThenFirstName() {
personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
personRepository.save(Person.builder().firstName("Anna").lastName("Cram").build());
personRepository.save(Person.builder().firstName("Bernd").lastName("Cram").build());
List<Person> results = personRepository.searchByName("Cram");
assertThat(results).extracting(Person::getFirstName)
.containsExactly("Anna", "Bernd", "Clara");
}
// ─── resolveByName fetch→classify, end-to-end on real Postgres (#763 review) ───
// The classifier unit tests in PersonServiceTest stub searchByName, so they never prove the
// fetch query actually finds an alias-only match and feeds it into classification. These walk
// the whole searchByName → resolveByName path over the real Postgres slice, closing AC#4/#5.
@Test
void resolveByName_maidenAlias_classifiesAsDirect_endToEnd() {
PersonService personService = new PersonService(personRepository, aliasRepository);
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Müller").build());
aliasRepository.save(PersonNameAlias.builder()
.person(clara).lastName("Cram").type(PersonNameAliasType.MAIDEN_NAME).sortOrder(0).build());
// Detach so resolveByName re-fetches with its lazy nameAliases loaded from the DB —
// the fresh-session behaviour the @Transactional(readOnly=true) path has in production.
entityManager.flush();
entityManager.clear();
NameMatches matches = personService.resolveByName("Clara Cram");
assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId());
}
@Test
void resolveByName_aliasFirstName_classifiesAsDirect_endToEnd() {
PersonService personService = new PersonService(personRepository, aliasRepository);
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
aliasRepository.save(PersonNameAlias.builder()
.person(clara).firstName("Wilhelmina").lastName("de Gruyter")
.type(PersonNameAliasType.BIRTH).sortOrder(0).build());
entityManager.flush();
entityManager.clear();
NameMatches matches = personService.resolveByName("Wilhelmina");
assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId());
}
// ─── searchWithDocumentCount with aliases ────────────────────────────────
@Test

View File

@@ -909,4 +909,154 @@ class PersonServiceTest {
assertThat(result).containsExactly(walter);
verify(personRepository).searchByName("Walter");
}
// ─── tokenize (name-match contract) ───────────────────────────────────────
@Test
void tokenize_hyphenatedName_splitsOnHyphen() {
assertThat(PersonService.tokenize("Anna-Maria")).containsExactly("anna", "maria");
}
@Test
void tokenize_apostropheName_splitsOnApostrophe() {
assertThat(PersonService.tokenize("D'Angelo")).containsExactly("d", "angelo");
}
@Test
void tokenize_umlautName_lowercasesToSingleToken() {
assertThat(PersonService.tokenize("Müller")).containsExactly("müller");
}
@Test
void tokenize_doubleSpace_dropsEmptyTokens() {
assertThat(PersonService.tokenize("Clara Cram")).containsExactly("clara", "cram");
}
@Test
void tokenize_allWhitespace_returnsEmpty() {
assertThat(PersonService.tokenize(" ")).isEmpty();
}
@Test
void tokenize_null_returnsEmpty() {
assertThat(PersonService.tokenize(null)).isEmpty();
}
// ─── resolveByName (direct / partial classification) ──────────────────────
@Test
void resolveByName_singleDirectMatch_classifiesAsDirect() {
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.direct()).containsExactly(clara);
}
@Test
void resolveByName_maidenAliasToken_classifiesAsDirect() {
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Müller")
.nameAliases(List.of(PersonNameAlias.builder().lastName("Cram")
.type(PersonNameAliasType.MAIDEN_NAME).build()))
.build();
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.direct()).containsExactly(clara);
}
@Test
void resolveByName_aliasFirstNameToken_isFetchedAndClassified() {
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram")
.nameAliases(List.of(PersonNameAlias.builder().firstName("Wilhelmina").lastName("de Gruyter")
.type(PersonNameAliasType.BIRTH).build()))
.build();
when(personRepository.searchByName("wilhelmina")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Wilhelmina");
assertThat(result.direct()).containsExactly(clara);
}
@Test
void resolveByName_middleName_stillDirect() {
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara Maria").lastName("Cram").build();
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.direct()).containsExactly(clara);
}
@Test
void resolveByName_reorderedTokens_stillDirect() {
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Cram Clara");
assertThat(result.direct()).containsExactly(clara);
}
@Test
void resolveByName_cramVsCramer_classifiesAsPartial() {
Person cramer = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cramer").build();
when(personRepository.searchByName("clara")).thenReturn(List.of(cramer));
when(personRepository.searchByName("cram")).thenReturn(List.of(cramer));
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.partial()).containsExactly(cramer);
}
@Test
void resolveByName_emptyAfterTokenizing_returnsNoCandidates() {
NameMatches result = personService.resolveByName(" - ");
assertThat(result.direct()).isEmpty();
verify(personRepository, never()).searchByName(any());
}
@Test
void resolveByName_directSortsBeyondCap_stillReturnedAsDirect() {
List<Person> pool = new java.util.ArrayList<>();
for (int i = 0; i < 10; i++) {
pool.add(Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cramer").build());
}
Person direct = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
pool.add(direct);
when(personRepository.searchByName("clara")).thenReturn(pool);
when(personRepository.searchByName("cram")).thenReturn(pool);
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.direct()).containsExactly(direct);
}
@Test
void resolveByName_over8Tokens_issuesAtMost8Fetches() {
personService.resolveByName("a b c d e f g h i j");
verify(personRepository, org.mockito.Mockito.atMost(8)).searchByName(any());
}
@Test
void resolveByName_samePersonFromTwoTokens_appearsOnce() {
// Both token fetches return the same person id — fetchPool's putIfAbsent must dedup so the
// candidate is classified once, not twice.
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.direct()).hasSize(1);
assertThat(result.partial()).isEmpty();
}
}

View File

@@ -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<Person> direct) {
return new NameMatches(direct, List.of());
}
private NameMatches makeNameMatches(List<Person> direct, List<Person> 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<Person> eleven = new ArrayList<>();
for (int i = 0; i < 11; i++) {
eleven.add(person(UUID.randomUUID(), "Walter", "Person" + i));
void search_tenDirectMatches_allShownAsAmbiguous() {
List<Person> 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);