From 11b8b8aeab233a65ca3bd4975c5128508f005ed1 Mon Sep 17 00:00:00 2001 From: leijendary Date: Fri, 28 Mar 2025 14:33:07 +0100 Subject: [PATCH] feat: JDBC implementation of ChatMemory Signed-off-by: leijendary --- README.md | 2 +- .../pom.xml | 77 +++++++ .../JdbcChatMemoryAutoConfiguration.java | 65 ++++++ ...ryDataSourceScriptDatabaseInitializer.java | 35 +++ .../JdbcChatMemoryProperties.java | 40 ++++ ...ot.autoconfigure.AutoConfiguration.imports | 16 ++ .../JdbcChatMemoryAutoConfigurationIT.java | 101 +++++++++ ...aSourceScriptDatabaseInitializerTests.java | 51 +++++ .../JdbcChatMemoryPropertiesTests.java | 43 ++++ .../pom.xml | 4 +- .../README.md | 1 + .../spring-ai-model-chat-memory-jdbc/pom.xml | 95 ++++++++ .../ai/chat/memory/jdbc/JdbcChatMemory.java | 107 +++++++++ .../memory/jdbc/JdbcChatMemoryConfig.java | 66 ++++++ .../aot/hint/JdbcChatMemoryRuntimeHints.java | 26 +++ .../resources/META-INF/spring/aot.factories | 2 + .../ai/chat/memory/jdbc/schema-mariadb.sql | 10 + .../ai/chat/memory/jdbc/schema-postgresql.sql | 9 + .../memory/jdbc/JdbcChatMemoryConfigTest.java | 34 +++ .../ai/chat/memory/jdbc/JdbcChatMemoryIT.java | 211 ++++++++++++++++++ .../hint/JdbcChatMemoryRuntimeHintsTest.java | 82 +++++++ pom.xml | 12 +- spring-ai-bom/pom.xml | 18 +- .../modules/ROOT/pages/api/chatclient.adoc | 110 ++++++--- .../pom.xml | 58 +++++ 25 files changed, 1231 insertions(+), 44 deletions(-) create mode 100644 auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/pom.xml create mode 100644 auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/main/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryAutoConfiguration.java create mode 100644 auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/main/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryDataSourceScriptDatabaseInitializer.java create mode 100644 auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/main/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryProperties.java create mode 100644 auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/test/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryAutoConfigurationIT.java create mode 100644 auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/test/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryDataSourceScriptDatabaseInitializerTests.java create mode 100644 auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/test/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryPropertiesTests.java create mode 100644 memory/spring-ai-model-chat-memory-jdbc/README.md create mode 100644 memory/spring-ai-model-chat-memory-jdbc/pom.xml create mode 100644 memory/spring-ai-model-chat-memory-jdbc/src/main/java/org/springframework/ai/chat/memory/jdbc/JdbcChatMemory.java create mode 100644 memory/spring-ai-model-chat-memory-jdbc/src/main/java/org/springframework/ai/chat/memory/jdbc/JdbcChatMemoryConfig.java create mode 100644 memory/spring-ai-model-chat-memory-jdbc/src/main/java/org/springframework/ai/chat/memory/jdbc/aot/hint/JdbcChatMemoryRuntimeHints.java create mode 100644 memory/spring-ai-model-chat-memory-jdbc/src/main/resources/META-INF/spring/aot.factories create mode 100644 memory/spring-ai-model-chat-memory-jdbc/src/main/resources/org/springframework/ai/chat/memory/jdbc/schema-mariadb.sql create mode 100644 memory/spring-ai-model-chat-memory-jdbc/src/main/resources/org/springframework/ai/chat/memory/jdbc/schema-postgresql.sql create mode 100644 memory/spring-ai-model-chat-memory-jdbc/src/test/java/org/springframework/ai/chat/memory/jdbc/JdbcChatMemoryConfigTest.java create mode 100644 memory/spring-ai-model-chat-memory-jdbc/src/test/java/org/springframework/ai/chat/memory/jdbc/JdbcChatMemoryIT.java create mode 100644 memory/spring-ai-model-chat-memory-jdbc/src/test/java/org/springframework/ai/chat/memory/jdbc/aot/hint/JdbcChatMemoryRuntimeHintsTest.java create mode 100644 spring-ai-spring-boot-starters/spring-ai-starter-model-chat-memory-jdbc/pom.xml diff --git a/README.md b/README.md index 1f53d37cdc4..c77b604d526 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ One way to run integration tests on part of the code is to first do a quick comp ``` Then run the integration test for a specific module using the `-pl` option ```shell -./mvnw verify -Pintegration-tests -pl spring-ai-spring-boot-testcontainers +./mvnw verify -Pintegration-tests -pl spring-ai-spring-boot-testcontainers ``` ### Documentation diff --git a/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/pom.xml b/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/pom.xml new file mode 100644 index 00000000000..69116d535b6 --- /dev/null +++ b/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/pom.xml @@ -0,0 +1,77 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai-parent + 1.0.0-SNAPSHOT + ../../../../../pom.xml + + spring-ai-autoconfigure-model-chat-memory-jdbc + jar + Spring AI JDBC Chat Memory Auto Configuration + Spring JDBC AI Chat Memory Auto Configuration + https://github.com/spring-projects/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + + + org.springframework.ai + spring-ai-core + ${project.parent.version} + + + + org.springframework.ai + spring-ai-model-chat-memory-jdbc + ${project.parent.version} + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.postgresql + postgresql + ${postgresql.version} + test + + + + org.testcontainers + junit-jupiter + test + + + + org.testcontainers + postgresql + test + + + + diff --git a/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/main/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryAutoConfiguration.java b/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/main/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryAutoConfiguration.java new file mode 100644 index 00000000000..bc811c3ded6 --- /dev/null +++ b/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/main/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryAutoConfiguration.java @@ -0,0 +1,65 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.chat.memory.jdbc.autoconfigure; + +import javax.sql.DataSource; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.chat.memory.jdbc.JdbcChatMemory; +import org.springframework.ai.chat.memory.jdbc.JdbcChatMemoryConfig; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.jdbc.init.DataSourceScriptDatabaseInitializer; +import org.springframework.context.annotation.Bean; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * @author Jonathan Leijendekker + * @since 1.0.0 + */ +@AutoConfiguration(after = JdbcTemplateAutoConfiguration.class) +@ConditionalOnClass({ JdbcChatMemory.class, DataSource.class, JdbcTemplate.class }) +@EnableConfigurationProperties(JdbcChatMemoryProperties.class) +public class JdbcChatMemoryAutoConfiguration { + + private static final Logger logger = LoggerFactory.getLogger(JdbcChatMemoryAutoConfiguration.class); + + @Bean + @ConditionalOnMissingBean + public JdbcChatMemory chatMemory(JdbcTemplate jdbcTemplate) { + var config = JdbcChatMemoryConfig.builder().jdbcTemplate(jdbcTemplate).build(); + + return JdbcChatMemory.create(config); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(value = "spring.ai.chat.memory.jdbc.initialize-schema", havingValue = "true", + matchIfMissing = true) + public DataSourceScriptDatabaseInitializer jdbcChatMemoryScriptDatabaseInitializer(DataSource dataSource) { + logger.debug("Initializing JdbcChatMemory schema"); + + return new JdbcChatMemoryDataSourceScriptDatabaseInitializer(dataSource); + } + +} diff --git a/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/main/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryDataSourceScriptDatabaseInitializer.java b/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/main/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryDataSourceScriptDatabaseInitializer.java new file mode 100644 index 00000000000..2f1927048df --- /dev/null +++ b/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/main/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryDataSourceScriptDatabaseInitializer.java @@ -0,0 +1,35 @@ +package org.springframework.ai.model.chat.memory.jdbc.autoconfigure; + +import java.util.List; + +import javax.sql.DataSource; + +import org.springframework.boot.jdbc.init.DataSourceScriptDatabaseInitializer; +import org.springframework.boot.jdbc.init.PlatformPlaceholderDatabaseDriverResolver; +import org.springframework.boot.sql.init.DatabaseInitializationMode; +import org.springframework.boot.sql.init.DatabaseInitializationSettings; + +class JdbcChatMemoryDataSourceScriptDatabaseInitializer extends DataSourceScriptDatabaseInitializer { + + private static final String SCHEMA_LOCATION = "classpath:org/springframework/ai/chat/memory/jdbc/schema-@@platform@@.sql"; + + public JdbcChatMemoryDataSourceScriptDatabaseInitializer(DataSource dataSource) { + super(dataSource, getSettings(dataSource)); + } + + static DatabaseInitializationSettings getSettings(DataSource dataSource) { + var settings = new DatabaseInitializationSettings(); + settings.setSchemaLocations(resolveSchemaLocations(dataSource)); + settings.setMode(DatabaseInitializationMode.ALWAYS); + settings.setContinueOnError(true); + + return settings; + } + + static List resolveSchemaLocations(DataSource dataSource) { + var platformResolver = new PlatformPlaceholderDatabaseDriverResolver(); + + return platformResolver.resolveAll(dataSource, SCHEMA_LOCATION); + } + +} diff --git a/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/main/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryProperties.java b/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/main/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryProperties.java new file mode 100644 index 00000000000..1c33ffbb0a5 --- /dev/null +++ b/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/main/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryProperties.java @@ -0,0 +1,40 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.chat.memory.jdbc.autoconfigure; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author Jonathan Leijendekker + * @since 1.0.0 + */ +@ConfigurationProperties(JdbcChatMemoryProperties.CONFIG_PREFIX) +public class JdbcChatMemoryProperties { + + public static final String CONFIG_PREFIX = "spring.ai.chat.memory.jdbc"; + + private boolean initializeSchema = true; + + public boolean isInitializeSchema() { + return this.initializeSchema; + } + + public void setInitializeSchema(boolean initializeSchema) { + this.initializeSchema = initializeSchema; + } + +} diff --git a/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..6820c4237de --- /dev/null +++ b/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,16 @@ +# +# Copyright 2024-2025 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +org.springframework.ai.model.chat.memory.jdbc.autoconfigure.JdbcChatMemoryAutoConfiguration diff --git a/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/test/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryAutoConfigurationIT.java b/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/test/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryAutoConfigurationIT.java new file mode 100644 index 00000000000..6f9573d3eb0 --- /dev/null +++ b/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/test/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryAutoConfigurationIT.java @@ -0,0 +1,101 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.chat.memory.jdbc.autoconfigure; + +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import org.springframework.ai.chat.memory.jdbc.JdbcChatMemory; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Jonathan Leijendekker + */ +@Testcontainers +class JdbcChatMemoryAutoConfigurationIT { + + static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("postgres:17"); + + @Container + @SuppressWarnings("resource") + static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>(DEFAULT_IMAGE_NAME) + .withDatabaseName("chat_memory_auto_configuration_test") + .withUsername("postgres") + .withPassword("postgres"); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(JdbcChatMemoryAutoConfiguration.class, + JdbcTemplateAutoConfiguration.class, DataSourceAutoConfiguration.class)) + .withPropertyValues(String.format("spring.datasource.url=%s", postgresContainer.getJdbcUrl()), + String.format("spring.datasource.username=%s", postgresContainer.getUsername()), + String.format("spring.datasource.password=%s", postgresContainer.getPassword())); + + @Test + void jdbcChatMemoryScriptDatabaseInitializer_shouldBeLoaded() { + this.contextRunner.withPropertyValues("spring.ai.chat.memory.jdbc.initialize-schema=true").run(context -> { + assertThat(context.containsBean("jdbcChatMemoryScriptDatabaseInitializer")).isTrue(); + }); + } + + @Test + void jdbcChatMemoryScriptDatabaseInitializer_shouldNotBeLoaded() { + this.contextRunner.withPropertyValues("spring.ai.chat.memory.jdbc.initialize-schema=false").run(context -> { + assertThat(context.containsBean("jdbcChatMemoryScriptDatabaseInitializer")).isFalse(); + }); + } + + @Test + void addGetAndClear_shouldAllExecute() { + this.contextRunner.withPropertyValues("spring.ai.chat.memory.jdbc.initialize-schema=true").run(context -> { + var chatMemory = context.getBean(JdbcChatMemory.class); + var conversationId = UUID.randomUUID().toString(); + var userMessage = new UserMessage("Message from the user"); + + chatMemory.add(conversationId, userMessage); + + assertThat(chatMemory.get(conversationId, Integer.MAX_VALUE)).hasSize(1); + assertThat(chatMemory.get(conversationId, Integer.MAX_VALUE)).isEqualTo(List.of(userMessage)); + + chatMemory.clear(conversationId); + + assertThat(chatMemory.get(conversationId, Integer.MAX_VALUE)).isEmpty(); + + var multipleMessages = List.of(new UserMessage("Message from the user 1"), + new AssistantMessage("Message from the assistant 1")); + + chatMemory.add(conversationId, multipleMessages); + + assertThat(chatMemory.get(conversationId, Integer.MAX_VALUE)).hasSize(multipleMessages.size()); + assertThat(chatMemory.get(conversationId, Integer.MAX_VALUE)).isEqualTo(multipleMessages); + }); + } + +} diff --git a/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/test/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryDataSourceScriptDatabaseInitializerTests.java b/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/test/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryDataSourceScriptDatabaseInitializerTests.java new file mode 100644 index 00000000000..bcb1a9daacc --- /dev/null +++ b/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/test/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryDataSourceScriptDatabaseInitializerTests.java @@ -0,0 +1,51 @@ +package org.springframework.ai.model.chat.memory.jdbc.autoconfigure; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Jonathan Leijendekker + */ +@Testcontainers +class JdbcChatMemoryDataSourceScriptDatabaseInitializerTests { + + static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("postgres:17"); + + @Container + @SuppressWarnings("resource") + static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>(DEFAULT_IMAGE_NAME) + .withDatabaseName("chat_memory_initializer_test") + .withUsername("postgres") + .withPassword("postgres"); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(JdbcChatMemoryAutoConfiguration.class, + JdbcTemplateAutoConfiguration.class, DataSourceAutoConfiguration.class)) + .withPropertyValues(String.format("spring.datasource.url=%s", postgresContainer.getJdbcUrl()), + String.format("spring.datasource.username=%s", postgresContainer.getUsername()), + String.format("spring.datasource.password=%s", postgresContainer.getPassword())); + + @Test + void getSettings_shouldHaveSchemaLocations() { + this.contextRunner.run(context -> { + var dataSource = context.getBean(DataSource.class); + var settings = JdbcChatMemoryDataSourceScriptDatabaseInitializer.getSettings(dataSource); + + assertThat(settings.getSchemaLocations()) + .containsOnly("classpath:org/springframework/ai/chat/memory/jdbc/schema-postgresql.sql"); + }); + } + +} diff --git a/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/test/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryPropertiesTests.java b/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/test/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryPropertiesTests.java new file mode 100644 index 00000000000..196176149ff --- /dev/null +++ b/auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc/src/test/java/org/springframework/ai/model/chat/memory/jdbc/autoconfigure/JdbcChatMemoryPropertiesTests.java @@ -0,0 +1,43 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.chat.memory.jdbc.autoconfigure; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Jonathan Leijendekker + */ +class JdbcChatMemoryPropertiesTests { + + @Test + void defaultValues() { + var props = new JdbcChatMemoryProperties(); + + assertThat(props.isInitializeSchema()).isTrue(); + } + + @Test + void customValues() { + var props = new JdbcChatMemoryProperties(); + props.setInitializeSchema(false); + + assertThat(props.isInitializeSchema()).isFalse(); + } + +} diff --git a/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-milvus/pom.xml b/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-milvus/pom.xml index 6571316245c..dd9ce193b19 100644 --- a/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-milvus/pom.xml +++ b/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-milvus/pom.xml @@ -27,8 +27,8 @@ spring-ai-autoconfigure-vector-store-milvus jar - Spring AI Auto Configuration for Weaviate vector store - Spring AI Auto Configuration for Weaviate vector store + Spring AI Auto Configuration for Milvus vector store + Spring AI Auto Configuration for Mulvis vector store https://github.com/spring-projects/spring-ai diff --git a/memory/spring-ai-model-chat-memory-jdbc/README.md b/memory/spring-ai-model-chat-memory-jdbc/README.md new file mode 100644 index 00000000000..8e100ad20a3 --- /dev/null +++ b/memory/spring-ai-model-chat-memory-jdbc/README.md @@ -0,0 +1 @@ +[Chat Memory Documentation](https://docs.spring.io/spring-ai/reference/api/chatclient.html#_chat_memory) diff --git a/memory/spring-ai-model-chat-memory-jdbc/pom.xml b/memory/spring-ai-model-chat-memory-jdbc/pom.xml new file mode 100644 index 00000000000..98183015242 --- /dev/null +++ b/memory/spring-ai-model-chat-memory-jdbc/pom.xml @@ -0,0 +1,95 @@ + + + + + 4.0.0 + + org.springframework.ai + spring-ai-parent + 1.0.0-SNAPSHOT + ../../pom.xml + + + spring-ai-model-chat-memory-jdbc + Spring AI JDBC Chat Memory + Spring AI JDBC Chat Memory implementation + + + + org.springframework.ai + spring-ai-core + ${project.parent.version} + + + + org.springframework + spring-jdbc + + + + com.zaxxer + HikariCP + + + + org.postgresql + postgresql + ${postgresql.version} + true + + + + org.mariadb.jdbc + mariadb-java-client + ${mariadb.version} + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.testcontainers + testcontainers + test + + + + org.testcontainers + postgresql + test + + + + org.testcontainers + mariadb + test + + + + org.testcontainers + junit-jupiter + test + + + diff --git a/memory/spring-ai-model-chat-memory-jdbc/src/main/java/org/springframework/ai/chat/memory/jdbc/JdbcChatMemory.java b/memory/spring-ai-model-chat-memory-jdbc/src/main/java/org/springframework/ai/chat/memory/jdbc/JdbcChatMemory.java new file mode 100644 index 00000000000..477f7509a19 --- /dev/null +++ b/memory/spring-ai-model-chat-memory-jdbc/src/main/java/org/springframework/ai/chat/memory/jdbc/JdbcChatMemory.java @@ -0,0 +1,107 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.chat.memory.jdbc; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; + +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.messages.*; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +/** + * An implementation of {@link ChatMemory} for JDBC. Creating an instance of + * JdbcChatMemory example: + * JdbcChatMemory.create(JdbcChatMemoryConfig.builder().jdbcTemplate(jdbcTemplate).build()); + * + * @author Jonathan Leijendekker + * @since 1.0.0 + */ +public class JdbcChatMemory implements ChatMemory { + + private static final String QUERY_ADD = """ + INSERT INTO ai_chat_memory (conversation_id, content, type) VALUES (?, ?, ?)"""; + + private static final String QUERY_GET = """ + SELECT content, type FROM ai_chat_memory WHERE conversation_id = ? ORDER BY "timestamp" DESC LIMIT ?"""; + + private static final String QUERY_CLEAR = "DELETE FROM ai_chat_memory WHERE conversation_id = ?"; + + private final JdbcTemplate jdbcTemplate; + + public JdbcChatMemory(JdbcChatMemoryConfig config) { + this.jdbcTemplate = config.getJdbcTemplate(); + } + + public static JdbcChatMemory create(JdbcChatMemoryConfig config) { + return new JdbcChatMemory(config); + } + + @Override + public void add(String conversationId, List messages) { + this.jdbcTemplate.batchUpdate(QUERY_ADD, new AddBatchPreparedStatement(conversationId, messages)); + } + + @Override + public List get(String conversationId, int lastN) { + return this.jdbcTemplate.query(QUERY_GET, new MessageRowMapper(), conversationId, lastN); + } + + @Override + public void clear(String conversationId) { + this.jdbcTemplate.update(QUERY_CLEAR, conversationId); + } + + private record AddBatchPreparedStatement(String conversationId, + List messages) implements BatchPreparedStatementSetter { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + var message = this.messages.get(i); + + ps.setString(1, this.conversationId); + ps.setString(2, message.getText()); + ps.setString(3, message.getMessageType().name()); + } + + @Override + public int getBatchSize() { + return this.messages.size(); + } + } + + private static class MessageRowMapper implements RowMapper { + + @Override + public Message mapRow(ResultSet rs, int i) throws SQLException { + var content = rs.getString(1); + var type = MessageType.valueOf(rs.getString(2)); + + return switch (type) { + case USER -> new UserMessage(content); + case ASSISTANT -> new AssistantMessage(content); + case SYSTEM -> new SystemMessage(content); + default -> null; + }; + } + + } + +} diff --git a/memory/spring-ai-model-chat-memory-jdbc/src/main/java/org/springframework/ai/chat/memory/jdbc/JdbcChatMemoryConfig.java b/memory/spring-ai-model-chat-memory-jdbc/src/main/java/org/springframework/ai/chat/memory/jdbc/JdbcChatMemoryConfig.java new file mode 100644 index 00000000000..5a503aef051 --- /dev/null +++ b/memory/spring-ai-model-chat-memory-jdbc/src/main/java/org/springframework/ai/chat/memory/jdbc/JdbcChatMemoryConfig.java @@ -0,0 +1,66 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.chat.memory.jdbc; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.util.Assert; + +/** + * Configuration for {@link JdbcChatMemory}. + * + * @author Jonathan Leijendekker + * @since 1.0.0 + */ +public final class JdbcChatMemoryConfig { + + private final JdbcTemplate jdbcTemplate; + + private JdbcChatMemoryConfig(Builder builder) { + this.jdbcTemplate = builder.jdbcTemplate; + } + + public static Builder builder() { + return new Builder(); + } + + JdbcTemplate getJdbcTemplate() { + return this.jdbcTemplate; + } + + public static final class Builder { + + private JdbcTemplate jdbcTemplate; + + private Builder() { + } + + public Builder jdbcTemplate(JdbcTemplate jdbcTemplate) { + Assert.notNull(jdbcTemplate, "jdbc template must not be null"); + + this.jdbcTemplate = jdbcTemplate; + return this; + } + + public JdbcChatMemoryConfig build() { + Assert.notNull(this.jdbcTemplate, "jdbc template must not be null"); + + return new JdbcChatMemoryConfig(this); + } + + } + +} diff --git a/memory/spring-ai-model-chat-memory-jdbc/src/main/java/org/springframework/ai/chat/memory/jdbc/aot/hint/JdbcChatMemoryRuntimeHints.java b/memory/spring-ai-model-chat-memory-jdbc/src/main/java/org/springframework/ai/chat/memory/jdbc/aot/hint/JdbcChatMemoryRuntimeHints.java new file mode 100644 index 00000000000..eae3206f9a9 --- /dev/null +++ b/memory/spring-ai-model-chat-memory-jdbc/src/main/java/org/springframework/ai/chat/memory/jdbc/aot/hint/JdbcChatMemoryRuntimeHints.java @@ -0,0 +1,26 @@ +package org.springframework.ai.chat.memory.jdbc.aot.hint; + +import javax.sql.DataSource; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; + +/** + * A {@link RuntimeHintsRegistrar} for JDBC Chat Memory hints + * + * @author Jonathan Leijendekker + */ +class JdbcChatMemoryRuntimeHints implements RuntimeHintsRegistrar { + + @Override + public void registerHints(RuntimeHints hints, ClassLoader classLoader) { + hints.reflection() + .registerType(DataSource.class, (hint) -> hint.withMembers(MemberCategory.INVOKE_DECLARED_METHODS)); + + hints.resources() + .registerPattern("org/springframework/ai/chat/memory/jdbc/schema-mariadb.sql") + .registerPattern("org/springframework/ai/chat/memory/jdbc/schema-postgresql.sql"); + } + +} diff --git a/memory/spring-ai-model-chat-memory-jdbc/src/main/resources/META-INF/spring/aot.factories b/memory/spring-ai-model-chat-memory-jdbc/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 00000000000..4b6f4a8f5ce --- /dev/null +++ b/memory/spring-ai-model-chat-memory-jdbc/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1,2 @@ +org.springframework.aot.hint.RuntimeHintsRegistrar=\ +org.springframework.ai.chat.memory.jdbc.aot.hint.JdbcChatMemoryRuntimeHints diff --git a/memory/spring-ai-model-chat-memory-jdbc/src/main/resources/org/springframework/ai/chat/memory/jdbc/schema-mariadb.sql b/memory/spring-ai-model-chat-memory-jdbc/src/main/resources/org/springframework/ai/chat/memory/jdbc/schema-mariadb.sql new file mode 100644 index 00000000000..88c0ea11ba0 --- /dev/null +++ b/memory/spring-ai-model-chat-memory-jdbc/src/main/resources/org/springframework/ai/chat/memory/jdbc/schema-mariadb.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS ai_chat_memory ( + conversation_id VARCHAR(36) NOT NULL, + content TEXT NOT NULL, + type VARCHAR(10) NOT NULL, + `timestamp` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT type_check CHECK (type IN ('USER', 'ASSISTANT', 'SYSTEM', 'TOOL')) +); + +CREATE INDEX IF NOT EXISTS ai_chat_memory_conversation_id_timestamp_idx +ON ai_chat_memory(conversation_id, `timestamp` DESC); diff --git a/memory/spring-ai-model-chat-memory-jdbc/src/main/resources/org/springframework/ai/chat/memory/jdbc/schema-postgresql.sql b/memory/spring-ai-model-chat-memory-jdbc/src/main/resources/org/springframework/ai/chat/memory/jdbc/schema-postgresql.sql new file mode 100644 index 00000000000..11e60194b60 --- /dev/null +++ b/memory/spring-ai-model-chat-memory-jdbc/src/main/resources/org/springframework/ai/chat/memory/jdbc/schema-postgresql.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS ai_chat_memory ( + conversation_id VARCHAR(36) NOT NULL, + content TEXT NOT NULL, + type VARCHAR(10) NOT NULL CHECK (type IN ('USER', 'ASSISTANT', 'SYSTEM', 'TOOL')), + "timestamp" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS ai_chat_memory_conversation_id_timestamp_idx +ON ai_chat_memory(conversation_id, "timestamp" DESC); diff --git a/memory/spring-ai-model-chat-memory-jdbc/src/test/java/org/springframework/ai/chat/memory/jdbc/JdbcChatMemoryConfigTest.java b/memory/spring-ai-model-chat-memory-jdbc/src/test/java/org/springframework/ai/chat/memory/jdbc/JdbcChatMemoryConfigTest.java new file mode 100644 index 00000000000..7ae2c4477a1 --- /dev/null +++ b/memory/spring-ai-model-chat-memory-jdbc/src/test/java/org/springframework/ai/chat/memory/jdbc/JdbcChatMemoryConfigTest.java @@ -0,0 +1,34 @@ +package org.springframework.ai.chat.memory.jdbc; + +import org.junit.jupiter.api.Test; + +import org.springframework.jdbc.core.JdbcTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +/** + * @author Jonathan Leijendekker + */ +class JdbcChatMemoryConfigTest { + + @Test + void setValues() { + var jdbcTemplate = mock(JdbcTemplate.class); + var config = JdbcChatMemoryConfig.builder().jdbcTemplate(jdbcTemplate).build(); + + assertThat(config.getJdbcTemplate()).isEqualTo(jdbcTemplate); + } + + @Test + void setJdbcTemplateToNull_shouldThrow() { + assertThatThrownBy(() -> JdbcChatMemoryConfig.builder().jdbcTemplate(null)); + } + + @Test + void buildWithNullJdbcTemplate_shouldThrow() { + assertThatThrownBy(() -> JdbcChatMemoryConfig.builder().build()); + } + +} diff --git a/memory/spring-ai-model-chat-memory-jdbc/src/test/java/org/springframework/ai/chat/memory/jdbc/JdbcChatMemoryIT.java b/memory/spring-ai-model-chat-memory-jdbc/src/test/java/org/springframework/ai/chat/memory/jdbc/JdbcChatMemoryIT.java new file mode 100644 index 00000000000..1651bd49e87 --- /dev/null +++ b/memory/spring-ai-model-chat-memory-jdbc/src/test/java/org/springframework/ai/chat/memory/jdbc/JdbcChatMemoryIT.java @@ -0,0 +1,211 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.chat.memory.jdbc; + +import java.sql.Timestamp; +import java.util.List; +import java.util.UUID; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.MountableFile; + +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.messages.*; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.jdbc.core.JdbcTemplate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Jonathan Leijendekker + */ +@Testcontainers +class JdbcChatMemoryIT { + + @Container + @SuppressWarnings("resource") + static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>("postgres:17") + .withDatabaseName("chat_memory_test") + .withUsername("postgres") + .withPassword("postgres") + .withCopyFileToContainer( + MountableFile.forClasspathResource("org/springframework/ai/chat/memory/jdbc/schema-postgresql.sql"), + "/docker-entrypoint-initdb.d/schema.sql"); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(TestApplication.class) + .withPropertyValues(String.format("app.datasource.url=%s", postgresContainer.getJdbcUrl()), + String.format("app.datasource.username=%s", postgresContainer.getUsername()), + String.format("app.datasource.password=%s", postgresContainer.getPassword())); + + @BeforeAll + static void beforeAll() { + + } + + @Test + void correctChatMemoryInstance() { + this.contextRunner.run(context -> { + var chatMemory = context.getBean(ChatMemory.class); + + assertThat(chatMemory).isInstanceOf(JdbcChatMemory.class); + }); + } + + @ParameterizedTest + @CsvSource({ "Message from assistant,ASSISTANT", "Message from user,USER", "Message from system,SYSTEM" }) + void add_shouldInsertSingleMessage(String content, MessageType messageType) { + this.contextRunner.run(context -> { + var chatMemory = context.getBean(ChatMemory.class); + var conversationId = UUID.randomUUID().toString(); + var message = switch (messageType) { + case ASSISTANT -> new AssistantMessage(content + " - " + conversationId); + case USER -> new UserMessage(content + " - " + conversationId); + case SYSTEM -> new SystemMessage(content + " - " + conversationId); + default -> throw new IllegalArgumentException("Type not supported: " + messageType); + }; + + chatMemory.add(conversationId, message); + + var jdbcTemplate = context.getBean(JdbcTemplate.class); + var query = "SELECT conversation_id, content, type, \"timestamp\" FROM ai_chat_memory WHERE conversation_id = ?"; + var result = jdbcTemplate.queryForMap(query, conversationId); + + assertThat(result.size()).isEqualTo(4); + assertThat(result.get("conversation_id")).isEqualTo(conversationId); + assertThat(result.get("content")).isEqualTo(message.getText()); + assertThat(result.get("type")).isEqualTo(messageType.name()); + assertThat(result.get("timestamp")).isInstanceOf(Timestamp.class); + }); + } + + @Test + void add_shouldInsertMessages() { + this.contextRunner.run(context -> { + var chatMemory = context.getBean(ChatMemory.class); + var conversationId = UUID.randomUUID().toString(); + var messages = List.of(new AssistantMessage("Message from assistant - " + conversationId), + new UserMessage("Message from user - " + conversationId), + new SystemMessage("Message from system - " + conversationId)); + + chatMemory.add(conversationId, messages); + + var jdbcTemplate = context.getBean(JdbcTemplate.class); + var query = "SELECT conversation_id, content, type, \"timestamp\" FROM ai_chat_memory WHERE conversation_id = ?"; + var results = jdbcTemplate.queryForList(query, conversationId); + + assertThat(results.size()).isEqualTo(messages.size()); + + for (var i = 0; i < messages.size(); i++) { + var message = messages.get(i); + var result = results.get(i); + + assertThat(result.get("conversation_id")).isNotNull(); + assertThat(result.get("conversation_id")).isEqualTo(conversationId); + assertThat(result.get("content")).isEqualTo(message.getText()); + assertThat(result.get("type")).isEqualTo(message.getMessageType().name()); + assertThat(result.get("timestamp")).isInstanceOf(Timestamp.class); + } + }); + } + + @Test + void get_shouldReturnMessages() { + this.contextRunner.run(context -> { + var chatMemory = context.getBean(ChatMemory.class); + var conversationId = UUID.randomUUID().toString(); + var messages = List.of(new AssistantMessage("Message from assistant 1 - " + conversationId), + new AssistantMessage("Message from assistant 2 - " + conversationId), + new UserMessage("Message from user - " + conversationId), + new SystemMessage("Message from system - " + conversationId)); + + chatMemory.add(conversationId, messages); + + var results = chatMemory.get(conversationId, Integer.MAX_VALUE); + + assertThat(results.size()).isEqualTo(messages.size()); + assertThat(results).isEqualTo(messages); + }); + } + + @Test + void clear_shouldDeleteMessages() { + this.contextRunner.run(context -> { + var chatMemory = context.getBean(ChatMemory.class); + var conversationId = UUID.randomUUID().toString(); + var messages = List.of(new AssistantMessage("Message from assistant - " + conversationId), + new UserMessage("Message from user - " + conversationId), + new SystemMessage("Message from system - " + conversationId)); + + chatMemory.add(conversationId, messages); + + chatMemory.clear(conversationId); + + var jdbcTemplate = context.getBean(JdbcTemplate.class); + var count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM ai_chat_memory WHERE conversation_id = ?", + Integer.class, conversationId); + + assertThat(count).isZero(); + }); + } + + @SpringBootConfiguration + @EnableAutoConfiguration(exclude = { DataSourceAutoConfiguration.class }) + static class TestApplication { + + @Bean + public ChatMemory chatMemory(JdbcTemplate jdbcTemplate) { + var config = JdbcChatMemoryConfig.builder().jdbcTemplate(jdbcTemplate).build(); + + return JdbcChatMemory.create(config); + } + + @Bean + public JdbcTemplate jdbcTemplate(DataSource dataSource) { + return new JdbcTemplate(dataSource); + } + + @Bean + @Primary + @ConfigurationProperties("app.datasource") + public DataSourceProperties dataSourceProperties() { + return new DataSourceProperties(); + } + + @Bean + public DataSource dataSource(DataSourceProperties dataSourceProperties) { + return dataSourceProperties.initializeDataSourceBuilder().build(); + } + + } + +} diff --git a/memory/spring-ai-model-chat-memory-jdbc/src/test/java/org/springframework/ai/chat/memory/jdbc/aot/hint/JdbcChatMemoryRuntimeHintsTest.java b/memory/spring-ai-model-chat-memory-jdbc/src/test/java/org/springframework/ai/chat/memory/jdbc/aot/hint/JdbcChatMemoryRuntimeHintsTest.java new file mode 100644 index 00000000000..1a6c7ff2d83 --- /dev/null +++ b/memory/spring-ai-model-chat-memory-jdbc/src/test/java/org/springframework/ai/chat/memory/jdbc/aot/hint/JdbcChatMemoryRuntimeHintsTest.java @@ -0,0 +1,82 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.chat.memory.jdbc.aot.hint; + +import java.io.IOException; +import java.util.Arrays; +import java.util.stream.Stream; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.SpringFactoriesLoader; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Jonathan Leijendekker + */ +class JdbcChatMemoryRuntimeHintsTest { + + private final RuntimeHints hints = new RuntimeHints(); + + private final JdbcChatMemoryRuntimeHints jdbcChatMemoryRuntimeHints = new JdbcChatMemoryRuntimeHints(); + + @Test + void aotFactoriesContainsRegistrar() { + var match = SpringFactoriesLoader.forResourceLocation("META-INF/spring/aot.factories") + .load(RuntimeHintsRegistrar.class) + .stream() + .anyMatch((registrar) -> registrar instanceof JdbcChatMemoryRuntimeHints); + + assertThat(match).isTrue(); + } + + @ParameterizedTest + @MethodSource("getSchemaFileNames") + void jdbcSchemasHasHints(String schemaFileName) { + this.jdbcChatMemoryRuntimeHints.registerHints(this.hints, getClass().getClassLoader()); + + var predicate = RuntimeHintsPredicates.resource() + .forResource("org/springframework/ai/chat/memory/jdbc/" + schemaFileName); + + assertThat(predicate).accepts(this.hints); + } + + @Test + void dataSourceHasHints() { + this.jdbcChatMemoryRuntimeHints.registerHints(this.hints, getClass().getClassLoader()); + + assertThat(RuntimeHintsPredicates.reflection().onType(DataSource.class)).accepts(this.hints); + } + + private static Stream getSchemaFileNames() throws IOException { + var resources = new PathMatchingResourcePatternResolver() + .getResources("classpath*:org/springframework/ai/chat/memory/jdbc/schema-*.sql"); + + return Arrays.stream(resources).map(Resource::getFilename); + } + +} diff --git a/pom.xml b/pom.xml index df01bc41373..6a1a5e5de9e 100644 --- a/pom.xml +++ b/pom.xml @@ -40,8 +40,9 @@ spring-ai-rag advisors/spring-ai-advisors-vector-store - memory/spring-ai-model-chat-memory-neo4j memory/spring-ai-model-chat-memory-cassandra + memory/spring-ai-model-chat-memory-jdbc + memory/spring-ai-model-chat-memory-neo4j auto-configurations/common/spring-ai-autoconfigure-retry @@ -50,6 +51,7 @@ auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-cassandra + auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-jdbc auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-neo4j auto-configurations/models/chat/observation/spring-ai-autoconfigure-model-chat-observation @@ -179,6 +181,7 @@ spring-ai-spring-boot-starters/spring-ai-starter-model-azure-openai spring-ai-spring-boot-starters/spring-ai-starter-model-bedrock spring-ai-spring-boot-starters/spring-ai-starter-model-bedrock-converse + spring-ai-spring-boot-starters/spring-ai-starter-model-chat-memory-jdbc spring-ai-spring-boot-starters/spring-ai-starter-model-huggingface spring-ai-spring-boot-starters/spring-ai-starter-model-minimax spring-ai-spring-boot-starters/spring-ai-starter-model-mistral-ai @@ -198,7 +201,7 @@ spring-ai-spring-boot-starters/spring-ai-starter-mcp-client spring-ai-spring-boot-starters/spring-ai-starter-mcp-server spring-ai-spring-boot-starters/spring-ai-starter-mcp-client-webflux - spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-webflux + spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-webflux spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-webmvc spring-ai-integration-tests @@ -293,6 +296,7 @@ 1.13.0 0.5.0 2.10.1 + 42.7.5 3.5.1 0.22.0 @@ -690,6 +694,9 @@ --> + + org.springframework.ai.chat.memory/**/*IT.java + org.springframework.ai.anthropic/**/*IT.java org.springframework.ai.azure.openai/**/*IT.java @@ -713,7 +720,6 @@ org.springframework.ai.vectorstore**/CosmosDB**IT.java org.springframework.ai.vectorstore.azure/**IT.java - org.springframework.ai.chat.memory/**/Cassandra**IT.java org.springframework.ai.vectorstore**/Cassandra**IT.java org.springframework.ai.chroma/**IT.java diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index 80f9a62ba31..1f1b0836bc4 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -148,13 +148,19 @@ ${project.version} + + org.springframework.ai + spring-ai-model-chat-memory-jdbc + ${project.version} + + org.springframework.ai spring-ai-model-chat-memory-neo4j ${project.version} - + org.springframework.ai @@ -973,6 +979,14 @@ ${project.version} + + + + org.springframework.ai + spring-ai-starter-model-chat-memory-jdbc + ${project.version} + + @@ -994,7 +1008,7 @@ - + diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc index 3fd13c44c89..7b445d067b3 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc @@ -366,46 +366,11 @@ In this configuration, the `MessageChatMemoryAdvisor` will be executed first, ad xref:ROOT:api/retrieval-augmented-generation.adoc#_questionansweradvisor[Learn about Question Answer Advisor] -=== Retrieval Augmented Generation - -Refer to the xref:_retrieval_augmented_generation[Retrieval Augmented Generation] guide. - -=== Chat Memory - -The interface `ChatMemory` represents a storage for chat conversation history. It provides methods to add messages to a conversation, retrieve messages from a conversation, and clear the conversation history. - -There are currently three implementations, `InMemoryChatMemory`, `CassandraChatMemory` and `Neo4jChatMemory`, that provide storage for chat conversation history, in-memory, persisted with `time-to-live` in Cassandra, and persisted without `time-to-live` in Neo4j correspondingly. - -To create a `CassandraChatMemory` with `time-to-live`: - -[source,java] ----- -CassandraChatMemory.create(CassandraChatMemoryConfig.builder().withTimeToLive(Duration.ofDays(1)).build()); ----- - -The Neo4j chat memory supports the following configuration parameters: - -[cols="2,5,1",stripes=even] -|=== -|Property | Description | Default Value - -| `spring.ai.chat.memory.neo4j.messageLabel` | The label for the nodes that store messages | `Message` -| `spring.ai.chat.memory.neo4j.sessionLabel` | The label for the nodes that store conversation sessions | `Session` -| `spring.ai.chat.memory.neo4j.toolCallLabel` | The label for nodes that store tool calls, for example -in Assistant Messages | `ToolCall` -| `spring.ai.chat.memory.neo4j.metadataLabel` | The label for the node that store a message metadata | `Metadata` -| `spring.ai.chat.memory.neo4j.toolResponseLabel` | The label for the nodes that store tool responses | `ToolResponse` -| `spring.ai.chat.memory.neo4j.mediaLabel` | The label for the nodes that store the media associated to a message | `ToolResponse` - - -|=== - - The following advisor implementations use the `ChatMemory` interface to advice the prompt with conversation history which differ in the details of how the memory is added to the prompt * `MessageChatMemoryAdvisor` : Memory is retrieved and added as a collection of messages to the prompt * `PromptChatMemoryAdvisor` : Memory is retrieved and added into the prompt's system text. -* `VectorStoreChatMemoryAdvisor` : The constructor `VectorStoreChatMemoryAdvisor(VectorStore vectorStore, String defaultConversationId, int chatHistoryWindowSize, int order)` This constructor allows you to: +* `VectorStoreChatMemoryAdvisor` : The constructor `VectorStoreChatMemoryAdvisor(VectorStore vectorStore, String defaultConversationId, int chatHistoryWindowSize, int order)` This constructor allows you to: . Specify the VectorStore instance used for managing and querying documents. . Set a default conversation ID to be used if none is provided in the context. @@ -465,6 +430,10 @@ public class CustomerSupportAssistant { xref:ROOT:api/retrieval-augmented-generation.adoc#_questionansweradvisor[Learn about Question Answer Advisor] +=== Retrieval Augmented Generation + +Refer to the xref:ROOT:api/retrieval-augmented-generation.adoc[Retrieval Augmented Generation] guide. + === Logging The `SimpleLoggerAdvisor` is an advisor that logs the `request` and `response` data of the `ChatClient`. @@ -515,3 +484,72 @@ SimpleLoggerAdvisor customLogger = new SimpleLoggerAdvisor( This allows you to tailor the logged information to your specific needs. TIP: Be cautious about logging sensitive information in production environments. + +== Chat Memory + +The interface `ChatMemory` represents a storage for chat conversation history. It provides methods to add messages to a conversation, retrieve messages from a conversation, and clear the conversation history. + +There are currently four implementations: `InMemoryChatMemory`, `CassandraChatMemory`, `Neo4jChatMemory`, and `JdbcChatMemory`, which provide storage for chat conversation history in-memory, persisted with `time-to-live` in Cassandra, and persisted without `time-to-live` in Neo4j and Jdbc, respectively. + +=== CassandraChatMemory + +To create a `CassandraChatMemory` with `time-to-live`: + +[source,java] +---- +CassandraChatMemory.create(CassandraChatMemoryConfig.builder().withTimeToLive(Duration.ofDays(1)).build()); +---- + +=== Neo4jChatMemory + +The Neo4j chat memory supports the following configuration parameters: + +[cols="2,5,1",stripes=even] +|=== +|Property | Description | Default Value + +| `spring.ai.chat.memory.neo4j.messageLabel` | The label for the nodes that store messages | `Message` +| `spring.ai.chat.memory.neo4j.sessionLabel` | The label for the nodes that store conversation sessions | `Session` +| `spring.ai.chat.memory.neo4j.toolCallLabel` | The label for nodes that store tool calls, for example +in Assistant Messages | `ToolCall` +| `spring.ai.chat.memory.neo4j.metadataLabel` | The label for the node that store a message metadata | `Metadata` +| `spring.ai.chat.memory.neo4j.toolResponseLabel` | The label for the nodes that store tool responses | `ToolResponse` +| `spring.ai.chat.memory.neo4j.mediaLabel` | The label for the nodes that store the media associated to a message | `ToolResponse` + +|=== + +=== JdbcChatMemory + +To create a `JdbcChatMemory`: + +[source,java] +---- +JdbcChatMemory.create(JdbcChatMemoryConfig.builder().jdbcTemplate(jdbcTemplate).build()); +---- + +The `JdbcChatMemory` can also be autoconfigured (given that you have the `JdbcTemplate` bean) by adding the following dependency to your project: + +to your Maven `pom.xml` file: + +[source,xml] +---- + + org.springframework.ai + spring-ai-starter-model-chat-memory-jdbc + +---- + +or to your Gradle `build.gradle` file: + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-starter-model-chat-memory-jdbc' +} +---- + +The autoconfiguration will automatically create the `ai_chat_memory` table by default based on the JDBC driver. Currently, only `postgresql` and `mariadb` are supported. + +To disable schema initialization, set the property `spring.ai.chat.memory.jdbc.initialize-schema` to `false`. + +There are instances where you are using a database migration tool like Liquibase or Flyway to manage your database schema. In that case, you may disable schema initialization and just refer to link:https://github.com/spring-projects/spring-ai/tree/main/memory/spring-ai-model-chat-memory-jdbc/src/main/resources/org/springframework/ai/chat/memory/jdbc[these sql files] and add them to your migration script. diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-model-chat-memory-jdbc/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-model-chat-memory-jdbc/pom.xml new file mode 100644 index 00000000000..112be3cc248 --- /dev/null +++ b/spring-ai-spring-boot-starters/spring-ai-starter-model-chat-memory-jdbc/pom.xml @@ -0,0 +1,58 @@ + + + + + 4.0.0 + + org.springframework.ai + spring-ai-parent + 1.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-starter-model-chat-memory-jdbc + jar + Spring AI Starter - JDBC Chat Memory + Spring AI JDBC Chat Memory Starter + https://github.com/spring-projects/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.ai + spring-ai-autoconfigure-model-chat-memory-jdbc + ${project.parent.version} + + + + org.springframework.ai + spring-ai-model-chat-memory-jdbc + ${project.parent.version} + + + +