diff --git a/symphony-bdk-core/build.gradle b/symphony-bdk-core/build.gradle index 1d857c67c..4ed4b12df 100644 --- a/symphony-bdk-core/build.gradle +++ b/symphony-bdk-core/build.gradle @@ -80,7 +80,7 @@ dependencies { } // OpenAPI code generation -def apiBaseUrl = "https://raw.githubusercontent.com/finos/symphony-api-spec/a0b152881a0405b7510cfc7e3a9698e8cb8c566c" +def apiBaseUrl = "https://raw.githubusercontent.com/finos/symphony-api-spec/8aeca61d3724e040a93fbf4e4ddb13800174d6ea" def generatedFolder = "${layout.buildDirectory.get()}/generated/openapi" def apisToGenerate = [ Agent: 'agent/agent-api-public-deprecated.yaml', diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/extension/MessageRetrieverOverride.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/extension/MessageRetrieverOverride.java index a92c2f290..c08b5d58c 100644 --- a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/extension/MessageRetrieverOverride.java +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/extension/MessageRetrieverOverride.java @@ -47,5 +47,16 @@ List searchMessages(AuthSession session, MessageSearchQuery query, List searchMessagesSemantic(AuthSession session, String query, @Nullable String streamId, @Nullable PaginationAttribute pagination) throws Exception; + /** + * Semantic search restricted to a date range, called only when {@code from} or {@code to} is set. + * + *

The default implementation rejects the call, so that overrides predating the date range never + * silently return messages outside of it. + */ + default List searchMessagesSemantic(AuthSession session, String query, @Nullable String streamId, + @Nullable Instant from, @Nullable Instant to, @Nullable PaginationAttribute pagination) throws Exception { + throw new UnsupportedOperationException("Semantic search with a date range is not supported by this override"); + } + V4Message getMessage(AuthSession session, String messageId) throws Exception; } diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/MessageService.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/MessageService.java index 2720ab5c1..a6e204a13 100644 --- a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/MessageService.java +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/MessageService.java @@ -344,14 +344,30 @@ public List searchMessagesSemantic(String query, @Nullable Pagination @Override public List searchMessagesSemantic(String query, @Nullable String streamId, @Nullable PaginationAttribute pagination) { + return searchMessagesSemantic(query, streamId, null, null, pagination); + } + + /** + * {@inheritDoc} + */ + @Override + public List searchMessagesSemantic(String query, @Nullable String streamId, @Nullable Instant from, + @Nullable Instant to, @Nullable PaginationAttribute pagination) { + if (from != null && to != null && !from.isBefore(to)) { + throw new IllegalArgumentException("The 'from' instant must be before the 'to' instant."); + } if (retrieverOverride != null) { + // overrides predating the date range only implement the search without it return executeAndRetry("searchMessagesSemantic", messagesApi.getApiClient().getBasePath(), - () -> wrapOverrideException( - () -> retrieverOverride.searchMessagesSemantic(authSession, query, streamId, pagination))); + () -> wrapOverrideException(() -> from == null && to == null + ? retrieverOverride.searchMessagesSemantic(authSession, query, streamId, pagination) + : retrieverOverride.searchMessagesSemantic(authSession, query, streamId, from, to, pagination))); } final SemanticSearchQuery searchQuery = new SemanticSearchQuery() .text(query) - .threadId(streamId != null ? toUrlSafeIdIfNeeded(streamId) : null); + .threadId(streamId != null ? toUrlSafeIdIfNeeded(streamId) : null) + .fromDate(from != null ? from.toEpochMilli() : null) + .toDate(to != null ? to.toEpochMilli() : null); return executeAndRetry("searchMessagesSemantic", messagesApi.getApiClient().getBasePath(), () -> messagesApi.v4MessageSearchSemanticPost( pagination != null ? pagination.getSkip() : null, diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/OboMessageService.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/OboMessageService.java index 802fba9a0..745651e19 100644 --- a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/OboMessageService.java +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/OboMessageService.java @@ -281,4 +281,21 @@ public interface OboMessageService { */ List searchMessagesSemantic(String query, @Nullable String streamId, @Nullable PaginationAttribute pagination); + + /** + * Searches for messages using a natural-language semantic query. Unlike the keyword based + * {@code /v1/message/search} endpoint, the query is interpreted by meaning rather than matched as + * {@code field:value} pairs. + * + * @param query the natural-language search query. + * @param streamId the stream to restrict the search to. If {@code null}, all accessible streams are searched. + * @param from only messages sent on or after this instant are returned. If {@code null}, no lower bound. + * @param to only messages sent before this instant are returned. If {@code null}, no upper bound. + * @param pagination the skip and limit for pagination. + * @return the list of matching messages. + * @throws IllegalArgumentException if {@code from} is not before {@code to}. + * @see Semantic search messages + */ + List searchMessagesSemantic(String query, @Nullable String streamId, @Nullable Instant from, + @Nullable Instant to, @Nullable PaginationAttribute pagination); } diff --git a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/service/message/MessageServiceTest.java b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/service/message/MessageServiceTest.java index bda085b88..4d716a411 100644 --- a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/service/message/MessageServiceTest.java +++ b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/service/message/MessageServiceTest.java @@ -3,6 +3,7 @@ import static com.symphony.bdk.core.util.IdUtil.fromUrlSafeId; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -38,6 +39,7 @@ import com.symphony.bdk.gen.api.model.MessageSearchQuery; import com.symphony.bdk.gen.api.model.MessageStatus; import com.symphony.bdk.gen.api.model.MessageSuppressionResponse; +import com.symphony.bdk.gen.api.model.SemanticSearchQuery; import com.symphony.bdk.gen.api.model.StreamAttachmentItem; import com.symphony.bdk.gen.api.model.V4ImportResponse; import com.symphony.bdk.gen.api.model.V4Message; @@ -290,6 +292,82 @@ void testSearchMessagesSemanticWithAllParameters() throws IOException { messages.stream().map(V4Message::getMessageId).collect(Collectors.toList())); } + @Test + void testSearchMessagesSemanticWithDateRange() throws Exception { + MessagesApi spiedMessagesApi = spy(new MessagesApi(mockApiClient.getApiClient("/agent"))); + MessageService service = new MessageService(spiedMessagesApi, messageApi, messageSuppressionApi, streamsApi, + podApi, attachmentsApi, defaultApi, authSession, templateEngine, new RetryWithRecoveryBuilder<>()); + mockApiClient.onPost(V4_SEARCH_MESSAGES_SEMANTIC, + JsonHelper.readFromClasspath("/message/get_message_stream_id.json")); + final Instant from = Instant.parse("2026-07-23T00:00:00Z"); + final Instant to = Instant.parse("2026-07-26T00:00:00Z"); + + final List messages = service.searchMessagesSemantic("query", null, from, to, null); + + assertEquals(2, messages.size()); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(SemanticSearchQuery.class); + verify(spiedMessagesApi).v4MessageSearchSemanticPost(isNull(), isNull(), eq(TOKEN), eq(TOKEN), + queryCaptor.capture()); + assertEquals("query", queryCaptor.getValue().getText()); + assertEquals(1784764800000L, queryCaptor.getValue().getFromDate()); + assertEquals(1785024000000L, queryCaptor.getValue().getToDate()); + } + + @Test + void testSearchMessagesSemanticWithOpenEndedDateRange() throws Exception { + MessagesApi spiedMessagesApi = spy(new MessagesApi(mockApiClient.getApiClient("/agent"))); + MessageService service = new MessageService(spiedMessagesApi, messageApi, messageSuppressionApi, streamsApi, + podApi, attachmentsApi, defaultApi, authSession, templateEngine, new RetryWithRecoveryBuilder<>()); + mockApiClient.onPost(V4_SEARCH_MESSAGES_SEMANTIC, + JsonHelper.readFromClasspath("/message/get_message_stream_id.json")); + final Instant from = Instant.parse("2026-07-23T00:00:00Z"); + + service.searchMessagesSemantic("query", STREAM_ID, from, null, new PaginationAttribute(0, 10)); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(SemanticSearchQuery.class); + verify(spiedMessagesApi).v4MessageSearchSemanticPost(eq(0), eq(10), eq(TOKEN), eq(TOKEN), + queryCaptor.capture()); + assertEquals(1784764800000L, queryCaptor.getValue().getFromDate()); + assertNull(queryCaptor.getValue().getToDate()); + } + + @Test + void testSearchMessagesSemanticWithoutDateRangeSendsNoDates() throws Exception { + MessagesApi spiedMessagesApi = spy(new MessagesApi(mockApiClient.getApiClient("/agent"))); + MessageService service = new MessageService(spiedMessagesApi, messageApi, messageSuppressionApi, streamsApi, + podApi, attachmentsApi, defaultApi, authSession, templateEngine, new RetryWithRecoveryBuilder<>()); + mockApiClient.onPost(V4_SEARCH_MESSAGES_SEMANTIC, + JsonHelper.readFromClasspath("/message/get_message_stream_id.json")); + + service.searchMessagesSemantic("query", STREAM_ID, null); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(SemanticSearchQuery.class); + verify(spiedMessagesApi).v4MessageSearchSemanticPost(isNull(), isNull(), eq(TOKEN), eq(TOKEN), + queryCaptor.capture()); + assertNull(queryCaptor.getValue().getFromDate()); + assertNull(queryCaptor.getValue().getToDate()); + } + + @Test + void testSearchMessagesSemanticWithInvalidDateRangeThrows() { + final Instant date = Instant.parse("2026-07-23T00:00:00Z"); + + assertThrows(IllegalArgumentException.class, + () -> messageService.searchMessagesSemantic("query", null, date, date, null)); + assertThrows(IllegalArgumentException.class, + () -> messageService.searchMessagesSemantic("query", null, date.plusSeconds(1), date, null)); + } + + @Test + void testSearchMessagesSemanticWithStreamDelegatesWithoutDateRange() { + MessageService service = spy(messageService); + doReturn(Collections.emptyList()).when(service) + .searchMessagesSemantic(anyString(), anyString(), isNull(), isNull(), isNull()); + + assertNotNull(service.searchMessagesSemantic("query", STREAM_ID, null)); + verify(service).searchMessagesSemantic("query", STREAM_ID, null, null, null); + } + @Test void testSearchMessagesSemanticSimpleDelegates() { MessageService service = spy(messageService); @@ -956,6 +1034,59 @@ void shouldDelegateSearchMessagesSemanticToOverrideWhenRegistered() throws Excep verify(override).searchMessagesSemantic(eq(authSession), eq("query"), isNull(), isNull()); } + @Test + void shouldDelegateSearchMessagesSemanticWithDateRangeToOverrideWhenRegistered() throws Exception { + com.symphony.bdk.core.extension.MessageRetrieverOverride override = + mock(com.symphony.bdk.core.extension.MessageRetrieverOverride.class); + final Instant from = Instant.parse("2026-07-23T00:00:00Z"); + final Instant to = Instant.parse("2026-07-26T00:00:00Z"); + V4Message expected = new V4Message().messageId("override-semantic-msg"); + when(override.searchMessagesSemantic(eq(authSession), eq("query"), isNull(), eq(from), eq(to), isNull())) + .thenReturn(Collections.singletonList(expected)); + + MessageService serviceWithOverride = new MessageService(messagesApi, messageApi, messageSuppressionApi, streamsApi, + podApi, attachmentsApi, defaultApi, authSession, templateEngine, new RetryWithRecoveryBuilder<>(), null, + override); + + List result = serviceWithOverride.searchMessagesSemantic("query", null, from, to, null); + + assertEquals(Collections.singletonList("override-semantic-msg"), + result.stream().map(V4Message::getMessageId).collect(Collectors.toList())); + verify(override).searchMessagesSemantic(eq(authSession), eq("query"), isNull(), eq(from), eq(to), isNull()); + } + + @Test + void overrideWithoutDateRangeSupport_rejectsSemanticSearchWithDateRange() { + com.symphony.bdk.core.extension.MessageRetrieverOverride override = + new com.symphony.bdk.core.extension.MessageRetrieverOverride() { + @Override + public List listMessages(AuthSession session, String streamId, Instant since, Instant until, + PaginationAttribute pagination) { + return Collections.emptyList(); + } + + @Override + public List searchMessages(AuthSession session, MessageSearchQuery query, + PaginationAttribute pagination, com.symphony.bdk.core.service.message.model.SortDir sortDir) { + return Collections.emptyList(); + } + + @Override + public List searchMessagesSemantic(AuthSession session, String query, String streamId, + PaginationAttribute pagination) { + return Collections.emptyList(); + } + + @Override + public V4Message getMessage(AuthSession session, String messageId) { + return null; + } + }; + + assertThrows(UnsupportedOperationException.class, () -> override.searchMessagesSemantic(authSession, "query", + null, Instant.parse("2026-07-23T00:00:00Z"), null, null)); + } + @Test void shouldDelegateGetMessageToOverrideWhenRegistered() throws Exception { com.symphony.bdk.core.extension.MessageRetrieverOverride override =