feat(search): map direct/partial NameMatches into resolve buckets (#763)
resolveNames now delegates to PersonService.resolveByName and maps by match strength: 1 direct → resolved (auto-select), ≥2 direct → ambiguous, 0 direct with partials → ambiguous suggestions, 0 candidates → folded into full-text. A single direct match no longer forces the picker when looser substring hits coexist. The MAX_CANDIDATES cap moved into PersonService (after classification); the MAX_NAME_LENGTH guard, resolved-cap overflow, and sender/receiver mapping are preserved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import org.raddatz.familienarchiv.document.DocumentSort;
|
|||||||
import org.raddatz.familienarchiv.document.SearchFilters;
|
import org.raddatz.familienarchiv.document.SearchFilters;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.person.NameMatches;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
import org.raddatz.familienarchiv.tag.Tag;
|
import org.raddatz.familienarchiv.tag.Tag;
|
||||||
@@ -30,7 +31,6 @@ public class NlQueryParserService {
|
|||||||
private static final int MIN_QUERY = 3;
|
private static final int MIN_QUERY = 3;
|
||||||
private static final int MAX_QUERY = 500;
|
private static final int MAX_QUERY = 500;
|
||||||
private static final int MAX_NAME_LENGTH = 200;
|
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 MIN_TAG_TERM = 3;
|
||||||
private static final int MAX_RESOLVED_TAGS = 10;
|
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());
|
log.debug("Skipping name fragment (too long or null): length={}", name == null ? 0 : name.length());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
List<Person> candidates = personService.findByDisplayNameContaining(name);
|
NameMatches matches = personService.resolveByName(name);
|
||||||
List<Person> capped = candidates.size() > MAX_CANDIDATES
|
List<Person> direct = matches.direct();
|
||||||
? candidates.subList(0, MAX_CANDIDATES)
|
List<Person> partial = matches.partial();
|
||||||
: candidates;
|
|
||||||
|
|
||||||
if (capped.isEmpty()) {
|
if (direct.size() == 1) {
|
||||||
noMatchFragments.add(name);
|
Person p = direct.get(0);
|
||||||
} else if (capped.size() == 1) {
|
|
||||||
Person p = capped.get(0);
|
|
||||||
PersonHint hint = new PersonHint(p.getId(), p.getDisplayName());
|
|
||||||
resolvedIndex++;
|
resolvedIndex++;
|
||||||
if (resolvedIndex <= 2) {
|
if (resolvedIndex <= 2) {
|
||||||
resolved.add(hint);
|
resolved.add(new PersonHint(p.getId(), p.getDisplayName()));
|
||||||
} else {
|
} else {
|
||||||
extraFragments.add(name);
|
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 {
|
} else {
|
||||||
capped.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName())));
|
noMatchFragments.add(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import org.raddatz.familienarchiv.document.DocumentSort;
|
|||||||
import org.raddatz.familienarchiv.document.SearchFilters;
|
import org.raddatz.familienarchiv.document.SearchFilters;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.person.NameMatches;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
import org.raddatz.familienarchiv.tag.Tag;
|
import org.raddatz.familienarchiv.tag.Tag;
|
||||||
@@ -64,6 +65,18 @@ class NlQueryParserServiceTest {
|
|||||||
return Person.builder().id(id).firstName(firstName).lastName(lastName).build();
|
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 P1 = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||||
private static final UUID P2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
private static final UUID P2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
||||||
private static final UUID P3 = UUID.fromString("00000000-0000-0000-0000-000000000003");
|
private static final UUID P3 = UUID.fromString("00000000-0000-0000-0000-000000000003");
|
||||||
@@ -75,7 +88,7 @@ class NlQueryParserServiceTest {
|
|||||||
Person walter = person(P1, "Walter", "Raddatz");
|
Person walter = person(P1, "Walter", "Raddatz");
|
||||||
when(ollamaClient.parse(anyString()))
|
when(ollamaClient.parse(anyString()))
|
||||||
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
|
.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);
|
NlSearchResponse resp = service.search("Was hat Walter geschrieben?", PAGE);
|
||||||
|
|
||||||
@@ -96,7 +109,7 @@ class NlQueryParserServiceTest {
|
|||||||
Person b = person(UUID.randomUUID(), "Walter", "Schmidt");
|
Person b = person(UUID.randomUUID(), "Walter", "Schmidt");
|
||||||
when(ollamaClient.parse(anyString()))
|
when(ollamaClient.parse(anyString()))
|
||||||
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
|
.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);
|
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
|
||||||
|
|
||||||
@@ -114,7 +127,7 @@ class NlQueryParserServiceTest {
|
|||||||
Person b = person(UUID.randomUUID(), "Emma", "Raddatz");
|
Person b = person(UUID.randomUUID(), "Emma", "Raddatz");
|
||||||
when(ollamaClient.parse(anyString()))
|
when(ollamaClient.parse(anyString()))
|
||||||
.thenReturn(extraction(List.of("Emma"), "any", null, null, List.of()));
|
.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);
|
NlSearchResponse resp = service.search("Briefe an Emma", PAGE);
|
||||||
|
|
||||||
@@ -129,7 +142,7 @@ class NlQueryParserServiceTest {
|
|||||||
void search_noMatchName_isFoldedIntoText() {
|
void search_noMatchName_isFoldedIntoText() {
|
||||||
when(ollamaClient.parse(anyString()))
|
when(ollamaClient.parse(anyString()))
|
||||||
.thenReturn(extraction(List.of("Karl"), "any", null, null, List.of()));
|
.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);
|
service.search("Briefe von Karl", PAGE);
|
||||||
|
|
||||||
@@ -147,7 +160,7 @@ class NlQueryParserServiceTest {
|
|||||||
Person walter = person(P1, "Walter", "Raddatz");
|
Person walter = person(P1, "Walter", "Raddatz");
|
||||||
when(ollamaClient.parse(anyString()))
|
when(ollamaClient.parse(anyString()))
|
||||||
.thenReturn(extraction(List.of("Walter"), "any", null, null, List.of()));
|
.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);
|
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
|
||||||
|
|
||||||
@@ -164,8 +177,8 @@ class NlQueryParserServiceTest {
|
|||||||
Person emma = person(P2, "Emma", "Raddatz");
|
Person emma = person(P2, "Emma", "Raddatz");
|
||||||
when(ollamaClient.parse(anyString()))
|
when(ollamaClient.parse(anyString()))
|
||||||
.thenReturn(extraction(List.of("Walter", "Emma"), "any", null, null, List.of()));
|
.thenReturn(extraction(List.of("Walter", "Emma"), "any", null, null, List.of()));
|
||||||
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||||
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma));
|
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
|
||||||
|
|
||||||
NlSearchResponse resp = service.search("Briefe von Walter an Emma", PAGE);
|
NlSearchResponse resp = service.search("Briefe von Walter an Emma", PAGE);
|
||||||
|
|
||||||
@@ -186,8 +199,8 @@ class NlQueryParserServiceTest {
|
|||||||
Person emma2 = person(P3, "Emma", "Schmidt");
|
Person emma2 = person(P3, "Emma", "Schmidt");
|
||||||
when(ollamaClient.parse(anyString()))
|
when(ollamaClient.parse(anyString()))
|
||||||
.thenReturn(extraction(List.of("Walter", "Emma"), "sender", null, null, List.of()));
|
.thenReturn(extraction(List.of("Walter", "Emma"), "sender", null, null, List.of()));
|
||||||
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||||
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma1, emma2));
|
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma1, emma2)));
|
||||||
|
|
||||||
NlSearchResponse resp = service.search("Briefe von Walter an Emma", PAGE);
|
NlSearchResponse resp = service.search("Briefe von Walter an Emma", PAGE);
|
||||||
|
|
||||||
@@ -202,8 +215,8 @@ class NlQueryParserServiceTest {
|
|||||||
Person emma = person(P2, "Emma", "Raddatz");
|
Person emma = person(P2, "Emma", "Raddatz");
|
||||||
when(ollamaClient.parse(anyString()))
|
when(ollamaClient.parse(anyString()))
|
||||||
.thenReturn(extraction(List.of("Karl", "Emma"), "sender", null, null, List.of()));
|
.thenReturn(extraction(List.of("Karl", "Emma"), "sender", null, null, List.of()));
|
||||||
when(personService.findByDisplayNameContaining("Karl")).thenReturn(List.of());
|
when(personService.resolveByName("Karl")).thenReturn(makeNameMatches());
|
||||||
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma));
|
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
|
||||||
|
|
||||||
service.search("Briefe von Karl an Emma", PAGE);
|
service.search("Briefe von Karl an Emma", PAGE);
|
||||||
|
|
||||||
@@ -222,9 +235,9 @@ class NlQueryParserServiceTest {
|
|||||||
Person heinrich = person(P3, "Heinrich", "Braun");
|
Person heinrich = person(P3, "Heinrich", "Braun");
|
||||||
when(ollamaClient.parse(anyString()))
|
when(ollamaClient.parse(anyString()))
|
||||||
.thenReturn(extraction(List.of("Walter", "Emma", "Heinrich"), "any", null, null, List.of()));
|
.thenReturn(extraction(List.of("Walter", "Emma", "Heinrich"), "any", null, null, List.of()));
|
||||||
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||||
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma));
|
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
|
||||||
when(personService.findByDisplayNameContaining("Heinrich")).thenReturn(List.of(heinrich));
|
when(personService.resolveByName("Heinrich")).thenReturn(makeNameMatches(List.of(heinrich)));
|
||||||
|
|
||||||
service.search("Briefe von Walter an Emma über Heinrich", PAGE);
|
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.
|
// but NlQueryParserService must also be safe if something unexpected arrives.
|
||||||
when(ollamaClient.parse(anyString()))
|
when(ollamaClient.parse(anyString()))
|
||||||
.thenReturn(new OllamaExtraction(List.of("Walter"), "unknown_role", null, null, List.of(), "query"));
|
.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);
|
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
|
||||||
|
|
||||||
@@ -374,20 +387,21 @@ class NlQueryParserServiceTest {
|
|||||||
|
|
||||||
service.search("Briefe von sehr langem Namen", PAGE);
|
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
|
@Test
|
||||||
void search_elevenCandidates_capsAtTen() {
|
void search_tenDirectMatches_allShownAsAmbiguous() {
|
||||||
List<Person> eleven = new ArrayList<>();
|
List<Person> ten = new ArrayList<>();
|
||||||
for (int i = 0; i < 11; i++) {
|
for (int i = 0; i < 10; i++) {
|
||||||
eleven.add(person(UUID.randomUUID(), "Walter", "Person" + i));
|
ten.add(person(UUID.randomUUID(), "Walter", "Person" + i));
|
||||||
}
|
}
|
||||||
when(ollamaClient.parse(anyString()))
|
when(ollamaClient.parse(anyString()))
|
||||||
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
|
.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);
|
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
|
||||||
|
|
||||||
@@ -421,7 +435,7 @@ class NlQueryParserServiceTest {
|
|||||||
Person emma = person(P2, "Emma", "Raddatz");
|
Person emma = person(P2, "Emma", "Raddatz");
|
||||||
when(ollamaClient.parse(anyString()))
|
when(ollamaClient.parse(anyString()))
|
||||||
.thenReturn(extraction(List.of("Emma"), "receiver", null, null, List.of()));
|
.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);
|
service.search("Briefe an Emma", PAGE);
|
||||||
|
|
||||||
@@ -443,6 +457,52 @@ class NlQueryParserServiceTest {
|
|||||||
assertThat(resp.interpretation().keywordsApplied()).isTrue();
|
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 ---
|
// --- Tag resolution helpers ---
|
||||||
|
|
||||||
private Tag tag(UUID id, String name) {
|
private Tag tag(UUID id, String name) {
|
||||||
@@ -546,7 +606,7 @@ class NlQueryParserServiceTest {
|
|||||||
Tag hochzeit = tag(T1, "Hochzeit");
|
Tag hochzeit = tag(T1, "Hochzeit");
|
||||||
when(ollamaClient.parse(anyString()))
|
when(ollamaClient.parse(anyString()))
|
||||||
.thenReturn(extraction(List.of("Walter"), "any", null, null, List.of("Hochzeit")));
|
.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));
|
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||||
|
|
||||||
NlSearchResponse resp = service.search("Briefe von Walter über Hochzeit", PAGE);
|
NlSearchResponse resp = service.search("Briefe von Walter über Hochzeit", PAGE);
|
||||||
|
|||||||
Reference in New Issue
Block a user