fix(personal): harden retrieval edge cases

This commit is contained in:
2026-07-12 10:16:58 +08:00
parent 5a66c3ee34
commit 9ecb2fba41
4 changed files with 73 additions and 8 deletions
@@ -15,6 +15,7 @@ import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.DateTimeException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
@@ -205,9 +206,7 @@ public class PersonalRetrievalService {
|| request.queryText().trim().length() > 1000) {
throw new IllegalArgumentException("PERSONAL_SEARCH_QUERY_INVALID");
}
if (request.dateFrom() != null && request.dateTo() != null && request.dateFrom().isAfter(request.dateTo())) {
throw new IllegalArgumentException("PERSONAL_SEARCH_DATE_RANGE_INVALID");
}
validateDates(request.dateFrom(), request.dateTo());
List<Long> itemIds = request.itemIds() == null ? List.of() : request.itemIds().stream().distinct().toList();
if (itemIds.size() > 100 || itemIds.stream().anyMatch(id -> id == null || id <= 0)) {
throw new IllegalArgumentException("PERSONAL_SEARCH_ITEM_SCOPE_INVALID");
@@ -231,6 +230,19 @@ public class PersonalRetrievalService {
args.addAll(itemIds);
}
private static void validateDates(LocalDate dateFrom, LocalDate dateTo) {
if (dateFrom != null && dateTo != null && dateFrom.isAfter(dateTo)) {
throw new IllegalArgumentException("PERSONAL_SEARCH_DATE_INVALID");
}
if (dateTo != null) {
try {
dateTo.plusDays(1);
} catch (DateTimeException ex) {
throw new IllegalArgumentException("PERSONAL_SEARCH_DATE_INVALID");
}
}
}
private static String excerpt(String content) {
if (content == null) {
return "";
@@ -15,6 +15,7 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -135,9 +136,7 @@ public class PersonalVectorStore {
requireOwner(owner);
ArrayNode vector = parseVector(vectorJson);
validateDimension(vector.size());
if (dateFrom != null && dateTo != null && dateFrom.isAfter(dateTo)) {
throw new IllegalArgumentException("PERSONAL_VECTOR_DATE_RANGE_INVALID");
}
validateDates(dateFrom, dateTo);
List<Long> scopedItems = itemIds == null ? List.of() : itemIds.stream().distinct().toList();
if (scopedItems.size() > 100 || scopedItems.stream().anyMatch(id -> id == null || id <= 0)) {
throw new IllegalArgumentException("PERSONAL_VECTOR_ITEM_SCOPE_INVALID");
@@ -239,7 +238,7 @@ public class PersonalVectorStore {
}
return new CollectionMetadata(size.asInt(), result.path("payload_schema"));
} catch (Exception ex) {
log.warn("event=personal_vector_transport_failed exception={}", ex.getClass().getSimpleName());
log.warn("event=personal_vector_collection_metadata_invalid exception={}", ex.getClass().getSimpleName());
throw unavailable();
}
}
@@ -308,6 +307,19 @@ public class PersonalVectorStore {
}
}
private static void validateDates(LocalDate dateFrom, LocalDate dateTo) {
if (dateFrom != null && dateTo != null && dateFrom.isAfter(dateTo)) {
throw new IllegalArgumentException("PERSONAL_SEARCH_DATE_INVALID");
}
if (dateTo != null) {
try {
dateTo.plusDays(1);
} catch (DateTimeException ex) {
throw new IllegalArgumentException("PERSONAL_SEARCH_DATE_INVALID");
}
}
}
private void requireOwner(PersonalOwner owner) {
if (owner == null || owner.tenantId() == null || owner.tenantId().isBlank() || owner.userId() <= 0) {
log.warn("event=personal_vector_owner_invalid");
@@ -330,6 +342,10 @@ public class PersonalVectorStore {
return transport.send(new TransportRequest(method, path,
body == null ? "" : objectMapper.writeValueAsString(body), Map.copyOf(headers)));
} catch (Exception ex) {
if (ex instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
log.warn("event=personal_vector_transport_failed exception={}", ex.getClass().getSimpleName());
throw unavailable();
}
}
@@ -59,8 +59,10 @@ class PersonalRetrievalServiceTest {
PersonalOwner owner = new PersonalOwner("t", 1, null);
assertTrue(service.search(owner, new PersonalSearchRequest("q", List.of(SearchScope.ENTERPRISE), null, null, null, 10)).isEmpty());
assertThrows(IllegalArgumentException.class, () -> service.search(owner, new PersonalSearchRequest(" ", null, null, null, null, 10)));
assertThrows(IllegalArgumentException.class, () -> service.search(owner, new PersonalSearchRequest("q", null,
assertDateInvalid(() -> service.search(owner, new PersonalSearchRequest("q", null,
LocalDate.of(2026, 2, 1), LocalDate.of(2026, 1, 1), null, 10)));
assertDateInvalid(() -> service.search(owner, new PersonalSearchRequest("q", null,
null, LocalDate.MAX, null, 10)));
verifyNoInteractions(jdbc);
}
@@ -166,4 +168,9 @@ class PersonalRetrievalServiceTest {
private SearchHitResponse hit(String id, String title) {
return new SearchHitResponse("PERSONAL", id, title, title + " excerpt", LocalDateTime.of(2026, 1, 1, 0, 0), 1);
}
private void assertDateInvalid(org.junit.jupiter.api.function.Executable executable) {
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, executable);
assertEquals("PERSONAL_SEARCH_DATE_INVALID", error.getMessage());
}
}
@@ -144,6 +144,36 @@ class PersonalVectorStoreTest {
assertEquals(2, posts.get());
}
@Test
void rejectsOverflowingAndReversedDatesBeforeHttp() {
List<PersonalVectorStore.TransportRequest> seen = new ArrayList<>();
PersonalVectorStore store = fixture(seen, request -> ok("{}"));
PersonalOwner owner = new PersonalOwner("t", 1, null);
for (List<LocalDate> dates : List.of(
java.util.Arrays.asList(null, LocalDate.MAX),
List.of(LocalDate.of(2026, 2, 1), LocalDate.of(2026, 1, 1)))) {
IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
() -> store.query(owner, "[1,2]", 5, dates.get(0), dates.get(1), List.of()));
assertEquals("PERSONAL_SEARCH_DATE_INVALID", error.getMessage());
}
assertTrue(seen.isEmpty());
}
@Test
void preservesThreadInterruptWhenTransportIsInterrupted() {
PersonalVectorStore store = PersonalVectorStore.forTest(properties(), mapper, request -> {
throw new InterruptedException("stop");
});
try {
assertTrue(store.query(new PersonalOwner("t", 1, null), "[1,2]", 5).isEmpty());
assertTrue(Thread.currentThread().isInterrupted());
} finally {
Thread.interrupted();
}
assertFalse(Thread.currentThread().isInterrupted());
}
@Test
void preservesConfiguredQdrantBasePathPrefix() {
PersonalKnowledgeProperties properties = properties();