MessageService.java

package com.seebie.server.service;

import com.seebie.server.AppProperties;
import com.seebie.server.dto.MessageDto;
import com.seebie.server.entity.MessageType;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.stereotype.Service;

import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

import static java.time.Instant.now;

@Service
public class MessageService {

    private final ChatModel chatModel;
    private final MessagePersistenceService messagePersistenceService;
    private final Message systemMessage;

    public MessageService(ChatModel chatModel,
                          MessagePersistenceService messagePersistenceService,
                          AppProperties appProperties)
    {
        this.chatModel = chatModel;
        this.messagePersistenceService = messagePersistenceService;

        // gpt4 takes a SystemMessage, o1 takes a Developer Message which isn't available
        this.systemMessage = new UserMessage(appProperties.ai().system().prompt());
    }

    public void deleteMessages(UUID userPublicId) {
        messagePersistenceService.deleteConversation(userPublicId);
    }

    public List<MessageDto> getMessages(UUID publicId) {
        var sevenDaysAgo = now().minus(7, ChronoUnit.DAYS);
        return messagePersistenceService.getChatHistory(publicId, sevenDaysAgo);
    }

    public MessageDto processPrompt(MessageDto userPrompt, UUID publicId) {

        var sevenDaysAgo = now().minus(7, ChronoUnit.DAYS);

        var chatHistory = messagePersistenceService.getChatHistory(publicId, sevenDaysAgo);

        var messagesToSend = buildPromptMessages(userPrompt, chatHistory);

        var options = OpenAiChatOptions.builder().user(publicId.toString()).build();
        var chatResponse = chatModel.call(new Prompt(messagesToSend, options));

        var response = extractAssistantResponse(chatResponse);

        messagePersistenceService.saveExchange(publicId, userPrompt, response);

        return response;
    }

    private List<Message> buildPromptMessages(MessageDto userPrompt, List<MessageDto> chatHistory) {

        List<Message> messages = new ArrayList<>();

        messages.add(systemMessage);

        chatHistory.stream()
                .map(MessageService::dtoToSpringAi)
                .forEach(messages::add);

        messages.add(new UserMessage(userPrompt.content()));

        return messages;
    }

    private MessageDto extractAssistantResponse(ChatResponse chatResponse) {

        // there could be multiple completions, but spring.ai.openai.chat.options.n is configured to 1
        // so it's ok to use .findFirst()
        return chatResponse.getResults().stream()
                .findFirst()
                .map(gen -> gen.getOutput().getText())
                .map(text -> new MessageDto(text, MessageType.ASSISTANT))
                .orElseThrow(() -> new RuntimeException("Nothing was generated"));
    }

    /**
     *  Although OpenAiChatModel takes a List of Message interfaces,
     *  it actually depends on Spring's concrete classes internally,
     *  so we can't just implement the interfaces ourselves.
     */
    public static Message dtoToSpringAi(MessageDto messageDto) {
        return switch(messageDto.type()) {
            case ASSISTANT -> new AssistantMessage(messageDto.content());
            case USER -> new UserMessage(messageDto.content());
        };
    }

}