Skip to content

Commit ee927d4

Browse files
codebase/building-an-ai-chatbot-using-deepseek-models-with-spring-ai [BAEL-9195] (#18325)
* add chatbot built on top of deepseek reasoning model * refactor DeepSeekModelOutputConverter * refactor usage of custom output converter * fix: NPE * incorporate feedback and add UT for custom converter
1 parent 71938b2 commit ee927d4

File tree

11 files changed

+287
-0
lines changed

11 files changed

+287
-0
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.baeldung.springai.deepseek;
2+
3+
import org.springframework.ai.autoconfigure.anthropic.AnthropicAutoConfiguration;
4+
import org.springframework.ai.autoconfigure.vectorstore.chroma.ChromaVectorStoreAutoConfiguration;
5+
import org.springframework.boot.SpringApplication;
6+
import org.springframework.boot.autoconfigure.SpringBootApplication;
7+
import org.springframework.context.annotation.PropertySource;
8+
9+
/**
10+
* Excluding the below auto-configurations to avoid start up
11+
* failure. Their corresponding starters are present on the classpath but are
12+
* only needed by other articles in the shared codebase.
13+
*/
14+
@SpringBootApplication(exclude = {
15+
AnthropicAutoConfiguration.class,
16+
ChromaVectorStoreAutoConfiguration.class
17+
})
18+
@PropertySource("classpath:application-deepseek.properties")
19+
class Application {
20+
21+
public static void main(String[] args) {
22+
SpringApplication.run(Application.class, args);
23+
}
24+
25+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.baeldung.springai.deepseek;
2+
3+
import org.springframework.lang.Nullable;
4+
5+
import java.util.UUID;
6+
7+
record ChatRequest(@Nullable UUID chatId, String question) {
8+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.baeldung.springai.deepseek;
2+
3+
import java.util.UUID;
4+
5+
record ChatResponse(UUID chatId, String chainOfThought, String answer) {
6+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.baeldung.springai.deepseek;
2+
3+
import org.springframework.ai.chat.client.ChatClient;
4+
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
5+
import org.springframework.ai.chat.memory.ChatMemory;
6+
import org.springframework.ai.chat.memory.InMemoryChatMemory;
7+
import org.springframework.ai.chat.model.ChatModel;
8+
import org.springframework.beans.factory.annotation.Qualifier;
9+
import org.springframework.context.annotation.Bean;
10+
import org.springframework.context.annotation.Configuration;
11+
12+
@Configuration
13+
class ChatbotConfiguration {
14+
15+
@Bean
16+
ChatMemory chatMemory() {
17+
return new InMemoryChatMemory();
18+
}
19+
20+
/**
21+
* When using alternate providers, use the following qualifiers instead:
22+
* - @Qualifier("bedrockProxyChatModel") for Amazon Bedrock Converse API
23+
* - @Qualifier("ollamaChatModel") for Ollama
24+
*/
25+
@Bean
26+
ChatClient chatClient(
27+
@Qualifier("openAiChatModel") ChatModel chatModel,
28+
ChatMemory chatMemory
29+
) {
30+
return ChatClient
31+
.builder(chatModel)
32+
.defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory))
33+
.build();
34+
}
35+
36+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.baeldung.springai.deepseek;
2+
3+
import org.springframework.http.ResponseEntity;
4+
import org.springframework.web.bind.annotation.PostMapping;
5+
import org.springframework.web.bind.annotation.RequestBody;
6+
import org.springframework.web.bind.annotation.RestController;
7+
8+
@RestController
9+
class ChatbotController {
10+
11+
private final ChatbotService chatbotService;
12+
13+
ChatbotController(ChatbotService chatbotService) {
14+
this.chatbotService = chatbotService;
15+
}
16+
17+
@PostMapping("/chat")
18+
ResponseEntity<ChatResponse> chat(@RequestBody ChatRequest chatRequest) {
19+
ChatResponse chatResponse = chatbotService.chat(chatRequest);
20+
return ResponseEntity.ok(chatResponse);
21+
}
22+
23+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.baeldung.springai.deepseek;
2+
3+
import org.springframework.ai.chat.client.ChatClient;
4+
import org.springframework.stereotype.Service;
5+
6+
import java.util.Optional;
7+
import java.util.UUID;
8+
9+
@Service
10+
class ChatbotService {
11+
12+
private final ChatClient chatClient;
13+
14+
ChatbotService(ChatClient chatClient) {
15+
this.chatClient = chatClient;
16+
}
17+
18+
ChatResponse chat(ChatRequest chatRequest) {
19+
UUID chatId = Optional
20+
.ofNullable(chatRequest.chatId())
21+
.orElse(UUID.randomUUID());
22+
DeepSeekModelResponse response = chatClient
23+
.prompt()
24+
.user(chatRequest.question())
25+
.advisors(advisorSpec ->
26+
advisorSpec
27+
.param("chat_memory_conversation_id", chatId))
28+
.call()
29+
.entity(new DeepSeekModelOutputConverter());
30+
return new ChatResponse(chatId, response.chainOfThought(), response.answer());
31+
}
32+
33+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.baeldung.springai.deepseek;
2+
3+
import org.slf4j.Logger;
4+
import org.slf4j.LoggerFactory;
5+
import org.springframework.ai.converter.StructuredOutputConverter;
6+
import org.springframework.lang.NonNull;
7+
import org.springframework.util.StringUtils;
8+
9+
class DeepSeekModelOutputConverter implements StructuredOutputConverter<DeepSeekModelResponse> {
10+
11+
private static final Logger logger = LoggerFactory.getLogger(DeepSeekModelOutputConverter.class);
12+
private static final String OPENING_THINK_TAG = "<think>";
13+
private static final String CLOSING_THINK_TAG = "</think>";
14+
15+
@Override
16+
public DeepSeekModelResponse convert(@NonNull String text) {
17+
if (!StringUtils.hasText(text)) {
18+
throw new IllegalArgumentException("Text cannot be blank");
19+
}
20+
int openingThinkTagIndex = text.indexOf(OPENING_THINK_TAG);
21+
int closingThinkTagIndex = text.indexOf(CLOSING_THINK_TAG);
22+
23+
if (openingThinkTagIndex != -1 && closingThinkTagIndex != -1 && closingThinkTagIndex > openingThinkTagIndex) {
24+
String chainOfThought = text.substring(openingThinkTagIndex + OPENING_THINK_TAG.length(), closingThinkTagIndex);
25+
String answer = text.substring(closingThinkTagIndex + CLOSING_THINK_TAG.length());
26+
return new DeepSeekModelResponse(chainOfThought, answer);
27+
} else {
28+
logger.debug("No <think> tags found in the response. Treating entire text as answer.");
29+
return new DeepSeekModelResponse(null, text);
30+
}
31+
}
32+
33+
/**
34+
* This method is used to define instructions for formatting the AI model's response,
35+
* which are appended to the user prompt.
36+
* See {@link org.springframework.ai.converter.BeanOutputConverter#getFormat()} for reference.
37+
*
38+
* However, in the current implementation, we extract only the AI response and its chain of thought,
39+
* so no formatting instructions are needed.
40+
*/
41+
@Override
42+
public String getFormat() {
43+
return null;
44+
}
45+
46+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package com.baeldung.springai.deepseek;
2+
3+
record DeepSeekModelResponse(String chainOfThought, String answer) {
4+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
spring.ai.openai.api-key=${DEEPSEEK_API_KEY}
2+
spring.ai.openai.base-url=https://api.deepseek.com
3+
spring.ai.openai.chat.options.model=deepseek-reasoner
4+
spring.ai.openai.embedding.enabled=false
5+
6+
spring.ai.ollama.chat.options.model=deepseek-r1
7+
spring.ai.ollama.embedding.enabled=false
8+
spring.ai.ollama.init.pull-model-strategy=when_missing
9+
10+
spring.ai.bedrock.aws.access-key=${AWS_ACCESS_KEY}
11+
spring.ai.bedrock.aws.secret-key=${AWS_SECRET_KEY}
12+
spring.ai.bedrock.aws.region=${AWS_REGION}
13+
spring.ai.bedrock.converse.chat.options.model=arn:aws:sagemaker:REGION:ACCOUNT_ID:endpoint/ENDPOINT_NAME
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.baeldung.springai.deepseek;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.springframework.beans.factory.annotation.Autowired;
5+
import org.springframework.boot.test.context.SpringBootTest;
6+
7+
import static org.assertj.core.api.Assertions.assertThat;
8+
9+
@SpringBootTest
10+
class ChatbotServiceLiveTest {
11+
12+
@Autowired
13+
private ChatbotService chatbotService;
14+
15+
@Test
16+
void whenChatbotCalledInSequence_thenConversationContextIsMaintained() {
17+
ChatRequest chatRequest = new ChatRequest(null, "What was the name of Superman's adoptive mother?");
18+
ChatResponse chatResponse = chatbotService.chat(chatRequest);
19+
20+
assertThat(chatResponse)
21+
.isNotNull()
22+
.hasNoNullFieldsOrProperties();
23+
assertThat(chatResponse.answer())
24+
.contains("Martha");
25+
26+
ChatRequest followUpChatRequest = new ChatRequest(chatResponse.chatId(), "Which bald billionaire hates him?");
27+
ChatResponse followUpChatResponse = chatbotService.chat(followUpChatRequest);
28+
29+
assertThat(followUpChatResponse)
30+
.isNotNull()
31+
.hasNoNullFieldsOrProperties();
32+
assertThat(followUpChatResponse.answer())
33+
.contains("Lex Luthor");
34+
}
35+
36+
}

0 commit comments

Comments
 (0)