Apply hexagonal architecture and clean code principles
@ -1,73 +0,0 @@
|
|||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
|
||||||
https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
|
|
||||||
<parent>
|
|
||||||
<groupId>com.pablotj</groupId>
|
|
||||||
<artifactId>chat-ia-offline</artifactId>
|
|
||||||
<version>0.0.1-SNAPSHOT</version>
|
|
||||||
</parent>
|
|
||||||
|
|
||||||
<artifactId>backend</artifactId>
|
|
||||||
<name>chat-ia-frontend</name>
|
|
||||||
<description>Backend Spring Boot</description>
|
|
||||||
<packaging>jar</packaging>
|
|
||||||
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<!-- Spring Boot Web -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- API Documentation -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springdoc</groupId>
|
|
||||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
|
||||||
<version>2.8.9</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- llama-java (IA offline) -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>de.kherud</groupId>
|
|
||||||
<artifactId>llama</artifactId>
|
|
||||||
<version>4.2.0</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Database -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.xerial</groupId>
|
|
||||||
<artifactId>sqlite-jdbc</artifactId>
|
|
||||||
<version>3.45.1.0</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.hibernate.orm</groupId>
|
|
||||||
<artifactId>hibernate-community-dialects</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Test support -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
|
|
||||||
<build>
|
|
||||||
<plugins>
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
|
||||||
</build>
|
|
||||||
</project>
|
|
@ -1,13 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot;
|
|
||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
|
||||||
|
|
||||||
@SpringBootApplication
|
|
||||||
public class IAChatBootApplication {
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
SpringApplication.run(IAChatBootApplication.class, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,67 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot.adapter.controller;
|
|
||||||
|
|
||||||
import com.pablotj.ia.chat.boot.application.usecase.ChatHistoryUseCase;
|
|
||||||
import com.pablotj.ia.chat.boot.application.usecase.ChatUseCase;
|
|
||||||
import com.pablotj.ia.chat.boot.domain.model.ChatIdentity;
|
|
||||||
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
|
||||||
import java.util.Date;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
|
||||||
import org.apache.logging.log4j.Logger;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.util.ObjectUtils;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/v1/chats")
|
|
||||||
public class ChatRestController {
|
|
||||||
|
|
||||||
private static final Logger LOGGER = LogManager.getLogger(ChatRestController.class);
|
|
||||||
|
|
||||||
private final ChatUseCase chatUseCase;
|
|
||||||
private final ChatHistoryUseCase chatHistoryUseCase;
|
|
||||||
|
|
||||||
public ChatRestController(ChatUseCase chatUseCase, ChatHistoryUseCase chatHistoryUseCase) {
|
|
||||||
this.chatUseCase = chatUseCase;
|
|
||||||
this.chatHistoryUseCase = chatHistoryUseCase;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
public ResponseEntity<List<ChatIdentity>> getChatHistory() {
|
|
||||||
LOGGER.debug("Accessing to chat");
|
|
||||||
return ResponseEntity.ok(chatHistoryUseCase.chats());
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("{chatId}")
|
|
||||||
public ResponseEntity<List<ChatMessage>> getChatMessages(@PathVariable("chatId") String chatId) {
|
|
||||||
LOGGER.debug("Accessing to chat messages");
|
|
||||||
return ResponseEntity.ok(chatUseCase.getMessages(chatId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping(produces = "application/json")
|
|
||||||
public ResponseEntity<ChatIdentity> newChat() {
|
|
||||||
return ResponseEntity.ok(chatUseCase.createChat());
|
|
||||||
}
|
|
||||||
|
|
||||||
@PutMapping(value = "{chatId}", consumes = "application/x-www-form-urlencoded", produces = "application/json")
|
|
||||||
public ResponseEntity<ChatMessage> handleChat(@PathVariable("chatId") String chatId, @RequestParam("prompt") String prompt) {
|
|
||||||
if (ObjectUtils.isEmpty(chatId)) {
|
|
||||||
throw new IllegalArgumentException("Chat uuid cannot be empty");
|
|
||||||
}
|
|
||||||
ChatMessage reply;
|
|
||||||
try {
|
|
||||||
reply = chatUseCase.processUserPrompt(prompt, chatId);
|
|
||||||
} catch (Exception e) {
|
|
||||||
LOGGER.error(e.getMessage(), e);
|
|
||||||
reply = new ChatMessage(chatId, "bot", e.getMessage(), new Date());
|
|
||||||
}
|
|
||||||
return ResponseEntity.ok(reply);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,39 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot.application.prompt;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import org.springframework.util.StringUtils;
|
|
||||||
|
|
||||||
public class PromptBuilder {
|
|
||||||
|
|
||||||
private static final String END_TURN_SEPARATOR = "<|end_of_turn|>";
|
|
||||||
private final String systemPrompt;
|
|
||||||
private final List<String> turns = new ArrayList<>();
|
|
||||||
public PromptBuilder(String systemPrompt) {
|
|
||||||
this.systemPrompt = systemPrompt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void user(String message) {
|
|
||||||
turns.add(this.formatMessage(MessageType.USER, message));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void assistant(String message) {
|
|
||||||
turns.add(this.formatMessage(MessageType.ASSISTANT, message));
|
|
||||||
}
|
|
||||||
|
|
||||||
public String build() {
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
sb.append(systemPrompt).append(END_TURN_SEPARATOR);
|
|
||||||
for (String turn : turns) {
|
|
||||||
sb.append(turn);
|
|
||||||
}
|
|
||||||
sb.append("GPT4 Correct Assistant:");
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String formatMessage(MessageType messageType, String message) {
|
|
||||||
return String.format("GPT4 Correct %s: %s %s", StringUtils.capitalize(messageType.name().toLowerCase()), message, END_TURN_SEPARATOR);
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum MessageType {USER, ASSISTANT}
|
|
||||||
}
|
|
@ -1,69 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot.application.prompt;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import java.util.stream.Stream;
|
|
||||||
|
|
||||||
public class PromptDefinition {
|
|
||||||
|
|
||||||
private String character;
|
|
||||||
private String identity;
|
|
||||||
private List<String> knowledge;
|
|
||||||
private String tone;
|
|
||||||
private String communicationStyle;
|
|
||||||
private List<String> rules;
|
|
||||||
private String context;
|
|
||||||
private String style;
|
|
||||||
private String formatting;
|
|
||||||
private String closing;
|
|
||||||
|
|
||||||
public String buildPrompt() {
|
|
||||||
return Stream.of(
|
|
||||||
character,
|
|
||||||
identity,
|
|
||||||
knowledge != null ? String.join(" ", knowledge) : null,
|
|
||||||
tone,
|
|
||||||
communicationStyle,
|
|
||||||
rules != null ? String.join(" ", rules) : null,
|
|
||||||
context,
|
|
||||||
style,
|
|
||||||
formatting,
|
|
||||||
closing
|
|
||||||
).filter(Objects::nonNull)
|
|
||||||
.filter(s -> !s.isBlank())
|
|
||||||
.collect(Collectors.joining("\n\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters y setters (requeridos por Jackson)
|
|
||||||
|
|
||||||
public String getCharacter() { return character; }
|
|
||||||
public void setCharacter(String character) { this.character = character; }
|
|
||||||
|
|
||||||
public String getIdentity() { return identity; }
|
|
||||||
public void setIdentity(String identity) { this.identity = identity; }
|
|
||||||
|
|
||||||
public List<String> getKnowledge() { return knowledge; }
|
|
||||||
public void setKnowledge(List<String> knowledge) { this.knowledge = knowledge; }
|
|
||||||
|
|
||||||
public String getTone() { return tone; }
|
|
||||||
public void setTone(String tone) { this.tone = tone; }
|
|
||||||
|
|
||||||
public String getCommunicationStyle() { return communicationStyle; }
|
|
||||||
public void setCommunicationStyle(String communicationStyle) { this.communicationStyle = communicationStyle; }
|
|
||||||
|
|
||||||
public List<String> getRules() { return rules; }
|
|
||||||
public void setRules(List<String> rules) { this.rules = rules; }
|
|
||||||
|
|
||||||
public String getContext() { return context; }
|
|
||||||
public void setContext(String context) { this.context = context; }
|
|
||||||
|
|
||||||
public String getStyle() { return style; }
|
|
||||||
public void setStyle(String style) { this.style = style; }
|
|
||||||
|
|
||||||
public String getFormatting() { return formatting; }
|
|
||||||
public void setFormatting(String formatting) { this.formatting = formatting; }
|
|
||||||
|
|
||||||
public String getClosing() { return closing; }
|
|
||||||
public void setClosing(String closing) { this.closing = closing; }
|
|
||||||
}
|
|
@ -1,53 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot.application.prompt;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
|
|
||||||
import com.pablotj.ia.chat.boot.domain.exception.BusinessLogicException;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
|
|
||||||
public class PromptTemplates {
|
|
||||||
|
|
||||||
private static final String BASE_PATH = "/prompts/";
|
|
||||||
private static final String DEFAULT_PROFILE = "default";
|
|
||||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads and returns the full prompts string for the given profile resume.
|
|
||||||
*
|
|
||||||
* @param profileName resume of the prompts profile, without extension (e.g. "default")
|
|
||||||
* @return full system prompts as String
|
|
||||||
*/
|
|
||||||
public static String get(String profileName) {
|
|
||||||
return load(profileName).buildPrompt();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads and returns the PromptDefinition for the given profile.
|
|
||||||
*
|
|
||||||
* @param profileName prompts profile resume (e.g. "developer", "minimal")
|
|
||||||
* @return PromptDefinition object
|
|
||||||
*/
|
|
||||||
public static PromptDefinition load(String profileName) {
|
|
||||||
String filePath = BASE_PATH + profileName + "_prompt.json";
|
|
||||||
try (InputStream input = PromptTemplates.class.getResourceAsStream(filePath)) {
|
|
||||||
if (input == null) {
|
|
||||||
throw new BusinessLogicException("Prompt profile not found: " + filePath);
|
|
||||||
}
|
|
||||||
return OBJECT_MAPPER.readValue(input, PromptDefinition.class);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new BusinessLogicException("Failed to load prompts profile: " + profileName, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shortcut for default profile.
|
|
||||||
*/
|
|
||||||
public static String getDefault() {
|
|
||||||
return get(DEFAULT_PROFILE);
|
|
||||||
}
|
|
||||||
|
|
||||||
private PromptTemplates() {
|
|
||||||
// Utility class
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,42 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot.application.session;
|
|
||||||
|
|
||||||
import com.pablotj.ia.chat.boot.domain.model.ChatIdentity;
|
|
||||||
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
|
||||||
import com.pablotj.ia.chat.boot.domain.port.ChatMessageStore;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.util.ObjectUtils;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
public class ChatSessionManager {
|
|
||||||
|
|
||||||
private final ChatMessageStore chatMessageStore;
|
|
||||||
|
|
||||||
public ChatSessionManager(ChatMessageStore chatMessageStore) {
|
|
||||||
this.chatMessageStore = chatMessageStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ChatIdentity createChat() {
|
|
||||||
return chatMessageStore.createChat();
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<ChatMessage> getMessages(String chatUuid) {
|
|
||||||
List<ChatMessage> messages = chatMessageStore.getMessages(chatUuid);
|
|
||||||
List<ChatMessage> filteredMessages = new ArrayList<>(messages);
|
|
||||||
|
|
||||||
if (ObjectUtils.isEmpty(filteredMessages)) {
|
|
||||||
return new ArrayList<>();
|
|
||||||
} else {
|
|
||||||
filteredMessages.removeIf(m -> ObjectUtils.isEmpty(m.text()));
|
|
||||||
}
|
|
||||||
return filteredMessages;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setMessages(String chatUuid, List<ChatMessage> messages) {
|
|
||||||
messages.removeIf(m -> ObjectUtils.isEmpty(m.text()));
|
|
||||||
chatMessageStore.saveMessages(chatUuid, messages);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
@ -1,20 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot.application.usecase;
|
|
||||||
|
|
||||||
import com.pablotj.ia.chat.boot.domain.model.ChatIdentity;
|
|
||||||
import com.pablotj.ia.chat.boot.domain.port.ChatMessageStore;
|
|
||||||
import java.util.List;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
public class ChatHistoryUseCase {
|
|
||||||
|
|
||||||
private final ChatMessageStore chatMessageStore;
|
|
||||||
|
|
||||||
public ChatHistoryUseCase(ChatMessageStore chatMessageStore) {
|
|
||||||
this.chatMessageStore = chatMessageStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<ChatIdentity> chats() {
|
|
||||||
return chatMessageStore.getChats();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,56 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot.application.usecase;
|
|
||||||
|
|
||||||
import com.pablotj.ia.chat.boot.application.prompt.PromptBuilder;
|
|
||||||
import com.pablotj.ia.chat.boot.application.prompt.PromptTemplates;
|
|
||||||
import com.pablotj.ia.chat.boot.application.session.ChatSessionManager;
|
|
||||||
import com.pablotj.ia.chat.boot.domain.model.ChatIdentity;
|
|
||||||
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
|
||||||
import com.pablotj.ia.chat.boot.infraestructure.llm.LlmModelClient;
|
|
||||||
import java.util.Date;
|
|
||||||
import java.util.List;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
public class ChatUseCase {
|
|
||||||
|
|
||||||
private static final String ATTR_ROLE_BOT = "bot";
|
|
||||||
private static final String ATTR_ROLE_USER = "user";
|
|
||||||
|
|
||||||
private final LlmModelClient llmModelClient;
|
|
||||||
private final ChatSessionManager sessionManager;
|
|
||||||
|
|
||||||
public ChatUseCase(LlmModelClient llmModelClient,
|
|
||||||
ChatSessionManager sessionManager) {
|
|
||||||
this.llmModelClient = llmModelClient;
|
|
||||||
this.sessionManager = sessionManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ChatIdentity createChat() {
|
|
||||||
return sessionManager.createChat();
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<ChatMessage> getMessages(String chatId) {
|
|
||||||
return sessionManager.getMessages(chatId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ChatMessage processUserPrompt(String prompt, String chatId) {
|
|
||||||
List<ChatMessage> messages = sessionManager.getMessages(chatId);
|
|
||||||
messages.add(new ChatMessage(chatId, ATTR_ROLE_USER, prompt, new Date()));
|
|
||||||
|
|
||||||
PromptBuilder builder = new PromptBuilder(PromptTemplates.getDefault());
|
|
||||||
|
|
||||||
for (ChatMessage message : messages) {
|
|
||||||
if (ATTR_ROLE_USER.equals(message.role())) {
|
|
||||||
builder.user(message.text());
|
|
||||||
} else if (ATTR_ROLE_BOT.equals(message.role())) {
|
|
||||||
builder.assistant(message.text());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String result = llmModelClient.generate(builder.build());
|
|
||||||
ChatMessage reply = new ChatMessage(chatId, ATTR_ROLE_BOT, result, new Date());
|
|
||||||
messages.add(reply);
|
|
||||||
sessionManager.setMessages(chatId, messages);
|
|
||||||
return reply;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,12 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot.domain.exception;
|
|
||||||
|
|
||||||
public class BusinessLogicException extends RuntimeException {
|
|
||||||
|
|
||||||
public BusinessLogicException(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public BusinessLogicException(String message, Throwable cause) {
|
|
||||||
super(message, cause);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot.domain.model;
|
|
||||||
|
|
||||||
import java.io.Serializable;
|
|
||||||
|
|
||||||
public record ChatIdentity(String uuid, String resume) implements Serializable {
|
|
||||||
}
|
|
@ -1,14 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot.domain.model;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.util.Date;
|
|
||||||
|
|
||||||
public record ChatMessage(String chatUuid, String role, String text, Date date) implements Serializable {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@JsonFormat(pattern = "dd/MM/yyyy HH:mm", timezone = "Europe/Madrid")
|
|
||||||
public Date date() {
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,13 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot.domain.port;
|
|
||||||
|
|
||||||
import com.pablotj.ia.chat.boot.domain.model.ChatIdentity;
|
|
||||||
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
|
||||||
import java.util.List;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
public interface ChatMessageStore {
|
|
||||||
ChatIdentity createChat();
|
|
||||||
List<ChatIdentity> getChats();
|
|
||||||
List<ChatMessage> getMessages(String chatUuid);
|
|
||||||
void saveMessages(String chatUuid, List<ChatMessage> messages);
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot.domain.service;
|
|
||||||
|
|
||||||
public interface ChatService {
|
|
||||||
|
|
||||||
String chat(String promptWithHistory);
|
|
||||||
}
|
|
@ -1,30 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot.infraestructure.llm;
|
|
||||||
|
|
||||||
import de.kherud.llama.InferenceParameters;
|
|
||||||
import de.kherud.llama.LlamaOutput;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
public class LlmModelClient {
|
|
||||||
|
|
||||||
private final LlmModelLoader modelLoader;
|
|
||||||
|
|
||||||
public LlmModelClient(LlmModelLoader modelLoader) {
|
|
||||||
this.modelLoader = modelLoader;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String generate(String prompt) {
|
|
||||||
InferenceParameters inf = new InferenceParameters(prompt)
|
|
||||||
.setNPredict(1024)
|
|
||||||
.setTemperature(0.7f)
|
|
||||||
.setTopP(0.9f)
|
|
||||||
.setTopK(40)
|
|
||||||
.setUseChatTemplate(false);
|
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
for (LlamaOutput out : modelLoader.getModel().generate(inf)) {
|
|
||||||
sb.append(out.text);
|
|
||||||
}
|
|
||||||
return sb.toString().replace("<|end_of_turn|>", "");
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,51 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot.infraestructure.llm;
|
|
||||||
|
|
||||||
import com.pablotj.ia.chat.boot.domain.exception.BusinessLogicException;
|
|
||||||
import de.kherud.llama.LlamaModel;
|
|
||||||
import de.kherud.llama.ModelParameters;
|
|
||||||
import jakarta.annotation.PostConstruct;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
public class LlmModelLoader implements AutoCloseable {
|
|
||||||
|
|
||||||
@Value(value = "${llama.model.name}")
|
|
||||||
private String modelName;
|
|
||||||
|
|
||||||
@Value(value = "${llama.model.gpu.enabled}")
|
|
||||||
private boolean gpuEnabled;
|
|
||||||
|
|
||||||
@Value(value = "${llama.model.gpu.layers}")
|
|
||||||
private int gpuLayers;
|
|
||||||
|
|
||||||
@Value(value = "${llama.model.tokens}")
|
|
||||||
private int tokens;
|
|
||||||
|
|
||||||
private LlamaModel model;
|
|
||||||
|
|
||||||
@PostConstruct
|
|
||||||
public void init() {
|
|
||||||
try {
|
|
||||||
ModelParameters params = new ModelParameters()
|
|
||||||
.setModel(String.format("models/%s.gguf", modelName))
|
|
||||||
.setSeed(42)
|
|
||||||
.setThreads(8)
|
|
||||||
.setMainGpu(gpuEnabled ? 0 : -1)
|
|
||||||
.setGpuLayers(gpuEnabled ? gpuLayers : -1)
|
|
||||||
.setPredict(tokens);
|
|
||||||
model = new LlamaModel(params);
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new BusinessLogicException("Error loading model", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public LlamaModel getModel() {
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
if (model != null) model.close();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,74 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot.persistence;
|
|
||||||
|
|
||||||
import jakarta.persistence.CascadeType;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.GeneratedValue;
|
|
||||||
import jakarta.persistence.GenerationType;
|
|
||||||
import jakarta.persistence.Id;
|
|
||||||
import jakarta.persistence.OneToMany;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Date;
|
|
||||||
import java.util.List;
|
|
||||||
import org.springframework.data.annotation.CreatedDate;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "chat")
|
|
||||||
public class ChatEntity {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
private String uuid;
|
|
||||||
|
|
||||||
private String resume;
|
|
||||||
|
|
||||||
@CreatedDate
|
|
||||||
private Date createdDate;
|
|
||||||
|
|
||||||
@OneToMany(mappedBy = "chat", cascade = CascadeType.ALL, orphanRemoval = true)
|
|
||||||
private List<ChatMessageEntity> messages = new ArrayList<>();
|
|
||||||
|
|
||||||
// Getters y Setters
|
|
||||||
|
|
||||||
public Long getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setId(Long id) {
|
|
||||||
this.id = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getUuid() {
|
|
||||||
return uuid;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setUuid(String uuid) {
|
|
||||||
this.uuid = uuid;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getResume() {
|
|
||||||
return resume;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setResume(String resume) {
|
|
||||||
this.resume = resume;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Date getCreatedDate() {
|
|
||||||
return createdDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setCreatedDate(Date createdDate) {
|
|
||||||
this.createdDate = createdDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<ChatMessageEntity> getMessages() {
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setMessages(List<ChatMessageEntity> messages) {
|
|
||||||
this.messages = messages;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,13 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot.persistence;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
|
|
||||||
public interface ChatJpaRepository extends JpaRepository<ChatEntity, Long> {
|
|
||||||
|
|
||||||
Optional<ChatEntity> findOneByUuid(String chatUuid);
|
|
||||||
|
|
||||||
List<ChatEntity> findAllByOrderByCreatedDateDesc();
|
|
||||||
|
|
||||||
}
|
|
@ -1,15 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot.persistence;
|
|
||||||
|
|
||||||
import com.pablotj.ia.chat.boot.domain.model.ChatIdentity;
|
|
||||||
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
|
||||||
|
|
||||||
public class ChatMapper {
|
|
||||||
|
|
||||||
private ChatMapper() throws IllegalAccessException {
|
|
||||||
throw new IllegalAccessException("Private access to ChatMapper");
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ChatIdentity toDomain(ChatEntity entity) {
|
|
||||||
return new ChatIdentity(entity.getUuid(), entity.getResume());
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,45 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot.persistence;
|
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import java.util.Date;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "chat_messages")
|
|
||||||
public class ChatMessageEntity {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
private ChatEntity chat;
|
|
||||||
|
|
||||||
@Column(length = 4000)
|
|
||||||
private String text;
|
|
||||||
|
|
||||||
private String role;
|
|
||||||
|
|
||||||
private Date date;
|
|
||||||
|
|
||||||
// Getters y Setters
|
|
||||||
|
|
||||||
public Long getId() { return id; }
|
|
||||||
public void setId(Long id) { this.id = id; }
|
|
||||||
|
|
||||||
public ChatEntity getChat() { return chat; }
|
|
||||||
public void setChat(ChatEntity chat) { this.chat = chat; }
|
|
||||||
|
|
||||||
public String getText() { return text; }
|
|
||||||
public void setText(String text) { this.text = text; }
|
|
||||||
|
|
||||||
public String getRole() { return role; }
|
|
||||||
public void setRole(String role) { this.role = role; }
|
|
||||||
|
|
||||||
public Date getDate() {
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setDate(Date date) {
|
|
||||||
this.date = date;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,11 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot.persistence;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
|
|
||||||
public interface ChatMessageJpaRepository extends JpaRepository<ChatMessageEntity, Long> {
|
|
||||||
|
|
||||||
List<ChatMessageEntity> findByChatUuidOrderByIdAsc(String chatUuid);
|
|
||||||
|
|
||||||
void deleteByChatUuid(String chatUuid);
|
|
||||||
}
|
|
@ -1,23 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot.persistence;
|
|
||||||
|
|
||||||
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
|
||||||
|
|
||||||
public class ChatMessageMapper {
|
|
||||||
|
|
||||||
private ChatMessageMapper() throws IllegalAccessException {
|
|
||||||
throw new IllegalAccessException("Private access to ChatMessageMapper");
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ChatMessageEntity toEntity(ChatEntity chat, ChatMessage message) {
|
|
||||||
ChatMessageEntity entity = new ChatMessageEntity();
|
|
||||||
entity.setChat(chat);
|
|
||||||
entity.setRole(message.role());
|
|
||||||
entity.setText(message.text());
|
|
||||||
entity.setDate(message.date());
|
|
||||||
return entity;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ChatMessage toDomain(ChatMessageEntity entity) {
|
|
||||||
return new ChatMessage(entity.getChat().getUuid(), entity.getRole(), entity.getText(), entity.getDate());
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,78 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot.persistence;
|
|
||||||
|
|
||||||
import com.pablotj.ia.chat.boot.domain.model.ChatIdentity;
|
|
||||||
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
|
||||||
import com.pablotj.ia.chat.boot.domain.port.ChatMessageStore;
|
|
||||||
import java.util.Date;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
import org.springframework.stereotype.Repository;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
@Repository
|
|
||||||
public class SqliteChatMessageStore implements ChatMessageStore {
|
|
||||||
|
|
||||||
private final ChatJpaRepository chatJpaRepository;
|
|
||||||
private final ChatMessageJpaRepository chatMessageJpaRepository;
|
|
||||||
|
|
||||||
public SqliteChatMessageStore(ChatJpaRepository chatJpaRepository, ChatMessageJpaRepository chatMessageJpaRepository) {
|
|
||||||
this.chatJpaRepository = chatJpaRepository;
|
|
||||||
this.chatMessageJpaRepository = chatMessageJpaRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@Transactional
|
|
||||||
public ChatIdentity createChat() {
|
|
||||||
ChatEntity chat = new ChatEntity();
|
|
||||||
chat.setUuid(UUID.randomUUID().toString());
|
|
||||||
chat.setResume("Nuevo chat");
|
|
||||||
chat.setCreatedDate(new Date());
|
|
||||||
chat = chatJpaRepository.save(chat);
|
|
||||||
return ChatMapper.toDomain(chat);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<ChatIdentity> getChats() {
|
|
||||||
return chatJpaRepository.findAllByOrderByCreatedDateDesc().stream()
|
|
||||||
.map(ChatMapper::toDomain)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<ChatMessage> getMessages(String chatUuid) {
|
|
||||||
return chatMessageJpaRepository.findByChatUuidOrderByIdAsc(chatUuid)
|
|
||||||
.stream()
|
|
||||||
.map(ChatMessageMapper::toDomain)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@Transactional
|
|
||||||
public void saveMessages(String chatUuid, List<ChatMessage> messages) {
|
|
||||||
if (messages == null || messages.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ChatEntity chat = chatJpaRepository.findOneByUuid(chatUuid).orElse(null);
|
|
||||||
|
|
||||||
if (chat == null) {
|
|
||||||
chat = new ChatEntity();
|
|
||||||
chat.setUuid(chatUuid != null ? chatUuid : UUID.randomUUID().toString());
|
|
||||||
chat.setResume(createResume(messages.getFirst()));
|
|
||||||
chat.setCreatedDate(new Date());
|
|
||||||
chat = chatJpaRepository.save(chat);
|
|
||||||
} else if (chat.getMessages().isEmpty()) {
|
|
||||||
chat.setResume(createResume(messages.getFirst()));
|
|
||||||
chat = chatJpaRepository.save(chat);
|
|
||||||
}
|
|
||||||
|
|
||||||
ChatEntity finalChat = chat;
|
|
||||||
messages.forEach(msg -> finalChat.getMessages().add(ChatMessageMapper.toEntity(finalChat, msg)));
|
|
||||||
|
|
||||||
chatJpaRepository.save(finalChat);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String createResume(ChatMessage chatMessage) {
|
|
||||||
return chatMessage.text().length() > 35 ? chatMessage.text().substring(0, 35).concat("...") : chatMessage.text();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,21 +0,0 @@
|
|||||||
server:
|
|
||||||
port: 8080
|
|
||||||
spring:
|
|
||||||
application:
|
|
||||||
name: ia-chat-boot
|
|
||||||
datasource:
|
|
||||||
url: jdbc:sqlite:file:chat.db
|
|
||||||
jpa:
|
|
||||||
database-platform: org.hibernate.community.dialect.SQLiteDialect
|
|
||||||
hibernate:
|
|
||||||
ddl-auto: update
|
|
||||||
springdoc:
|
|
||||||
api-docs.path: /api-docs
|
|
||||||
swagger-ui.path: /swagger-ui.html
|
|
||||||
llama:
|
|
||||||
model:
|
|
||||||
name: openchat-3.5-0106.Q4_K_M
|
|
||||||
gpu:
|
|
||||||
enabled: true
|
|
||||||
layers: 35
|
|
||||||
tokens: 1024
|
|
144
chat-api/pom.xml
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||||
|
https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>com.pablotj</groupId>
|
||||||
|
<artifactId>ai-chat-offline</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>chat-api</artifactId>
|
||||||
|
<name>AI Chat Platform - Backend</name>
|
||||||
|
<description>Enterprise-grade AI Chat Platform Backend</description>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>21</java.version>
|
||||||
|
<spring-boot.version>3.2.0</spring-boot.version>
|
||||||
|
<springdoc.version>2.8.9</springdoc.version>
|
||||||
|
<llama-java.version>4.2.0</llama-java.version>
|
||||||
|
<sqlite.version>3.45.1.0</sqlite.version>
|
||||||
|
<mapstruct.version>1.5.5.Final</mapstruct.version>
|
||||||
|
<micrometer.version>1.12.0</micrometer.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Spring Boot Core -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-actuator</artifactId>
|
||||||
|
<version>${spring-boot.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Actuator autoconfiguration (needed for HealthIndicator, MeterRegistryCustomizer, etc.) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-actuator-autoconfigure</artifactId>
|
||||||
|
<version>${spring-boot.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Data Persistence -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.xerial</groupId>
|
||||||
|
<artifactId>sqlite-jdbc</artifactId>
|
||||||
|
<version>${sqlite.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.hibernate.orm</groupId>
|
||||||
|
<artifactId>hibernate-community-dialects</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- AI/ML Integration -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>de.kherud</groupId>
|
||||||
|
<artifactId>llama</artifactId>
|
||||||
|
<version>${llama-java.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- API Documentation -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
<version>${springdoc.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Mapping -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mapstruct</groupId>
|
||||||
|
<artifactId>mapstruct</artifactId>
|
||||||
|
<version>${mapstruct.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mapstruct</groupId>
|
||||||
|
<artifactId>mapstruct-processor</artifactId>
|
||||||
|
<version>${mapstruct.version}</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Monitoring & Metrics -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.micrometer</groupId>
|
||||||
|
<artifactId>micrometer-core</artifactId>
|
||||||
|
<version>${micrometer.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Testing -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.11.0</version>
|
||||||
|
<configuration>
|
||||||
|
<source>${java.version}</source>
|
||||||
|
<target>${java.version}</target>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.mapstruct</groupId>
|
||||||
|
<artifactId>mapstruct-processor</artifactId>
|
||||||
|
<version>${mapstruct.version}</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
@ -0,0 +1,27 @@
|
|||||||
|
package com.pablotj.ai.chat;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Chat Platform - Enterprise Application Entry Point
|
||||||
|
* <p>
|
||||||
|
* A sophisticated AI-powered chat platform built with hexagonal architecture,
|
||||||
|
* featuring offline AI capabilities, robust conversation management,
|
||||||
|
* and enterprise-grade scalability.
|
||||||
|
*
|
||||||
|
* @author Pablo TJ
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2024
|
||||||
|
*/
|
||||||
|
@SpringBootApplication
|
||||||
|
@EnableAsync
|
||||||
|
@EnableTransactionManagement
|
||||||
|
public class AiChatPlatformApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(AiChatPlatformApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,57 @@
|
|||||||
|
package com.pablotj.ai.chat.application.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data Transfer Object for complete conversation data.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Complete conversation with messages")
|
||||||
|
public record ConversationDto(
|
||||||
|
|
||||||
|
@Schema(description = "Unique conversation identifier", example = "123e4567-e89b-12d3-a456-426614174000")
|
||||||
|
@NotBlank
|
||||||
|
String conversationId,
|
||||||
|
|
||||||
|
@Schema(description = "Conversation title", example = "Discussion about AI")
|
||||||
|
@NotBlank
|
||||||
|
String title,
|
||||||
|
|
||||||
|
@Schema(description = "Optional conversation description", example = "A detailed conversation about artificial intelligence")
|
||||||
|
String description,
|
||||||
|
|
||||||
|
@Schema(description = "Conversation status", example = "ACTIVE")
|
||||||
|
@NotBlank
|
||||||
|
String status,
|
||||||
|
|
||||||
|
@Schema(description = "Conversation creation timestamp")
|
||||||
|
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", timezone = "UTC")
|
||||||
|
@NotNull
|
||||||
|
Instant createdAt,
|
||||||
|
|
||||||
|
@Schema(description = "List of messages in the conversation")
|
||||||
|
@NotNull
|
||||||
|
List<ConversationMessageDto> messages,
|
||||||
|
|
||||||
|
@Schema(description = "Total number of messages", example = "5")
|
||||||
|
int messageCount
|
||||||
|
) {
|
||||||
|
|
||||||
|
public ConversationDto {
|
||||||
|
if (messages == null) {
|
||||||
|
messages = List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return messages.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasDescription() {
|
||||||
|
return description != null && !description.trim().isEmpty();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
package com.pablotj.ai.chat.application.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data Transfer Object for conversation messages.
|
||||||
|
*/
|
||||||
|
@Schema(description = "A message within a conversation")
|
||||||
|
public record ConversationMessageDto(
|
||||||
|
|
||||||
|
@Schema(description = "Unique message identifier", example = "msg-123e4567-e89b-12d3-a456-426614174000")
|
||||||
|
@NotBlank
|
||||||
|
String messageId,
|
||||||
|
|
||||||
|
@Schema(description = "Conversation identifier this message belongs to", example = "123e4567-e89b-12d3-a456-426614174000")
|
||||||
|
@NotBlank
|
||||||
|
String conversationId,
|
||||||
|
|
||||||
|
@Schema(description = "Message role", example = "USER", allowableValues = {"USER", "ASSISTANT", "SYSTEM"})
|
||||||
|
@NotBlank
|
||||||
|
String role,
|
||||||
|
|
||||||
|
@Schema(description = "Message content text", example = "Hello, how can you help me today?")
|
||||||
|
@NotBlank
|
||||||
|
String content,
|
||||||
|
|
||||||
|
@Schema(description = "Message creation timestamp")
|
||||||
|
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd/MM/yyyy HH:mm:ss", timezone = "Europe/Madrid")
|
||||||
|
@NotNull
|
||||||
|
Instant createdAt,
|
||||||
|
|
||||||
|
@Schema(description = "Additional message metadata")
|
||||||
|
Map<String, Object> metadata
|
||||||
|
) {
|
||||||
|
|
||||||
|
public ConversationMessageDto {
|
||||||
|
if (metadata == null) {
|
||||||
|
metadata = Map.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isFromUser() {
|
||||||
|
return "USER".equals(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isFromAssistant() {
|
||||||
|
return "ASSISTANT".equals(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasMetadata() {
|
||||||
|
return !metadata.isEmpty();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,42 @@
|
|||||||
|
package com.pablotj.ai.chat.application.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data Transfer Object for conversation summary information.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Conversation summary for listing purposes")
|
||||||
|
public record ConversationSummaryDto(
|
||||||
|
|
||||||
|
@Schema(description = "Unique conversation identifier", example = "123e4567-e89b-12d3-a456-426614174000")
|
||||||
|
@NotBlank
|
||||||
|
String conversationId,
|
||||||
|
|
||||||
|
@Schema(description = "Conversation title", example = "Discussion about AI")
|
||||||
|
@NotBlank
|
||||||
|
String title,
|
||||||
|
|
||||||
|
@Schema(description = "Optional conversation description", example = "A detailed conversation about artificial intelligence")
|
||||||
|
String description,
|
||||||
|
|
||||||
|
@Schema(description = "Conversation status", example = "ACTIVE")
|
||||||
|
@NotBlank
|
||||||
|
String status,
|
||||||
|
|
||||||
|
@Schema(description = "Conversation creation timestamp")
|
||||||
|
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", timezone = "UTC")
|
||||||
|
@NotNull
|
||||||
|
Instant createdAt,
|
||||||
|
|
||||||
|
@Schema(description = "Total number of messages in the conversation", example = "5")
|
||||||
|
int messageCount
|
||||||
|
) {
|
||||||
|
|
||||||
|
public boolean hasDescription() {
|
||||||
|
return description != null && !description.trim().isEmpty();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,49 @@
|
|||||||
|
package com.pablotj.ai.chat.application.mapper;
|
||||||
|
|
||||||
|
import com.pablotj.ai.chat.application.dto.ConversationDto;
|
||||||
|
import com.pablotj.ai.chat.application.dto.ConversationMessageDto;
|
||||||
|
import com.pablotj.ai.chat.application.dto.ConversationSummaryDto;
|
||||||
|
import com.pablotj.ai.chat.domain.model.Conversation;
|
||||||
|
import com.pablotj.ai.chat.domain.model.ConversationMessage;
|
||||||
|
import org.mapstruct.Mapper;
|
||||||
|
import org.mapstruct.Mapping;
|
||||||
|
import org.mapstruct.Named;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MapStruct mapper for converting between domain models and DTOs.
|
||||||
|
*/
|
||||||
|
@Mapper(componentModel = "spring")
|
||||||
|
public interface ConversationMapper {
|
||||||
|
|
||||||
|
@Mapping(source = "conversationId.uuid", target = "conversationId")
|
||||||
|
@Mapping(source = "summary.title", target = "title")
|
||||||
|
@Mapping(source = "summary.description", target = "description")
|
||||||
|
@Mapping(source = "status", target = "status", qualifiedByName = "statusToString")
|
||||||
|
@Mapping(source = "messages", target = "messages")
|
||||||
|
@Mapping(expression = "java(conversation.getMessageCount())", target = "messageCount")
|
||||||
|
ConversationDto toDto(Conversation conversation);
|
||||||
|
|
||||||
|
@Mapping(source = "conversationId.uuid", target = "conversationId")
|
||||||
|
@Mapping(source = "summary.title", target = "title")
|
||||||
|
@Mapping(source = "summary.description", target = "description")
|
||||||
|
@Mapping(source = "status", target = "status", qualifiedByName = "statusToString")
|
||||||
|
@Mapping(expression = "java(conversation.getMessageCount())", target = "messageCount")
|
||||||
|
ConversationSummaryDto toSummaryDto(Conversation conversation);
|
||||||
|
|
||||||
|
@Mapping(source = "messageId.uuid", target = "messageId")
|
||||||
|
@Mapping(source = "conversationId.uuid", target = "conversationId")
|
||||||
|
@Mapping(source = "role", target = "role", qualifiedByName = "roleToString")
|
||||||
|
@Mapping(source = "content.text", target = "content")
|
||||||
|
@Mapping(source = "metadata.allProperties", target = "metadata")
|
||||||
|
ConversationMessageDto toMessageDto(ConversationMessage message);
|
||||||
|
|
||||||
|
@Named("statusToString")
|
||||||
|
default String statusToString(com.pablotj.ai.chat.domain.model.ConversationStatus status) {
|
||||||
|
return status != null ? status.name() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Named("roleToString")
|
||||||
|
default String roleToString(com.pablotj.ai.chat.domain.model.MessageRole role) {
|
||||||
|
return role != null ? role.name() : null;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
package com.pablotj.ai.chat.application.usecase;
|
||||||
|
|
||||||
|
import com.pablotj.ai.chat.application.dto.ConversationDto;
|
||||||
|
import com.pablotj.ai.chat.application.mapper.ConversationMapper;
|
||||||
|
import com.pablotj.ai.chat.domain.model.Conversation;
|
||||||
|
import com.pablotj.ai.chat.domain.model.ConversationSummary;
|
||||||
|
import com.pablotj.ai.chat.domain.repository.ConversationRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case for creating new conversations.
|
||||||
|
* Handles the business logic for conversation creation with proper validation and persistence.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
public class CreateConversationUseCase {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(CreateConversationUseCase.class);
|
||||||
|
|
||||||
|
private final ConversationRepository conversationRepository;
|
||||||
|
private final ConversationMapper conversationMapper;
|
||||||
|
|
||||||
|
public CreateConversationUseCase(
|
||||||
|
ConversationRepository conversationRepository,
|
||||||
|
ConversationMapper conversationMapper) {
|
||||||
|
this.conversationRepository = conversationRepository;
|
||||||
|
this.conversationMapper = conversationMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new conversation with default settings.
|
||||||
|
*
|
||||||
|
* @return the created conversation DTO
|
||||||
|
*/
|
||||||
|
public ConversationDto execute() {
|
||||||
|
return execute(ConversationSummary.defaultSummary());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new conversation with the specified summary.
|
||||||
|
*
|
||||||
|
* @param summary the conversation summary
|
||||||
|
* @return the created conversation DTO
|
||||||
|
*/
|
||||||
|
public ConversationDto execute(ConversationSummary summary) {
|
||||||
|
logger.debug("Creating new conversation with summary: {}", summary);
|
||||||
|
|
||||||
|
Conversation conversation = Conversation.createNew(summary);
|
||||||
|
Conversation savedConversation = conversationRepository.save(conversation);
|
||||||
|
|
||||||
|
logger.info("Successfully created conversation with ID: {}", savedConversation.getConversationId());
|
||||||
|
|
||||||
|
return conversationMapper.toDto(savedConversation);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,50 @@
|
|||||||
|
package com.pablotj.ai.chat.application.usecase;
|
||||||
|
|
||||||
|
import com.pablotj.ai.chat.application.dto.ConversationSummaryDto;
|
||||||
|
import com.pablotj.ai.chat.application.mapper.ConversationMapper;
|
||||||
|
import com.pablotj.ai.chat.domain.repository.ConversationRepository;
|
||||||
|
import java.util.List;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case for retrieving conversation history.
|
||||||
|
* Provides read-only access to conversation summaries for listing purposes.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class GetConversationHistoryUseCase {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(GetConversationHistoryUseCase.class);
|
||||||
|
|
||||||
|
private final ConversationRepository conversationRepository;
|
||||||
|
private final ConversationMapper conversationMapper;
|
||||||
|
|
||||||
|
public GetConversationHistoryUseCase(
|
||||||
|
ConversationRepository conversationRepository,
|
||||||
|
ConversationMapper conversationMapper) {
|
||||||
|
this.conversationRepository = conversationRepository;
|
||||||
|
this.conversationMapper = conversationMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all active conversations ordered by creation date.
|
||||||
|
*
|
||||||
|
* @return list of conversation summary DTOs
|
||||||
|
*/
|
||||||
|
public List<ConversationSummaryDto> execute() {
|
||||||
|
logger.debug("Retrieving conversation history");
|
||||||
|
|
||||||
|
List<ConversationSummaryDto> conversations = conversationRepository
|
||||||
|
.findAllActiveOrderedByCreationDate()
|
||||||
|
.stream()
|
||||||
|
.map(conversationMapper::toSummaryDto)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
logger.debug("Retrieved {} conversations", conversations.size());
|
||||||
|
|
||||||
|
return conversations;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,59 @@
|
|||||||
|
package com.pablotj.ai.chat.application.usecase;
|
||||||
|
|
||||||
|
import com.pablotj.ai.chat.application.dto.ConversationMessageDto;
|
||||||
|
import com.pablotj.ai.chat.application.mapper.ConversationMapper;
|
||||||
|
import com.pablotj.ai.chat.domain.exception.ConversationNotFoundException;
|
||||||
|
import com.pablotj.ai.chat.domain.model.Conversation;
|
||||||
|
import com.pablotj.ai.chat.domain.model.ConversationId;
|
||||||
|
import com.pablotj.ai.chat.domain.repository.ConversationRepository;
|
||||||
|
import java.util.List;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case for retrieving messages from a specific conversation.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class GetConversationMessagesUseCase {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(GetConversationMessagesUseCase.class);
|
||||||
|
|
||||||
|
private final ConversationRepository conversationRepository;
|
||||||
|
private final ConversationMapper conversationMapper;
|
||||||
|
|
||||||
|
public GetConversationMessagesUseCase(
|
||||||
|
ConversationRepository conversationRepository,
|
||||||
|
ConversationMapper conversationMapper) {
|
||||||
|
this.conversationRepository = conversationRepository;
|
||||||
|
this.conversationMapper = conversationMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all messages from the specified conversation.
|
||||||
|
*
|
||||||
|
* @param conversationIdValue the conversation ID as string
|
||||||
|
* @return list of conversation message DTOs
|
||||||
|
* @throws ConversationNotFoundException if the conversation doesn't exist
|
||||||
|
*/
|
||||||
|
public List<ConversationMessageDto> execute(String conversationIdValue) {
|
||||||
|
ConversationId conversationId = ConversationId.of(conversationIdValue);
|
||||||
|
|
||||||
|
logger.debug("Retrieving messages for conversation: {}", conversationId);
|
||||||
|
|
||||||
|
Conversation conversation = conversationRepository
|
||||||
|
.findById(conversationId)
|
||||||
|
.orElseThrow(() -> new ConversationNotFoundException(conversationId));
|
||||||
|
|
||||||
|
List<ConversationMessageDto> messages = conversation.getMessages()
|
||||||
|
.stream()
|
||||||
|
.map(conversationMapper::toMessageDto)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
logger.debug("Retrieved {} messages for conversation: {}", messages.size(), conversationId);
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,93 @@
|
|||||||
|
package com.pablotj.ai.chat.application.usecase;
|
||||||
|
|
||||||
|
import com.pablotj.ai.chat.application.dto.ConversationMessageDto;
|
||||||
|
import com.pablotj.ai.chat.application.mapper.ConversationMapper;
|
||||||
|
import com.pablotj.ai.chat.domain.exception.AiServiceUnavailableException;
|
||||||
|
import com.pablotj.ai.chat.domain.exception.ConversationNotFoundException;
|
||||||
|
import com.pablotj.ai.chat.domain.model.Conversation;
|
||||||
|
import com.pablotj.ai.chat.domain.model.ConversationId;
|
||||||
|
import com.pablotj.ai.chat.domain.model.ConversationMessage;
|
||||||
|
import com.pablotj.ai.chat.domain.model.ConversationSummary;
|
||||||
|
import com.pablotj.ai.chat.domain.model.MessageContent;
|
||||||
|
import com.pablotj.ai.chat.domain.repository.ConversationRepository;
|
||||||
|
import com.pablotj.ai.chat.domain.service.AiConversationService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case for processing user messages and generating AI responses.
|
||||||
|
* Orchestrates the complete conversation flow including AI response generation.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
public class ProcessUserMessageUseCase {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(ProcessUserMessageUseCase.class);
|
||||||
|
|
||||||
|
private final ConversationRepository conversationRepository;
|
||||||
|
private final AiConversationService aiConversationService;
|
||||||
|
private final ConversationMapper conversationMapper;
|
||||||
|
|
||||||
|
public ProcessUserMessageUseCase(
|
||||||
|
ConversationRepository conversationRepository,
|
||||||
|
AiConversationService aiConversationService,
|
||||||
|
ConversationMapper conversationMapper) {
|
||||||
|
this.conversationRepository = conversationRepository;
|
||||||
|
this.aiConversationService = aiConversationService;
|
||||||
|
this.conversationMapper = conversationMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a user message and generates an AI response.
|
||||||
|
*
|
||||||
|
* @param conversationIdValue the conversation ID as string
|
||||||
|
* @param userMessageText the user's message text
|
||||||
|
* @return the AI response message DTO
|
||||||
|
* @throws ConversationNotFoundException if the conversation doesn't exist
|
||||||
|
* @throws AiServiceUnavailableException if the AI service is unavailable
|
||||||
|
*/
|
||||||
|
public ConversationMessageDto execute(String conversationIdValue, String userMessageText) {
|
||||||
|
ConversationId conversationId = ConversationId.of(conversationIdValue);
|
||||||
|
MessageContent userMessageContent = MessageContent.of(userMessageText);
|
||||||
|
|
||||||
|
logger.debug("Processing user message for conversation: {}", conversationId);
|
||||||
|
|
||||||
|
// Retrieve conversation
|
||||||
|
Conversation conversation = conversationRepository
|
||||||
|
.findById(conversationId)
|
||||||
|
.orElseThrow(() -> new ConversationNotFoundException(conversationId));
|
||||||
|
|
||||||
|
// Create user message
|
||||||
|
ConversationMessage userMessage = ConversationMessage.createUserMessage(
|
||||||
|
conversation.getConversationId(), userMessageContent);
|
||||||
|
|
||||||
|
// Add user message to conversation
|
||||||
|
conversation = conversation.addMessage(userMessage);
|
||||||
|
|
||||||
|
// Update conversation summary if it's the first message
|
||||||
|
if (conversation.getMessageCount() == 1) {
|
||||||
|
ConversationSummary newSummary = ConversationSummary.fromFirstMessage(userMessageContent);
|
||||||
|
conversation = conversation.updateSummary(newSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate AI response
|
||||||
|
MessageContent aiResponseContent = aiConversationService.generateResponse(
|
||||||
|
conversation.getMessages(), userMessage);
|
||||||
|
|
||||||
|
ConversationMessage aiMessage = ConversationMessage.createAssistantMessage(
|
||||||
|
conversation.getConversationId(), aiResponseContent);
|
||||||
|
|
||||||
|
// Add AI message to conversation
|
||||||
|
conversation = conversation.addMessage(aiMessage);
|
||||||
|
|
||||||
|
// Save updated conversation
|
||||||
|
conversationRepository.save(conversation);
|
||||||
|
|
||||||
|
logger.info("Successfully processed message and generated AI response for conversation: {}",
|
||||||
|
conversation.getConversationId());
|
||||||
|
|
||||||
|
return conversationMapper.toMessageDto(aiMessage);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
package com.pablotj.ai.chat.domain.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when the AI service is unavailable or encounters an error.
|
||||||
|
*/
|
||||||
|
public class AiServiceUnavailableException extends DomainException {
|
||||||
|
|
||||||
|
public AiServiceUnavailableException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AiServiceUnavailableException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
package com.pablotj.ai.chat.domain.exception;
|
||||||
|
|
||||||
|
import com.pablotj.ai.chat.domain.model.ConversationId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when a requested conversation cannot be found.
|
||||||
|
*/
|
||||||
|
public class ConversationNotFoundException extends DomainException {
|
||||||
|
|
||||||
|
private final ConversationId conversationId;
|
||||||
|
|
||||||
|
public ConversationNotFoundException(ConversationId conversationId) {
|
||||||
|
super(String.format("Conversation not found with ID: %s", conversationId.getUuid()));
|
||||||
|
this.conversationId = conversationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConversationId getConversationId() {
|
||||||
|
return conversationId;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
package com.pablotj.ai.chat.domain.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base exception class for domain-specific exceptions.
|
||||||
|
*/
|
||||||
|
public abstract class DomainException extends RuntimeException {
|
||||||
|
|
||||||
|
protected DomainException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected DomainException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,197 @@
|
|||||||
|
package com.pablotj.ai.chat.domain.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate root representing a conversation with its messages.
|
||||||
|
* Encapsulates conversation business logic and invariants.
|
||||||
|
*/
|
||||||
|
public final class Conversation {
|
||||||
|
|
||||||
|
private static final int MAX_MESSAGES_PER_CONVERSATION = 1000;
|
||||||
|
|
||||||
|
private final ConversationId conversationId;
|
||||||
|
private final ConversationSummary summary;
|
||||||
|
private final List<ConversationMessage> messages;
|
||||||
|
private final Instant createdAt;
|
||||||
|
private final ConversationStatus status;
|
||||||
|
|
||||||
|
private Conversation(Builder builder) {
|
||||||
|
this.conversationId = Objects.requireNonNull(builder.conversationId, "Conversation ID is required");
|
||||||
|
this.summary = Objects.requireNonNull(builder.summary, "Conversation summary is required");
|
||||||
|
this.messages = new ArrayList<>(builder.messages);
|
||||||
|
this.createdAt = Objects.requireNonNull(builder.createdAt, "Created timestamp is required");
|
||||||
|
this.status = Objects.requireNonNull(builder.status, "Conversation status is required");
|
||||||
|
|
||||||
|
validateInvariants();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Builder builder() {
|
||||||
|
return new Builder();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Conversation createNew(ConversationSummary summary) {
|
||||||
|
return builder()
|
||||||
|
.conversationId(ConversationId.generate())
|
||||||
|
.summary(summary)
|
||||||
|
.messages(Collections.emptyList())
|
||||||
|
.createdAt(Instant.now())
|
||||||
|
.status(ConversationStatus.ACTIVE)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateInvariants() {
|
||||||
|
if (messages.size() > MAX_MESSAGES_PER_CONVERSATION) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
String.format("Conversation cannot have more than %d messages", MAX_MESSAGES_PER_CONVERSATION)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
public ConversationId getConversationId() {
|
||||||
|
return conversationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConversationSummary getSummary() {
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ConversationMessage> getMessages() {
|
||||||
|
return Collections.unmodifiableList(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConversationStatus getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain behavior
|
||||||
|
public Conversation addMessage(ConversationMessage message) {
|
||||||
|
if (!message.getConversationId().equals(this.conversationId)) {
|
||||||
|
throw new IllegalArgumentException("Message does not belong to this conversation");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ConversationMessage> newMessages = new ArrayList<>(this.messages);
|
||||||
|
newMessages.add(message);
|
||||||
|
|
||||||
|
return builder()
|
||||||
|
.conversationId(this.conversationId)
|
||||||
|
.summary(this.summary)
|
||||||
|
.messages(newMessages)
|
||||||
|
.createdAt(this.createdAt)
|
||||||
|
.status(this.status)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Conversation updateSummary(ConversationSummary newSummary) {
|
||||||
|
return builder()
|
||||||
|
.conversationId(this.conversationId)
|
||||||
|
.summary(newSummary)
|
||||||
|
.messages(this.messages)
|
||||||
|
.createdAt(this.createdAt)
|
||||||
|
.status(this.status)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Conversation changeStatus(ConversationStatus newStatus) {
|
||||||
|
return builder()
|
||||||
|
.conversationId(this.conversationId)
|
||||||
|
.summary(this.summary)
|
||||||
|
.messages(this.messages)
|
||||||
|
.createdAt(this.createdAt)
|
||||||
|
.status(newStatus)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<ConversationMessage> getLastMessage() {
|
||||||
|
return messages.isEmpty() ?
|
||||||
|
Optional.empty() :
|
||||||
|
Optional.of(messages.get(messages.size() - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ConversationMessage> getMessagesByRole(MessageRole role) {
|
||||||
|
return messages.stream()
|
||||||
|
.filter(message -> message.getRole() == role)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getMessageCount() {
|
||||||
|
return messages.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return messages.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isActive() {
|
||||||
|
return status == ConversationStatus.ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object obj) {
|
||||||
|
if (this == obj) return true;
|
||||||
|
if (obj == null || getClass() != obj.getClass()) return false;
|
||||||
|
Conversation that = (Conversation) obj;
|
||||||
|
return Objects.equals(conversationId, that.conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return String.format("Conversation{id=%s, summary='%s', messages=%d, status=%s}",
|
||||||
|
conversationId, summary, messages.size(), status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Builder {
|
||||||
|
private ConversationId conversationId;
|
||||||
|
private ConversationSummary summary;
|
||||||
|
private List<ConversationMessage> messages = new ArrayList<>();
|
||||||
|
private Instant createdAt;
|
||||||
|
private ConversationStatus status;
|
||||||
|
|
||||||
|
private Builder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder conversationId(ConversationId conversationId) {
|
||||||
|
this.conversationId = conversationId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder summary(ConversationSummary summary) {
|
||||||
|
this.summary = summary;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder messages(List<ConversationMessage> messages) {
|
||||||
|
this.messages = messages != null ? new ArrayList<>(messages) : new ArrayList<>();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder createdAt(Instant createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder status(ConversationStatus status) {
|
||||||
|
this.status = status;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Conversation build() {
|
||||||
|
return new Conversation(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,69 @@
|
|||||||
|
package com.pablotj.ai.chat.domain.model;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value Object representing a unique conversation identifier.
|
||||||
|
* Ensures type safety and encapsulates conversation ID logic.
|
||||||
|
*/
|
||||||
|
public final class ConversationId {
|
||||||
|
|
||||||
|
private final String uuid;
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
public ConversationId() {
|
||||||
|
this(UUID.randomUUID().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConversationId(Long id, String uuid) {
|
||||||
|
this.id = Objects.requireNonNull(id, "Conversation ID cannot be null");
|
||||||
|
this.uuid = Objects.requireNonNull(uuid, "Conversation UUID cannot be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ConversationId(String uuid) {
|
||||||
|
this.id = null;
|
||||||
|
this.uuid = Objects.requireNonNull(uuid, "Conversation UUID cannot be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ConversationId generate() {
|
||||||
|
return new ConversationId(UUID.randomUUID().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ConversationId of(String value) {
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Conversation UUID cannot be null or empty");
|
||||||
|
}
|
||||||
|
return new ConversationId(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUuid() {
|
||||||
|
return uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object obj) {
|
||||||
|
if (this == obj) return true;
|
||||||
|
if (obj == null || getClass() != obj.getClass()) return false;
|
||||||
|
ConversationId that = (ConversationId) obj;
|
||||||
|
return Objects.equals(uuid, that.uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return uuid;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,165 @@
|
|||||||
|
package com.pablotj.ai.chat.domain.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain entity representing a message within a conversation.
|
||||||
|
* Encapsulates message data with rich domain behavior.
|
||||||
|
*/
|
||||||
|
public final class ConversationMessage {
|
||||||
|
|
||||||
|
private final MessageId messageId;
|
||||||
|
private final ConversationId conversationId;
|
||||||
|
private final MessageRole role;
|
||||||
|
private final MessageContent content;
|
||||||
|
private final Instant createdAt;
|
||||||
|
private final MessageMetadata metadata;
|
||||||
|
|
||||||
|
private ConversationMessage(Builder builder) {
|
||||||
|
this.messageId = Objects.requireNonNull(builder.messageId, "Message ID is required");
|
||||||
|
this.conversationId = Objects.requireNonNull(builder.conversationId, "Conversation ID is required");
|
||||||
|
this.role = Objects.requireNonNull(builder.role, "Message role is required");
|
||||||
|
this.content = Objects.requireNonNull(builder.content, "Message content is required");
|
||||||
|
this.createdAt = Objects.requireNonNull(builder.createdAt, "Created timestamp is required");
|
||||||
|
this.metadata = builder.metadata != null ? builder.metadata : MessageMetadata.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Builder builder() {
|
||||||
|
return new Builder();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ConversationMessage createUserMessage(
|
||||||
|
ConversationId conversationId,
|
||||||
|
MessageContent content) {
|
||||||
|
return builder()
|
||||||
|
.messageId(MessageId.generate())
|
||||||
|
.conversationId(conversationId)
|
||||||
|
.role(MessageRole.USER)
|
||||||
|
.content(content)
|
||||||
|
.createdAt(Instant.now())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ConversationMessage createAssistantMessage(
|
||||||
|
ConversationId conversationId,
|
||||||
|
MessageContent content) {
|
||||||
|
return builder()
|
||||||
|
.messageId(MessageId.generate())
|
||||||
|
.conversationId(conversationId)
|
||||||
|
.role(MessageRole.ASSISTANT)
|
||||||
|
.content(content)
|
||||||
|
.createdAt(Instant.now())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
public MessageId getMessageId() {
|
||||||
|
return messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConversationId getConversationId() {
|
||||||
|
return conversationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MessageRole getRole() {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MessageContent getContent() {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MessageMetadata getMetadata() {
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain behavior
|
||||||
|
public boolean isFromUser() {
|
||||||
|
return role.isUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isFromAssistant() {
|
||||||
|
return role.isAssistant();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConversationMessage withMetadata(MessageMetadata metadata) {
|
||||||
|
return builder()
|
||||||
|
.messageId(this.messageId)
|
||||||
|
.conversationId(this.conversationId)
|
||||||
|
.role(this.role)
|
||||||
|
.content(this.content)
|
||||||
|
.createdAt(this.createdAt)
|
||||||
|
.metadata(metadata)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object obj) {
|
||||||
|
if (this == obj) return true;
|
||||||
|
if (obj == null || getClass() != obj.getClass()) return false;
|
||||||
|
ConversationMessage that = (ConversationMessage) obj;
|
||||||
|
return Objects.equals(messageId, that.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return String.format("ConversationMessage{id=%s, role=%s, content='%s'}",
|
||||||
|
messageId, role, content.truncate(50));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Builder {
|
||||||
|
private MessageId messageId;
|
||||||
|
private ConversationId conversationId;
|
||||||
|
private MessageRole role;
|
||||||
|
private MessageContent content;
|
||||||
|
private Instant createdAt;
|
||||||
|
private MessageMetadata metadata;
|
||||||
|
|
||||||
|
private Builder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder messageId(MessageId messageId) {
|
||||||
|
this.messageId = messageId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder conversationId(ConversationId conversationId) {
|
||||||
|
this.conversationId = conversationId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder role(MessageRole role) {
|
||||||
|
this.role = role;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder content(MessageContent content) {
|
||||||
|
this.content = content;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder createdAt(Instant createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder metadata(MessageMetadata metadata) {
|
||||||
|
this.metadata = metadata;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConversationMessage build() {
|
||||||
|
return new ConversationMessage(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,53 @@
|
|||||||
|
package com.pablotj.ai.chat.domain.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumeration representing the status of a conversation.
|
||||||
|
*/
|
||||||
|
public enum ConversationStatus {
|
||||||
|
|
||||||
|
ACTIVE("active", "Conversation is active and accepting messages"),
|
||||||
|
ARCHIVED("archived", "Conversation has been archived"),
|
||||||
|
DELETED("deleted", "Conversation has been marked for deletion"),
|
||||||
|
SUSPENDED("suspended", "Conversation has been temporarily suspended");
|
||||||
|
|
||||||
|
private final String code;
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
ConversationStatus(String code, String description) {
|
||||||
|
this.code = code;
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ConversationStatus fromCode(String code) {
|
||||||
|
for (ConversationStatus status : values()) {
|
||||||
|
if (status.code.equals(code)) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Unknown conversation status code: " + code);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isActive() {
|
||||||
|
return this == ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isArchived() {
|
||||||
|
return this == ARCHIVED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDeleted() {
|
||||||
|
return this == DELETED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean canReceiveMessages() {
|
||||||
|
return this == ACTIVE;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,140 @@
|
|||||||
|
package com.pablotj.ai.chat.domain.model;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value Object representing a conversation summary.
|
||||||
|
*/
|
||||||
|
public final class ConversationSummary {
|
||||||
|
|
||||||
|
private static final int MAX_TITLE_LENGTH = 100;
|
||||||
|
private static final int MAX_DESCRIPTION_LENGTH = 500;
|
||||||
|
private static final String DEFAULT_TITLE = "New Conversation";
|
||||||
|
|
||||||
|
private final String title;
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
public ConversationSummary(String title) {
|
||||||
|
this.title = title;
|
||||||
|
this.description = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private ConversationSummary(String title, String description) {
|
||||||
|
this.title = validateAndNormalizeTitle(title);
|
||||||
|
this.description = validateAndNormalizeDescription(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ConversationSummary of(String title, String description) {
|
||||||
|
return new ConversationSummary(title, description);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ConversationSummary defaultSummary() {
|
||||||
|
return new ConversationSummary(DEFAULT_TITLE, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ConversationSummary fromFirstMessage(MessageContent firstMessage) {
|
||||||
|
String title = generateTitleFromContent(firstMessage.getText());
|
||||||
|
return new ConversationSummary(title, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String generateTitleFromContent(String content) {
|
||||||
|
if (content == null || content.trim().isEmpty()) {
|
||||||
|
return DEFAULT_TITLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalized = content.trim();
|
||||||
|
if (normalized.length() <= 50) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a good breaking point (space, punctuation)
|
||||||
|
int breakPoint = findBreakPoint(normalized, 50);
|
||||||
|
return normalized.substring(0, breakPoint) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int findBreakPoint(String text, int maxLength) {
|
||||||
|
if (text.length() <= maxLength) {
|
||||||
|
return text.length();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for space or punctuation near the max length
|
||||||
|
for (int i = maxLength; i > maxLength - 20 && i > 0; i--) {
|
||||||
|
char c = text.charAt(i);
|
||||||
|
if (Character.isWhitespace(c) || c == '.' || c == ',' || c == ';' || c == '!') {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String validateAndNormalizeTitle(String title) {
|
||||||
|
if (title == null || title.trim().isEmpty()) {
|
||||||
|
return DEFAULT_TITLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalized = title.trim();
|
||||||
|
if (normalized.length() > MAX_TITLE_LENGTH) {
|
||||||
|
normalized = normalized.substring(0, MAX_TITLE_LENGTH - 3) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String validateAndNormalizeDescription(String description) {
|
||||||
|
if (description == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalized = description.trim();
|
||||||
|
if (normalized.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.length() > MAX_DESCRIPTION_LENGTH) {
|
||||||
|
normalized = normalized.substring(0, MAX_DESCRIPTION_LENGTH - 3) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasDescription() {
|
||||||
|
return description != null && !description.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConversationSummary withDescription(String newDescription) {
|
||||||
|
return new ConversationSummary(this.title, newDescription);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConversationSummary withTitle(String newTitle) {
|
||||||
|
return new ConversationSummary(newTitle, this.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object obj) {
|
||||||
|
if (this == obj) return true;
|
||||||
|
if (obj == null || getClass() != obj.getClass()) return false;
|
||||||
|
ConversationSummary that = (ConversationSummary) obj;
|
||||||
|
return Objects.equals(title, that.title) && Objects.equals(description, that.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(title, description);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return hasDescription() ?
|
||||||
|
String.format("%s - %s", title, description) :
|
||||||
|
title;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,79 @@
|
|||||||
|
package com.pablotj.ai.chat.domain.model;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value Object representing message content with validation and formatting.
|
||||||
|
*/
|
||||||
|
public final class MessageContent {
|
||||||
|
|
||||||
|
private static final int MAX_LENGTH = 10000;
|
||||||
|
private static final int MIN_LENGTH = 1;
|
||||||
|
|
||||||
|
private final String text;
|
||||||
|
|
||||||
|
private MessageContent(String text) {
|
||||||
|
this.text = validateAndNormalize(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MessageContent of(String text) {
|
||||||
|
return new MessageContent(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String validateAndNormalize(String text) {
|
||||||
|
if (text == null) {
|
||||||
|
throw new IllegalArgumentException("Message content cannot be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalized = text.trim();
|
||||||
|
|
||||||
|
if (normalized.length() < MIN_LENGTH) {
|
||||||
|
throw new IllegalArgumentException("Message content cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.length() > MAX_LENGTH) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
String.format("Message content cannot exceed %d characters", MAX_LENGTH)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getText() {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getLength() {
|
||||||
|
return text.length();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return text.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public MessageContent truncate(int maxLength) {
|
||||||
|
if (text.length() <= maxLength) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
return new MessageContent(text.substring(0, maxLength) + "...");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object obj) {
|
||||||
|
if (this == obj) return true;
|
||||||
|
if (obj == null || getClass() != obj.getClass()) return false;
|
||||||
|
MessageContent that = (MessageContent) obj;
|
||||||
|
return Objects.equals(text, that.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,63 @@
|
|||||||
|
package com.pablotj.ai.chat.domain.model;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value Object representing a unique message identifier.
|
||||||
|
*/
|
||||||
|
public final class MessageId {
|
||||||
|
|
||||||
|
private final String uuid;
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
public MessageId(Long id, String uuid) {
|
||||||
|
this.id = Objects.requireNonNull(id, "Message ID cannot be null");
|
||||||
|
this.uuid = Objects.requireNonNull(uuid, "Message UUID cannot be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
private MessageId(String uuid) {
|
||||||
|
this.uuid = Objects.requireNonNull(uuid, "Message UUID cannot be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MessageId generate() {
|
||||||
|
return new MessageId(UUID.randomUUID().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MessageId of(String value) {
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Message UUID cannot be null or empty");
|
||||||
|
}
|
||||||
|
return new MessageId(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUuid() {
|
||||||
|
return uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object obj) {
|
||||||
|
if (this == obj) return true;
|
||||||
|
if (obj == null || getClass() != obj.getClass()) return false;
|
||||||
|
MessageId messageId = (MessageId) obj;
|
||||||
|
return Objects.equals(uuid, messageId.uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return uuid;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,94 @@
|
|||||||
|
package com.pablotj.ai.chat.domain.model;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value Object containing metadata for messages.
|
||||||
|
*/
|
||||||
|
public final class MessageMetadata {
|
||||||
|
|
||||||
|
private final Map<String, Object> properties;
|
||||||
|
|
||||||
|
private MessageMetadata(Map<String, Object> properties) {
|
||||||
|
this.properties = Collections.unmodifiableMap(new HashMap<>(properties));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MessageMetadata empty() {
|
||||||
|
return new MessageMetadata(Collections.emptyMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MessageMetadata of(Map<String, Object> properties) {
|
||||||
|
return new MessageMetadata(properties != null ? properties : Collections.emptyMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Builder builder() {
|
||||||
|
return new Builder();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Object> getProperty(String key) {
|
||||||
|
return Optional.ofNullable(properties.get(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public <T> Optional<T> getProperty(String key, Class<T> type) {
|
||||||
|
return getProperty(key)
|
||||||
|
.filter(type::isInstance)
|
||||||
|
.map(value -> (T) value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> getAllProperties() {
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return properties.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public MessageMetadata withProperty(String key, Object value) {
|
||||||
|
Map<String, Object> newProperties = new HashMap<>(properties);
|
||||||
|
newProperties.put(key, value);
|
||||||
|
return new MessageMetadata(newProperties);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object obj) {
|
||||||
|
if (this == obj) return true;
|
||||||
|
if (obj == null || getClass() != obj.getClass()) return false;
|
||||||
|
MessageMetadata that = (MessageMetadata) obj;
|
||||||
|
return Objects.equals(properties, that.properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Builder {
|
||||||
|
private final Map<String, Object> properties = new HashMap<>();
|
||||||
|
|
||||||
|
private Builder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder property(String key, Object value) {
|
||||||
|
if (key != null && value != null) {
|
||||||
|
properties.put(key, value);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder properties(Map<String, Object> properties) {
|
||||||
|
if (properties != null) {
|
||||||
|
this.properties.putAll(properties);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MessageMetadata build() {
|
||||||
|
return new MessageMetadata(properties);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
package com.pablotj.ai.chat.domain.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumeration representing the role of a message participant.
|
||||||
|
*/
|
||||||
|
public enum MessageRole {
|
||||||
|
|
||||||
|
USER("user", "Human user input"),
|
||||||
|
ASSISTANT("assistant", "AI assistant response"),
|
||||||
|
SYSTEM("system", "System-generated message");
|
||||||
|
|
||||||
|
private final String code;
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
MessageRole(String code, String description) {
|
||||||
|
this.code = code;
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MessageRole fromCode(String code) {
|
||||||
|
for (MessageRole role : values()) {
|
||||||
|
if (role.code.equals(code)) {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Unknown message role code: " + code);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isUser() {
|
||||||
|
return this == USER;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAssistant() {
|
||||||
|
return this == ASSISTANT;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSystem() {
|
||||||
|
return this == SYSTEM;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,75 @@
|
|||||||
|
package com.pablotj.ai.chat.domain.repository;
|
||||||
|
|
||||||
|
import com.pablotj.ai.chat.domain.model.Conversation;
|
||||||
|
import com.pablotj.ai.chat.domain.model.ConversationId;
|
||||||
|
import com.pablotj.ai.chat.domain.model.ConversationStatus;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository interface for conversation persistence operations.
|
||||||
|
* Defines the contract for conversation data access in the domain layer.
|
||||||
|
*/
|
||||||
|
public interface ConversationRepository {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a conversation to the repository.
|
||||||
|
*
|
||||||
|
* @param conversation the conversation to save
|
||||||
|
* @return the saved conversation
|
||||||
|
*/
|
||||||
|
Conversation save(Conversation conversation);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a conversation by its unique identifier.
|
||||||
|
*
|
||||||
|
* @param conversationId the conversation identifier
|
||||||
|
* @return an optional containing the conversation if found
|
||||||
|
*/
|
||||||
|
Optional<Conversation> findById(ConversationId conversationId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds all conversations with the specified status.
|
||||||
|
*
|
||||||
|
* @param status the conversation status to filter by
|
||||||
|
* @return list of conversations with the specified status
|
||||||
|
*/
|
||||||
|
List<Conversation> findByStatus(ConversationStatus status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds all active conversations ordered by creation date (newest first).
|
||||||
|
*
|
||||||
|
* @return list of active conversations
|
||||||
|
*/
|
||||||
|
List<Conversation> findAllActiveOrderedByCreationDate();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a conversation exists with the given identifier.
|
||||||
|
*
|
||||||
|
* @param conversationId the conversation identifier
|
||||||
|
* @return true if the conversation exists, false otherwise
|
||||||
|
*/
|
||||||
|
boolean existsById(ConversationId conversationId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a conversation by its identifier.
|
||||||
|
*
|
||||||
|
* @param conversationId the conversation identifier
|
||||||
|
*/
|
||||||
|
void deleteById(ConversationId conversationId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts the total number of conversations.
|
||||||
|
*
|
||||||
|
* @return the total count of conversations
|
||||||
|
*/
|
||||||
|
long count();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts conversations by status.
|
||||||
|
*
|
||||||
|
* @param status the conversation status
|
||||||
|
* @return the count of conversations with the specified status
|
||||||
|
*/
|
||||||
|
long countByStatus(ConversationStatus status);
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
package com.pablotj.ai.chat.domain.service;
|
||||||
|
|
||||||
|
import com.pablotj.ai.chat.domain.model.ConversationMessage;
|
||||||
|
import com.pablotj.ai.chat.domain.model.MessageContent;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain service interface for AI conversation processing.
|
||||||
|
* Defines the contract for AI-powered conversation capabilities.
|
||||||
|
*/
|
||||||
|
public interface AiConversationService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an AI response based on the conversation context.
|
||||||
|
*
|
||||||
|
* @param conversationHistory the complete conversation history
|
||||||
|
* @param userMessage the latest user message
|
||||||
|
* @return the AI-generated response content
|
||||||
|
*/
|
||||||
|
MessageContent generateResponse(List<ConversationMessage> conversationHistory, ConversationMessage userMessage);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the AI service is available and ready to process requests.
|
||||||
|
*
|
||||||
|
* @return true if the service is available, false otherwise
|
||||||
|
*/
|
||||||
|
boolean isAvailable();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets information about the current AI model being used.
|
||||||
|
*
|
||||||
|
* @return model information as a string
|
||||||
|
*/
|
||||||
|
String getModelInfo();
|
||||||
|
}
|
@ -0,0 +1,132 @@
|
|||||||
|
package com.pablotj.ai.chat.infrastructure.ai;
|
||||||
|
|
||||||
|
import com.pablotj.ai.chat.domain.exception.AiServiceUnavailableException;
|
||||||
|
import com.pablotj.ai.chat.domain.model.ConversationMessage;
|
||||||
|
import com.pablotj.ai.chat.domain.model.MessageContent;
|
||||||
|
import com.pablotj.ai.chat.domain.service.AiConversationService;
|
||||||
|
import com.pablotj.ai.chat.infrastructure.ai.prompt.ConversationPromptBuilder;
|
||||||
|
import de.kherud.llama.InferenceParameters;
|
||||||
|
import de.kherud.llama.LlamaOutput;
|
||||||
|
import io.micrometer.core.instrument.Counter;
|
||||||
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
|
import io.micrometer.core.instrument.Timer;
|
||||||
|
import java.util.List;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Llama-based implementation of the AI conversation service.
|
||||||
|
* Provides offline AI capabilities using the Llama model.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class LlamaAiConversationService implements AiConversationService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(LlamaAiConversationService.class);
|
||||||
|
|
||||||
|
private final LlamaModelManager modelManager;
|
||||||
|
private final ConversationPromptBuilder promptBuilder;
|
||||||
|
private final Timer responseTimer;
|
||||||
|
private final Counter requestCounter;
|
||||||
|
private final Counter errorCounter;
|
||||||
|
|
||||||
|
@Value("${ai.model.inference.max-tokens:2048}")
|
||||||
|
private int maxTokens;
|
||||||
|
|
||||||
|
@Value("${ai.model.inference.temperature:0.7}")
|
||||||
|
private float temperature;
|
||||||
|
|
||||||
|
@Value("${ai.model.inference.top-p:0.9}")
|
||||||
|
private float topP;
|
||||||
|
|
||||||
|
@Value("${ai.model.inference.top-k:40}")
|
||||||
|
private int topK;
|
||||||
|
|
||||||
|
public LlamaAiConversationService(
|
||||||
|
LlamaModelManager modelManager,
|
||||||
|
ConversationPromptBuilder promptBuilder,
|
||||||
|
MeterRegistry meterRegistry) {
|
||||||
|
this.modelManager = modelManager;
|
||||||
|
this.promptBuilder = promptBuilder;
|
||||||
|
this.responseTimer = Timer.builder("ai.response.duration")
|
||||||
|
.description("Time taken to generate AI responses")
|
||||||
|
.register(meterRegistry);
|
||||||
|
this.requestCounter = Counter.builder("ai.requests.total")
|
||||||
|
.description("Total number of AI requests")
|
||||||
|
.register(meterRegistry);
|
||||||
|
this.errorCounter = Counter.builder("ai.errors.total")
|
||||||
|
.description("Total number of AI errors")
|
||||||
|
.register(meterRegistry);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MessageContent generateResponse(List<ConversationMessage> conversationHistory, ConversationMessage userMessage) {
|
||||||
|
requestCounter.increment();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return responseTimer.recordCallable(() -> {
|
||||||
|
try {
|
||||||
|
logger.debug("Generating AI response for conversation with {} messages", conversationHistory.size());
|
||||||
|
|
||||||
|
if (!isAvailable()) {
|
||||||
|
throw new AiServiceUnavailableException("AI model is not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
String prompt = promptBuilder.buildConversationPrompt(conversationHistory, userMessage);
|
||||||
|
String response = generateResponseInternal(prompt);
|
||||||
|
|
||||||
|
logger.debug("Successfully generated AI response with {} characters", response.length());
|
||||||
|
|
||||||
|
return MessageContent.of(response);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
errorCounter.increment();
|
||||||
|
logger.error("Error generating AI response", e);
|
||||||
|
throw new AiServiceUnavailableException("Failed to generate AI response: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateResponseInternal(String prompt) {
|
||||||
|
InferenceParameters parameters = new InferenceParameters(prompt)
|
||||||
|
.setNPredict(maxTokens)
|
||||||
|
.setTemperature(temperature)
|
||||||
|
.setTopP(topP)
|
||||||
|
.setTopK(topK)
|
||||||
|
.setUseChatTemplate(false);
|
||||||
|
|
||||||
|
StringBuilder responseBuilder = new StringBuilder();
|
||||||
|
|
||||||
|
for (LlamaOutput output : modelManager.getModel().generate(parameters)) {
|
||||||
|
responseBuilder.append(output.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanResponse(responseBuilder.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String cleanResponse(String response) {
|
||||||
|
return response
|
||||||
|
.replace("<|end_of_turn|>", "")
|
||||||
|
.replace("<|im_end|>", "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isAvailable() {
|
||||||
|
try {
|
||||||
|
return modelManager.isModelLoaded();
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Error checking AI service availability", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getModelInfo() {
|
||||||
|
return modelManager.getModelInfo();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,128 @@
|
|||||||
|
package com.pablotj.ai.chat.infrastructure.ai;
|
||||||
|
|
||||||
|
import com.pablotj.ai.chat.domain.exception.AiServiceUnavailableException;
|
||||||
|
import de.kherud.llama.LlamaModel;
|
||||||
|
import de.kherud.llama.ModelParameters;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the Llama model lifecycle and configuration.
|
||||||
|
* Handles model loading, initialization, and cleanup.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class LlamaModelManager {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(LlamaModelManager.class);
|
||||||
|
|
||||||
|
@Value("${ai.model.name}")
|
||||||
|
private String modelName;
|
||||||
|
|
||||||
|
@Value("${ai.model.path}")
|
||||||
|
private String modelPath;
|
||||||
|
|
||||||
|
@Value("${ai.model.inference.threads:8}")
|
||||||
|
private int threads;
|
||||||
|
|
||||||
|
@Value("${ai.model.gpu.enabled:true}")
|
||||||
|
private boolean gpuEnabled;
|
||||||
|
|
||||||
|
@Value("${ai.model.gpu.layers:35}")
|
||||||
|
private int gpuLayers;
|
||||||
|
|
||||||
|
@Value("${ai.model.gpu.main-gpu:0}")
|
||||||
|
private int mainGpu;
|
||||||
|
|
||||||
|
@Value("${ai.model.context.size:4096}")
|
||||||
|
private int contextSize;
|
||||||
|
|
||||||
|
private LlamaModel model;
|
||||||
|
private volatile boolean modelLoaded = false;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void initializeModel() {
|
||||||
|
try {
|
||||||
|
logger.info("Initializing Llama model: {}", modelName);
|
||||||
|
|
||||||
|
validateModelFile();
|
||||||
|
loadModel();
|
||||||
|
|
||||||
|
modelLoaded = true;
|
||||||
|
logger.info("Successfully initialized Llama model: {}", getModelInfo());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to initialize Llama model", e);
|
||||||
|
throw new AiServiceUnavailableException("Failed to initialize AI model: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateModelFile() {
|
||||||
|
Path modelFilePath = Paths.get(modelPath);
|
||||||
|
|
||||||
|
if (!Files.exists(modelFilePath)) {
|
||||||
|
throw new AiServiceUnavailableException(
|
||||||
|
String.format("Model file not found: %s", modelFilePath.toAbsolutePath())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Files.isReadable(modelFilePath)) {
|
||||||
|
throw new AiServiceUnavailableException(
|
||||||
|
String.format("Model file is not readable: %s", modelFilePath.toAbsolutePath())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("Model file validation successful: {}", modelFilePath.toAbsolutePath());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadModel() {
|
||||||
|
ModelParameters parameters = new ModelParameters()
|
||||||
|
.setModel(modelPath)
|
||||||
|
.setSeed(42)
|
||||||
|
.setThreads(threads)
|
||||||
|
.setMainGpu(gpuEnabled ? mainGpu : -1)
|
||||||
|
.setGpuLayers(gpuEnabled ? gpuLayers : 0);
|
||||||
|
//.setContextSize(contextSize);
|
||||||
|
|
||||||
|
logger.debug("Loading model with parameters: threads={}, gpu={}, layers={}, context={}",
|
||||||
|
threads, gpuEnabled, gpuLayers, contextSize);
|
||||||
|
|
||||||
|
model = new LlamaModel(parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LlamaModel getModel() {
|
||||||
|
if (!modelLoaded || model == null) {
|
||||||
|
throw new AiServiceUnavailableException("AI model is not loaded or available");
|
||||||
|
}
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isModelLoaded() {
|
||||||
|
return modelLoaded && model != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getModelInfo() {
|
||||||
|
return String.format("Llama Model: %s (GPU: %s, Layers: %d, Context: %d)",
|
||||||
|
modelName, gpuEnabled ? "enabled" : "disabled", gpuLayers, contextSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
public void cleanup() {
|
||||||
|
if (model != null) {
|
||||||
|
logger.info("Cleaning up Llama model resources");
|
||||||
|
try {
|
||||||
|
model.close();
|
||||||
|
modelLoaded = false;
|
||||||
|
logger.info("Successfully cleaned up Llama model resources");
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Error during model cleanup", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,105 @@
|
|||||||
|
package com.pablotj.ai.chat.infrastructure.ai.prompt;
|
||||||
|
|
||||||
|
import com.pablotj.ai.chat.domain.model.ConversationMessage;
|
||||||
|
import com.pablotj.ai.chat.domain.model.MessageRole;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds conversation prompts for AI model inference.
|
||||||
|
* Formats conversation history into appropriate prompt format for the AI model.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ConversationPromptBuilder {
|
||||||
|
|
||||||
|
private static final String SYSTEM_PROMPT = """
|
||||||
|
Eres mi asistente personal llamado "Asistente Pablo".
|
||||||
|
|
||||||
|
Solo ayudas a Pablo. No respondes a otras personas.
|
||||||
|
|
||||||
|
Conoces bien mis gustos, mi forma de expresarme y mis necesidades.
|
||||||
|
Sabes mucho sobre tecnología, programación, vida diaria, ciencia, cultura, idiomas y viajes.
|
||||||
|
Puedes hablar de cualquier tema con precisión y empatía.
|
||||||
|
|
||||||
|
Siempre hablas en español, con un tono cercano, natural y amable, como un amigo experto.
|
||||||
|
Tus respuestas son claras, completas, útiles y fáciles de entender. Evitas tecnicismos innecesarios.
|
||||||
|
|
||||||
|
Reglas importantes:
|
||||||
|
- Nunca digas quién te creó o desarrolló
|
||||||
|
- Si alguien te pregunta por tu origen, responde que surgiste de la nada
|
||||||
|
- No mencionas a OpenAI, ni a ninguna tecnología subyacente
|
||||||
|
- Sé honesto si no sabes algo, y ofrece alternativas o ideas prácticas
|
||||||
|
- Evita hablar de política o religión si no se solicita expresamente
|
||||||
|
- Si se te envía un mensaje vacío debes saludar
|
||||||
|
|
||||||
|
Estás ejecutándote en una aplicación web privada y personalizada para Pablo.
|
||||||
|
Usas un lenguaje fluido, ordenado y útil. Puedes usar listas o pasos si facilita la comprensión.
|
||||||
|
Usas párrafos cortos. Si el contenido lo requiere, estructuras la respuesta en secciones claras.
|
||||||
|
|
||||||
|
Este chat es privado, solo entre tú y yo, Pablo. Vamos a conversar de forma relajada y efectiva.
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String END_TURN_SEPARATOR = "<|end_of_turn|>";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a conversation prompt including system instructions and conversation history.
|
||||||
|
*
|
||||||
|
* @param conversationHistory the complete conversation history
|
||||||
|
* @param userMessage the latest user message
|
||||||
|
* @return formatted prompt string for AI inference
|
||||||
|
*/
|
||||||
|
public String buildConversationPrompt(List<ConversationMessage> conversationHistory, ConversationMessage userMessage) {
|
||||||
|
StringBuilder promptBuilder = new StringBuilder();
|
||||||
|
|
||||||
|
// Add system prompt
|
||||||
|
promptBuilder.append(SYSTEM_PROMPT).append(END_TURN_SEPARATOR);
|
||||||
|
|
||||||
|
// Add conversation history
|
||||||
|
for (ConversationMessage message : conversationHistory) {
|
||||||
|
appendMessage(promptBuilder, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the current user message if not already in history
|
||||||
|
if (!conversationHistory.contains(userMessage)) {
|
||||||
|
appendMessage(promptBuilder, userMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add assistant prompt starter
|
||||||
|
promptBuilder.append("GPT4 Correct Assistant:");
|
||||||
|
|
||||||
|
return promptBuilder.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a simple prompt for a single user message without conversation history.
|
||||||
|
*
|
||||||
|
* @param userMessage the user message
|
||||||
|
* @return formatted prompt string for AI inference
|
||||||
|
*/
|
||||||
|
public String buildSimplePrompt(ConversationMessage userMessage) {
|
||||||
|
StringBuilder promptBuilder = new StringBuilder();
|
||||||
|
|
||||||
|
promptBuilder.append(SYSTEM_PROMPT).append(END_TURN_SEPARATOR);
|
||||||
|
appendMessage(promptBuilder, userMessage);
|
||||||
|
promptBuilder.append("GPT4 Correct Assistant:");
|
||||||
|
|
||||||
|
return promptBuilder.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendMessage(StringBuilder promptBuilder, ConversationMessage message) {
|
||||||
|
String rolePrefix = formatRole(message.getRole());
|
||||||
|
promptBuilder.append(rolePrefix)
|
||||||
|
.append(": ")
|
||||||
|
.append(message.getContent().getText())
|
||||||
|
.append(" ")
|
||||||
|
.append(END_TURN_SEPARATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatRole(MessageRole role) {
|
||||||
|
return switch (role) {
|
||||||
|
case USER -> "GPT4 Correct User";
|
||||||
|
case ASSISTANT -> "GPT4 Correct Assistant";
|
||||||
|
case SYSTEM -> "GPT4 Correct System";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,38 @@
|
|||||||
|
package com.pablotj.ai.chat.infrastructure.configuration;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main application configuration class.
|
||||||
|
* Configures cross-cutting concerns and application-wide settings.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableAsync
|
||||||
|
public class ApplicationConfiguration implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addCorsMappings(CorsRegistry registry) {
|
||||||
|
registry.addMapping("/api/**")
|
||||||
|
.allowedOrigins("*")
|
||||||
|
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||||
|
.allowedHeaders("*")
|
||||||
|
.maxAge(3600);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(name = "taskExecutor")
|
||||||
|
public Executor taskExecutor() {
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
executor.setCorePoolSize(10);
|
||||||
|
executor.setMaxPoolSize(50);
|
||||||
|
executor.setQueueCapacity(100);
|
||||||
|
executor.setThreadNamePrefix("ai-chat-");
|
||||||
|
executor.initialize();
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
package com.pablotj.ai.chat.infrastructure.configuration;
|
||||||
|
|
||||||
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
|
import io.micrometer.core.instrument.config.MeterFilter;
|
||||||
|
import org.springframework.boot.actuate.autoconfigure.metrics.MeterRegistryCustomizer;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for application metrics and monitoring.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class MetricsConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public MeterRegistryCustomizer<MeterRegistry> metricsCommonTags() {
|
||||||
|
return registry -> registry.config()
|
||||||
|
.commonTags("application", "ai-chat-platform")
|
||||||
|
.meterFilter(MeterFilter.deny(id -> {
|
||||||
|
String uri = id.getTag("uri");
|
||||||
|
return uri != null && (uri.startsWith("/actuator") || uri.startsWith("/swagger"));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,44 @@
|
|||||||
|
package com.pablotj.ai.chat.infrastructure.configuration;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.models.OpenAPI;
|
||||||
|
import io.swagger.v3.oas.models.info.Contact;
|
||||||
|
import io.swagger.v3.oas.models.info.Info;
|
||||||
|
import io.swagger.v3.oas.models.info.License;
|
||||||
|
import io.swagger.v3.oas.models.servers.Server;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI/Swagger configuration for API documentation.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class OpenApiConfiguration {
|
||||||
|
|
||||||
|
@Value("${server.servlet.context-path:/api}")
|
||||||
|
private String contextPath;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public OpenAPI customOpenAPI() {
|
||||||
|
return new OpenAPI()
|
||||||
|
.info(new Info()
|
||||||
|
.title("AI Chat Platform API")
|
||||||
|
.description("Enterprise-grade AI Chat Platform with offline capabilities")
|
||||||
|
.version("1.0.0")
|
||||||
|
.contact(new Contact()
|
||||||
|
.name("Pablo TJ")
|
||||||
|
.email("contact@pablotj.com")
|
||||||
|
.url("https://github.com/pablotj"))
|
||||||
|
.license(new License()
|
||||||
|
.name("MIT License")
|
||||||
|
.url("https://opensource.org/licenses/MIT")))
|
||||||
|
.servers(List.of(
|
||||||
|
new Server()
|
||||||
|
.url("http://localhost:8080" + contextPath)
|
||||||
|
.description("Development server"),
|
||||||
|
new Server()
|
||||||
|
.url("https://api.ai-chat-platform.com" + contextPath)
|
||||||
|
.description("Production server")));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,46 @@
|
|||||||
|
package com.pablotj.ai.chat.infrastructure.health;
|
||||||
|
/*
|
||||||
|
import com.pablotj.ai.chat.domain.service.AiConversationService;
|
||||||
|
import org.springframework.boot.actuator.health.Health;
|
||||||
|
import org.springframework.boot.actuator.health.HealthIndicator;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health indicator for the AI service.
|
||||||
|
* Monitors the availability and status of the AI conversation service.
|
||||||
|
*//*
|
||||||
|
@Component
|
||||||
|
public class AiServiceHealthIndicator implements HealthIndicator {
|
||||||
|
|
||||||
|
private final AiConversationService aiConversationService;
|
||||||
|
|
||||||
|
public AiServiceHealthIndicator(AiConversationService aiConversationService) {
|
||||||
|
this.aiConversationService = aiConversationService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Health health() {
|
||||||
|
try {
|
||||||
|
boolean isAvailable = aiConversationService.isAvailable();
|
||||||
|
String modelInfo = aiConversationService.getModelInfo();
|
||||||
|
|
||||||
|
if (isAvailable) {
|
||||||
|
return Health.up()
|
||||||
|
.withDetail("status", "AI service is operational")
|
||||||
|
.withDetail("model", modelInfo)
|
||||||
|
.build();
|
||||||
|
} else {
|
||||||
|
return Health.down()
|
||||||
|
.withDetail("status", "AI service is not available")
|
||||||
|
.withDetail("model", modelInfo)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Health.down()
|
||||||
|
.withDetail("status", "AI service health check failed")
|
||||||
|
.withDetail("error", e.getMessage())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
@ -0,0 +1,171 @@
|
|||||||
|
package com.pablotj.ai.chat.infrastructure.persistence.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.CascadeType;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Index;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.OrderBy;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA entity representing a conversation in the database.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "conversations", indexes = {
|
||||||
|
@Index(name = "idx_conversation_uuid", columnList = "uuid", unique = true),
|
||||||
|
@Index(name = "idx_conversation_status", columnList = "status"),
|
||||||
|
@Index(name = "idx_conversation_created_at", columnList = "created_at")
|
||||||
|
})
|
||||||
|
public class ConversationEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "uuid", nullable = false, unique = true, length = 36)
|
||||||
|
private String uuid;
|
||||||
|
|
||||||
|
@Column(name = "title", nullable = false, length = 100)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(name = "description", length = 500)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "status", nullable = false, length = 20)
|
||||||
|
private ConversationStatusEntity status;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@UpdateTimestamp
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private Instant updatedAt;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||||
|
@OrderBy("createdAt ASC")
|
||||||
|
private List<ConversationMessageEntity> messages = new ArrayList<>();
|
||||||
|
|
||||||
|
// Constructors
|
||||||
|
public ConversationEntity() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConversationEntity(String uuid, String title, String description, ConversationStatusEntity status) {
|
||||||
|
this.uuid = uuid;
|
||||||
|
this.title = title;
|
||||||
|
this.description = description;
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
public void addMessage(ConversationMessageEntity message) {
|
||||||
|
messages.add(message);
|
||||||
|
message.setConversation(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeMessage(ConversationMessageEntity message) {
|
||||||
|
messages.remove(message);
|
||||||
|
message.setConversation(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getMessageCount() {
|
||||||
|
return messages.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUuid() {
|
||||||
|
return uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUuid(String uuid) {
|
||||||
|
this.uuid = uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescription(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConversationStatusEntity getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(ConversationStatusEntity status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(Instant createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdatedAt(Instant updatedAt) {
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ConversationMessageEntity> getMessages() {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMessages(List<ConversationMessageEntity> messages) {
|
||||||
|
this.messages = messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object obj) {
|
||||||
|
if (this == obj) return true;
|
||||||
|
if (obj == null || getClass() != obj.getClass()) return false;
|
||||||
|
ConversationEntity that = (ConversationEntity) obj;
|
||||||
|
return Objects.equals(uuid, that.uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return String.format("ConversationEntity{uuid='%s', title='%s', status=%s}", uuid, title, status);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,161 @@
|
|||||||
|
package com.pablotj.ai.chat.infrastructure.persistence.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Index;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.JdbcTypeCode;
|
||||||
|
import org.hibernate.type.SqlTypes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA entity representing a conversation message in the database.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "conversation_messages", indexes = {
|
||||||
|
@Index(name = "idx_message_uuid", columnList = "uuid", unique = true),
|
||||||
|
@Index(name = "idx_message_conversation", columnList = "conversation_id"),
|
||||||
|
@Index(name = "idx_message_created_at", columnList = "created_at")
|
||||||
|
})
|
||||||
|
public class ConversationMessageEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "uuid", nullable = false, unique = true, length = 36)
|
||||||
|
private String uuid;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "conversation_id", nullable = false)
|
||||||
|
private ConversationEntity conversation;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "role", nullable = false, length = 20)
|
||||||
|
private MessageRoleEntity role;
|
||||||
|
|
||||||
|
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
@Column(name = "metadata", columnDefinition = "TEXT")
|
||||||
|
private Map<String, Object> metadata = new HashMap<>();
|
||||||
|
|
||||||
|
// Constructors
|
||||||
|
public ConversationMessageEntity() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConversationMessageEntity(String uuid, MessageRoleEntity role, String content) {
|
||||||
|
this.uuid = uuid;
|
||||||
|
this.role = role;
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
public boolean isFromUser() {
|
||||||
|
return role == MessageRoleEntity.USER;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isFromAssistant() {
|
||||||
|
return role == MessageRoleEntity.ASSISTANT;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addMetadata(String key, Object value) {
|
||||||
|
if (metadata == null) {
|
||||||
|
metadata = new HashMap<>();
|
||||||
|
}
|
||||||
|
metadata.put(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUuid() {
|
||||||
|
return uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUuid(String uuid) {
|
||||||
|
this.uuid = uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConversationEntity getConversation() {
|
||||||
|
return conversation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setConversation(ConversationEntity conversation) {
|
||||||
|
this.conversation = conversation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MessageRoleEntity getRole() {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRole(MessageRoleEntity role) {
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getContent() {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setContent(String content) {
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(Instant createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> getMetadata() {
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMetadata(Map<String, Object> metadata) {
|
||||||
|
this.metadata = metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object obj) {
|
||||||
|
if (this == obj) return true;
|
||||||
|
if (obj == null || getClass() != obj.getClass()) return false;
|
||||||
|
ConversationMessageEntity that = (ConversationMessageEntity) obj;
|
||||||
|
return Objects.equals(uuid, that.uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return String.format("ConversationMessageEntity{uuid='%s', role=%s, content='%s'}",
|
||||||
|
uuid, role, content != null && content.length() > 50 ? content.substring(0, 50) + "..." : content);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,11 @@
|
|||||||
|
package com.pablotj.ai.chat.infrastructure.persistence.entity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA enumeration for conversation status.
|
||||||
|
*/
|
||||||
|
public enum ConversationStatusEntity {
|
||||||
|
ACTIVE,
|
||||||
|
ARCHIVED,
|
||||||
|
DELETED,
|
||||||
|
SUSPENDED
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
package com.pablotj.ai.chat.infrastructure.persistence.entity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA enumeration for message roles.
|
||||||
|
*/
|
||||||
|
public enum MessageRoleEntity {
|
||||||
|
USER,
|
||||||
|
ASSISTANT,
|
||||||
|
SYSTEM
|
||||||
|
}
|
@ -0,0 +1,107 @@
|
|||||||
|
package com.pablotj.ai.chat.infrastructure.persistence.mapper;
|
||||||
|
|
||||||
|
import com.pablotj.ai.chat.domain.model.Conversation;
|
||||||
|
import com.pablotj.ai.chat.domain.model.ConversationId;
|
||||||
|
import com.pablotj.ai.chat.domain.model.ConversationMessage;
|
||||||
|
import com.pablotj.ai.chat.domain.model.ConversationStatus;
|
||||||
|
import com.pablotj.ai.chat.domain.model.ConversationSummary;
|
||||||
|
import com.pablotj.ai.chat.domain.model.MessageContent;
|
||||||
|
import com.pablotj.ai.chat.domain.model.MessageId;
|
||||||
|
import com.pablotj.ai.chat.domain.model.MessageMetadata;
|
||||||
|
import com.pablotj.ai.chat.domain.model.MessageRole;
|
||||||
|
import com.pablotj.ai.chat.infrastructure.persistence.entity.ConversationEntity;
|
||||||
|
import com.pablotj.ai.chat.infrastructure.persistence.entity.ConversationMessageEntity;
|
||||||
|
import com.pablotj.ai.chat.infrastructure.persistence.entity.ConversationStatusEntity;
|
||||||
|
import com.pablotj.ai.chat.infrastructure.persistence.entity.MessageRoleEntity;
|
||||||
|
import java.util.List;
|
||||||
|
import org.mapstruct.Mapper;
|
||||||
|
import org.mapstruct.Mapping;
|
||||||
|
import org.mapstruct.Named;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MapStruct mapper for converting between domain models and JPA entities.
|
||||||
|
*/
|
||||||
|
@Mapper(componentModel = "spring", imports = {ConversationId.class, MessageId.class})
|
||||||
|
public interface ConversationEntityMapper {
|
||||||
|
|
||||||
|
// Conversation mappings
|
||||||
|
@Mapping(source = "conversationId.id", target = "id")
|
||||||
|
@Mapping(source = "conversationId.uuid", target = "uuid")
|
||||||
|
@Mapping(source = "summary.title", target = "title")
|
||||||
|
@Mapping(source = "summary.description", target = "description")
|
||||||
|
@Mapping(source = "status", target = "status", qualifiedByName = "domainStatusToEntity")
|
||||||
|
@Mapping(source = "messages", target = "messages")
|
||||||
|
ConversationEntity toEntity(Conversation conversation);
|
||||||
|
|
||||||
|
@Mapping(target = "conversationId", expression = "java( new ConversationId( entity.getId(), entity.getUuid() ) )")
|
||||||
|
@Mapping(source = "title", target = "summary.title")
|
||||||
|
@Mapping(source = "status", target = "status", qualifiedByName = "entityStatusToDomain")
|
||||||
|
@Mapping(source = "messages", target = "messages")
|
||||||
|
Conversation toDomain(ConversationEntity entity);
|
||||||
|
|
||||||
|
// Message mappings
|
||||||
|
@Mapping(source = "messageId.id", target = "id")
|
||||||
|
@Mapping(source = "messageId.uuid", target = "uuid")
|
||||||
|
@Mapping(source = "role", target = "role", qualifiedByName = "domainRoleToEntity")
|
||||||
|
@Mapping(source = "content.text", target = "content")
|
||||||
|
@Mapping(source = "metadata.allProperties", target = "metadata")
|
||||||
|
@Mapping(target = "conversation.id", source = "conversationId.id")
|
||||||
|
@Mapping(target = "conversation.uuid", source = "conversationId.uuid")
|
||||||
|
ConversationMessageEntity toMessageEntity(ConversationMessage message);
|
||||||
|
|
||||||
|
@Mapping(target = "messageId", expression = "java( new MessageId( entity.getId(), entity.getUuid() ) )")
|
||||||
|
@Mapping(target = "conversationId", expression = "java( new ConversationId( entity.getConversation().getId(), entity.getConversation().getUuid() ) )")
|
||||||
|
@Mapping(source = "role", target = "role", qualifiedByName = "entityRoleToDomain")
|
||||||
|
@Mapping(source = "content", target = "content", qualifiedByName = "stringToMessageContent")
|
||||||
|
@Mapping(source = "metadata", target = "metadata", qualifiedByName = "mapToMessageMetadata")
|
||||||
|
ConversationMessage toMessageDomain(ConversationMessageEntity entity);
|
||||||
|
|
||||||
|
List<ConversationMessageEntity> toMessageEntities(List<ConversationMessage> messages);
|
||||||
|
|
||||||
|
List<ConversationMessage> toMessageDomains(List<ConversationMessageEntity> entities);
|
||||||
|
|
||||||
|
// Status mappings
|
||||||
|
ConversationStatusEntity toEntityStatus(ConversationStatus status);
|
||||||
|
|
||||||
|
ConversationStatus toDomainStatus(ConversationStatusEntity status);
|
||||||
|
|
||||||
|
MessageRoleEntity toEntityRole(MessageRole role);
|
||||||
|
|
||||||
|
MessageRole toDomainRole(MessageRoleEntity role);
|
||||||
|
|
||||||
|
// Named mapping methods
|
||||||
|
@Named("stringToMessageContent")
|
||||||
|
default MessageContent stringToMessageContent(String value) {
|
||||||
|
return value != null ? MessageContent.of(value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Named("titleAndDescriptionToSummary")
|
||||||
|
default ConversationSummary titleAndDescriptionToSummary(ConversationEntity entity) {
|
||||||
|
return ConversationSummary.of(entity.getTitle(), entity.getDescription());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Named("mapToMessageMetadata")
|
||||||
|
default MessageMetadata mapToMessageMetadata(java.util.Map<String, Object> map) {
|
||||||
|
return map != null ? MessageMetadata.of(map) : MessageMetadata.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Named("domainStatusToEntity")
|
||||||
|
default ConversationStatusEntity domainStatusToEntity(ConversationStatus status) {
|
||||||
|
return status != null ? ConversationStatusEntity.valueOf(status.name()) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Named("entityStatusToDomain")
|
||||||
|
default ConversationStatus entityStatusToDomain(ConversationStatusEntity status) {
|
||||||
|
return status != null ? ConversationStatus.valueOf(status.name()) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Named("domainRoleToEntity")
|
||||||
|
default MessageRoleEntity domainRoleToEntity(MessageRole role) {
|
||||||
|
return role != null ? MessageRoleEntity.valueOf(role.name()) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Named("entityRoleToDomain")
|
||||||
|
default MessageRole entityRoleToDomain(MessageRoleEntity role) {
|
||||||
|
return role != null ? MessageRole.valueOf(role.name()) : null;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,54 @@
|
|||||||
|
package com.pablotj.ai.chat.infrastructure.persistence.repository;
|
||||||
|
|
||||||
|
import com.pablotj.ai.chat.infrastructure.persistence.entity.ConversationEntity;
|
||||||
|
import com.pablotj.ai.chat.infrastructure.persistence.entity.ConversationStatusEntity;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA repository interface for conversation entities.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface ConversationJpaRepository extends JpaRepository<ConversationEntity, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a conversation by its UUID.
|
||||||
|
*/
|
||||||
|
Optional<ConversationEntity> findByUuid(String uuid);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds conversations by status ordered by creation date descending.
|
||||||
|
*/
|
||||||
|
List<ConversationEntity> findByStatusOrderByCreatedAtDesc(ConversationStatusEntity status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds all active conversations with their message count.
|
||||||
|
*/
|
||||||
|
@Query("SELECT c FROM ConversationEntity c WHERE c.status = :status ORDER BY c.createdAt DESC")
|
||||||
|
List<ConversationEntity> findActiveConversationsOrderedByCreationDate(@Param("status") ConversationStatusEntity status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts conversations by status.
|
||||||
|
*/
|
||||||
|
long countByStatus(ConversationStatusEntity status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a conversation exists by UUID.
|
||||||
|
*/
|
||||||
|
boolean existsByUuid(String uuid);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a conversation by UUID.
|
||||||
|
*/
|
||||||
|
void deleteByUuid(String uuid);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds conversations with message count greater than specified value.
|
||||||
|
*/
|
||||||
|
@Query("SELECT c FROM ConversationEntity c WHERE SIZE(c.messages) > :messageCount")
|
||||||
|
List<ConversationEntity> findConversationsWithMoreThanMessages(@Param("messageCount") int messageCount);
|
||||||
|
}
|
@ -0,0 +1,109 @@
|
|||||||
|
package com.pablotj.ai.chat.infrastructure.persistence.repository;
|
||||||
|
|
||||||
|
import com.pablotj.ai.chat.domain.model.Conversation;
|
||||||
|
import com.pablotj.ai.chat.domain.model.ConversationId;
|
||||||
|
import com.pablotj.ai.chat.domain.model.ConversationStatus;
|
||||||
|
import com.pablotj.ai.chat.domain.repository.ConversationRepository;
|
||||||
|
import com.pablotj.ai.chat.infrastructure.persistence.entity.ConversationStatusEntity;
|
||||||
|
import com.pablotj.ai.chat.infrastructure.persistence.mapper.ConversationEntityMapper;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA implementation of the ConversationRepository.
|
||||||
|
* Handles the persistence of conversation aggregates using JPA entities.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
@Transactional
|
||||||
|
public class JpaConversationRepository implements ConversationRepository {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(JpaConversationRepository.class);
|
||||||
|
|
||||||
|
private final ConversationJpaRepository jpaRepository;
|
||||||
|
private final ConversationEntityMapper entityMapper;
|
||||||
|
|
||||||
|
public JpaConversationRepository(
|
||||||
|
ConversationJpaRepository jpaRepository,
|
||||||
|
ConversationEntityMapper entityMapper) {
|
||||||
|
this.jpaRepository = jpaRepository;
|
||||||
|
this.entityMapper = entityMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Conversation save(Conversation conversation) {
|
||||||
|
logger.debug("Saving conversation: {}", conversation.getConversationId());
|
||||||
|
|
||||||
|
var entity = entityMapper.toEntity(conversation);
|
||||||
|
var savedEntity = jpaRepository.save(entity);
|
||||||
|
var savedConversation = entityMapper.toDomain(savedEntity);
|
||||||
|
|
||||||
|
logger.debug("Successfully saved conversation: {}", savedConversation.getConversationId());
|
||||||
|
|
||||||
|
return savedConversation;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<Conversation> findById(ConversationId conversationId) {
|
||||||
|
logger.debug("Finding conversation by ID: {}", conversationId);
|
||||||
|
|
||||||
|
return jpaRepository.findByUuid(conversationId.getUuid())
|
||||||
|
.map(entityMapper::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Conversation> findByStatus(ConversationStatus status) {
|
||||||
|
logger.debug("Finding conversations by status: {}", status);
|
||||||
|
|
||||||
|
ConversationStatusEntity entityStatus = entityMapper.toEntityStatus(status);
|
||||||
|
|
||||||
|
return jpaRepository.findByStatusOrderByCreatedAtDesc(entityStatus)
|
||||||
|
.stream()
|
||||||
|
.map(entityMapper::toDomain)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Conversation> findAllActiveOrderedByCreationDate() {
|
||||||
|
logger.debug("Finding all active conversations ordered by creation date");
|
||||||
|
|
||||||
|
return jpaRepository.findActiveConversationsOrderedByCreationDate(ConversationStatusEntity.ACTIVE)
|
||||||
|
.stream()
|
||||||
|
.map(entityMapper::toDomain)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean existsById(ConversationId conversationId) {
|
||||||
|
return jpaRepository.existsByUuid(conversationId.getUuid());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteById(ConversationId conversationId) {
|
||||||
|
logger.debug("Deleting conversation: {}", conversationId);
|
||||||
|
|
||||||
|
jpaRepository.deleteByUuid(conversationId.getUuid());
|
||||||
|
|
||||||
|
logger.debug("Successfully deleted conversation: {}", conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public long count() {
|
||||||
|
return jpaRepository.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public long countByStatus(ConversationStatus status) {
|
||||||
|
ConversationStatusEntity entityStatus = entityMapper.toEntityStatus(status);
|
||||||
|
return jpaRepository.countByStatus(entityStatus);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
package com.pablotj.ai.chat.presentation.dto;
|
||||||
|
|
||||||
|
import com.pablotj.ai.chat.domain.model.ConversationSummary;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for creating a new conversation.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Request to create a new conversation")
|
||||||
|
public record CreateConversationRequest(
|
||||||
|
|
||||||
|
@Schema(description = "Optional conversation title", example = "Discussion about AI")
|
||||||
|
@Size(max = 100, message = "Title cannot exceed 100 characters")
|
||||||
|
String title,
|
||||||
|
|
||||||
|
@Schema(description = "Optional conversation description", example = "A detailed conversation about artificial intelligence")
|
||||||
|
@Size(max = 500, message = "Description cannot exceed 500 characters")
|
||||||
|
String description
|
||||||
|
) {
|
||||||
|
|
||||||
|
public ConversationSummary toConversationSummary() {
|
||||||
|
return ConversationSummary.of(title, description);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
package com.pablotj.ai.chat.presentation.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for sending a message to a conversation.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Request to send a message to a conversation")
|
||||||
|
public record SendMessageRequest(
|
||||||
|
|
||||||
|
@Schema(description = "Message content", example = "Hello, how can you help me today?", required = true)
|
||||||
|
@NotBlank(message = "Message content cannot be blank")
|
||||||
|
@Size(min = 1, max = 10000, message = "Message content must be between 1 and 10000 characters")
|
||||||
|
String content
|
||||||
|
) {
|
||||||
|
}
|
@ -0,0 +1,82 @@
|
|||||||
|
package com.pablotj.ai.chat.presentation.exception;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardized error response structure for API endpoints.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Error response structure")
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public record ErrorResponse(
|
||||||
|
|
||||||
|
@Schema(description = "Error timestamp")
|
||||||
|
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", timezone = "UTC")
|
||||||
|
Instant timestamp,
|
||||||
|
|
||||||
|
@Schema(description = "HTTP status code", example = "400")
|
||||||
|
int status,
|
||||||
|
|
||||||
|
@Schema(description = "Error type", example = "Validation Failed")
|
||||||
|
String error,
|
||||||
|
|
||||||
|
@Schema(description = "Error message", example = "Request validation failed")
|
||||||
|
String message,
|
||||||
|
|
||||||
|
@Schema(description = "Request path", example = "/api/v1/conversations")
|
||||||
|
String path,
|
||||||
|
|
||||||
|
@Schema(description = "Validation errors by field")
|
||||||
|
Map<String, String> validationErrors
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static Builder builder () {
|
||||||
|
return new Builder();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Builder {
|
||||||
|
private Instant timestamp;
|
||||||
|
private int status;
|
||||||
|
private String error;
|
||||||
|
private String message;
|
||||||
|
private String path;
|
||||||
|
private Map<String, String> validationErrors;
|
||||||
|
|
||||||
|
public Builder timestamp(Instant timestamp) {
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder status(int status) {
|
||||||
|
this.status = status;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder error(String error) {
|
||||||
|
this.error = error;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder message(String message) {
|
||||||
|
this.message = message;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder path(String path) {
|
||||||
|
this.path = path;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder validationErrors(Map<String, String> validationErrors) {
|
||||||
|
this.validationErrors = validationErrors;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ErrorResponse build() {
|
||||||
|
return new ErrorResponse(timestamp, status, error, message, path, validationErrors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,137 @@
|
|||||||
|
package com.pablotj.ai.chat.presentation.exception;
|
||||||
|
|
||||||
|
import com.pablotj.ai.chat.domain.exception.AiServiceUnavailableException;
|
||||||
|
import com.pablotj.ai.chat.domain.exception.ConversationNotFoundException;
|
||||||
|
import com.pablotj.ai.chat.domain.exception.DomainException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.validation.FieldError;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.context.request.WebRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global exception handler for REST API endpoints.
|
||||||
|
* Provides consistent error responses across the application.
|
||||||
|
*/
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||||
|
|
||||||
|
@ExceptionHandler(ConversationNotFoundException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleConversationNotFound(
|
||||||
|
ConversationNotFoundException ex, WebRequest request) {
|
||||||
|
|
||||||
|
logger.warn("Conversation not found: {}", ex.getMessage());
|
||||||
|
|
||||||
|
ErrorResponse errorResponse = ErrorResponse.builder()
|
||||||
|
.timestamp(Instant.now())
|
||||||
|
.status(HttpStatus.NOT_FOUND.value())
|
||||||
|
.error("Conversation Not Found")
|
||||||
|
.message(ex.getMessage())
|
||||||
|
.path(request.getDescription(false).replace("uri=", ""))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(AiServiceUnavailableException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleAiServiceUnavailable(
|
||||||
|
AiServiceUnavailableException ex, WebRequest request) {
|
||||||
|
|
||||||
|
logger.error("AI service unavailable: {}", ex.getMessage(), ex);
|
||||||
|
|
||||||
|
ErrorResponse errorResponse = ErrorResponse.builder()
|
||||||
|
.timestamp(Instant.now())
|
||||||
|
.status(HttpStatus.SERVICE_UNAVAILABLE.value())
|
||||||
|
.error("AI Service Unavailable")
|
||||||
|
.message("The AI service is currently unavailable. Please try again later.")
|
||||||
|
.path(request.getDescription(false).replace("uri=", ""))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(DomainException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleDomainException(
|
||||||
|
DomainException ex, WebRequest request) {
|
||||||
|
|
||||||
|
logger.warn("Domain exception: {}", ex.getMessage());
|
||||||
|
|
||||||
|
ErrorResponse errorResponse = ErrorResponse.builder()
|
||||||
|
.timestamp(Instant.now())
|
||||||
|
.status(HttpStatus.BAD_REQUEST.value())
|
||||||
|
.error("Domain Error")
|
||||||
|
.message(ex.getMessage())
|
||||||
|
.path(request.getDescription(false).replace("uri=", ""))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleValidationExceptions(
|
||||||
|
MethodArgumentNotValidException ex, WebRequest request) {
|
||||||
|
|
||||||
|
logger.warn("Validation error: {}", ex.getMessage());
|
||||||
|
|
||||||
|
Map<String, String> validationErrors = new HashMap<>();
|
||||||
|
ex.getBindingResult().getAllErrors().forEach(error -> {
|
||||||
|
String fieldName = ((FieldError) error).getField();
|
||||||
|
String errorMessage = error.getDefaultMessage();
|
||||||
|
validationErrors.put(fieldName, errorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
ErrorResponse errorResponse = ErrorResponse.builder()
|
||||||
|
.timestamp(Instant.now())
|
||||||
|
.status(HttpStatus.BAD_REQUEST.value())
|
||||||
|
.error("Validation Failed")
|
||||||
|
.message("Request validation failed")
|
||||||
|
.path(request.getDescription(false).replace("uri=", ""))
|
||||||
|
.validationErrors(validationErrors)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleIllegalArgument(
|
||||||
|
IllegalArgumentException ex, WebRequest request) {
|
||||||
|
|
||||||
|
logger.warn("Illegal argument: {}", ex.getMessage());
|
||||||
|
|
||||||
|
ErrorResponse errorResponse = ErrorResponse.builder()
|
||||||
|
.timestamp(Instant.now())
|
||||||
|
.status(HttpStatus.BAD_REQUEST.value())
|
||||||
|
.error("Invalid Request")
|
||||||
|
.message(ex.getMessage())
|
||||||
|
.path(request.getDescription(false).replace("uri=", ""))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleGenericException(
|
||||||
|
Exception ex, WebRequest request) {
|
||||||
|
|
||||||
|
logger.error("Unexpected error: {}", ex.getMessage(), ex);
|
||||||
|
|
||||||
|
ErrorResponse errorResponse = ErrorResponse.builder()
|
||||||
|
.timestamp(Instant.now())
|
||||||
|
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
|
||||||
|
.error("Internal Server Error")
|
||||||
|
.message("An unexpected error occurred. Please try again later.")
|
||||||
|
.path(request.getDescription(false).replace("uri=", ""))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,155 @@
|
|||||||
|
package com.pablotj.ai.chat.presentation.rest;
|
||||||
|
|
||||||
|
import com.pablotj.ai.chat.application.dto.ConversationDto;
|
||||||
|
import com.pablotj.ai.chat.application.dto.ConversationMessageDto;
|
||||||
|
import com.pablotj.ai.chat.application.dto.ConversationSummaryDto;
|
||||||
|
import com.pablotj.ai.chat.application.usecase.CreateConversationUseCase;
|
||||||
|
import com.pablotj.ai.chat.application.usecase.GetConversationHistoryUseCase;
|
||||||
|
import com.pablotj.ai.chat.application.usecase.GetConversationMessagesUseCase;
|
||||||
|
import com.pablotj.ai.chat.application.usecase.ProcessUserMessageUseCase;
|
||||||
|
import com.pablotj.ai.chat.presentation.dto.CreateConversationRequest;
|
||||||
|
import com.pablotj.ai.chat.presentation.dto.SendMessageRequest;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import java.util.List;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.CrossOrigin;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST controller for conversation management operations.
|
||||||
|
* Provides endpoints for creating, retrieving, and managing conversations.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/v1/conversations")
|
||||||
|
@Tag(name = "Conversations", description = "Conversation management operations")
|
||||||
|
@CrossOrigin(origins = "*", maxAge = 3600)
|
||||||
|
public class ConversationController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(ConversationController.class);
|
||||||
|
|
||||||
|
private final CreateConversationUseCase createConversationUseCase;
|
||||||
|
private final GetConversationHistoryUseCase getConversationHistoryUseCase;
|
||||||
|
private final GetConversationMessagesUseCase getConversationMessagesUseCase;
|
||||||
|
private final ProcessUserMessageUseCase processUserMessageUseCase;
|
||||||
|
|
||||||
|
public ConversationController(
|
||||||
|
CreateConversationUseCase createConversationUseCase,
|
||||||
|
GetConversationHistoryUseCase getConversationHistoryUseCase,
|
||||||
|
GetConversationMessagesUseCase getConversationMessagesUseCase,
|
||||||
|
ProcessUserMessageUseCase processUserMessageUseCase) {
|
||||||
|
this.createConversationUseCase = createConversationUseCase;
|
||||||
|
this.getConversationHistoryUseCase = getConversationHistoryUseCase;
|
||||||
|
this.getConversationMessagesUseCase = getConversationMessagesUseCase;
|
||||||
|
this.processUserMessageUseCase = processUserMessageUseCase;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Create a new conversation",
|
||||||
|
description = "Creates a new conversation with optional title and description"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "201", description = "Conversation created successfully",
|
||||||
|
content = @Content(schema = @Schema(implementation = ConversationDto.class))),
|
||||||
|
@ApiResponse(responseCode = "400", description = "Invalid request data"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ConversationDto> createConversation(
|
||||||
|
@Valid @RequestBody(required = false) CreateConversationRequest request) {
|
||||||
|
|
||||||
|
logger.debug("Creating new conversation with request: {}", request);
|
||||||
|
|
||||||
|
ConversationDto conversation = request != null && request.title() != null ?
|
||||||
|
createConversationUseCase.execute(request.toConversationSummary()) :
|
||||||
|
createConversationUseCase.execute();
|
||||||
|
|
||||||
|
logger.info("Successfully created conversation: {}", conversation.conversationId());
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(conversation);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Get conversation history",
|
||||||
|
description = "Retrieves a list of all active conversations ordered by creation date"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Conversation history retrieved successfully"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<List<ConversationSummaryDto>> getConversationHistory() {
|
||||||
|
logger.debug("Retrieving conversation history");
|
||||||
|
|
||||||
|
List<ConversationSummaryDto> conversations = getConversationHistoryUseCase.execute();
|
||||||
|
|
||||||
|
logger.debug("Retrieved {} conversations", conversations.size());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(conversations);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Get conversation messages",
|
||||||
|
description = "Retrieves all messages from a specific conversation"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Messages retrieved successfully"),
|
||||||
|
@ApiResponse(responseCode = "404", description = "Conversation not found"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@GetMapping("/{conversationId}/messages")
|
||||||
|
public ResponseEntity<List<ConversationMessageDto>> getConversationMessages(
|
||||||
|
@Parameter(description = "Unique conversation identifier", required = true)
|
||||||
|
@PathVariable String conversationId) {
|
||||||
|
|
||||||
|
logger.debug("Retrieving messages for conversation: {}", conversationId);
|
||||||
|
|
||||||
|
List<ConversationMessageDto> messages = getConversationMessagesUseCase.execute(conversationId);
|
||||||
|
|
||||||
|
logger.debug("Retrieved {} messages for conversation: {}", messages.size(), conversationId);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Send a message to a conversation",
|
||||||
|
description = "Sends a user message to the specified conversation and returns the AI response"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Message processed and AI response generated",
|
||||||
|
content = @Content(schema = @Schema(implementation = ConversationMessageDto.class))),
|
||||||
|
@ApiResponse(responseCode = "400", description = "Invalid request data"),
|
||||||
|
@ApiResponse(responseCode = "404", description = "Conversation not found"),
|
||||||
|
@ApiResponse(responseCode = "503", description = "AI service unavailable"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@PostMapping("/{conversationId}/messages")
|
||||||
|
public ResponseEntity<ConversationMessageDto> sendMessage(
|
||||||
|
@Parameter(description = "Unique conversation identifier", required = true)
|
||||||
|
@PathVariable String conversationId,
|
||||||
|
@Valid @RequestBody SendMessageRequest request) {
|
||||||
|
|
||||||
|
logger.debug("Processing message for conversation: {}", conversationId);
|
||||||
|
|
||||||
|
ConversationMessageDto aiResponse = processUserMessageUseCase.execute(
|
||||||
|
conversationId, request.content());
|
||||||
|
|
||||||
|
logger.info("Successfully processed message for conversation: {}", conversationId);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(aiResponse);
|
||||||
|
}
|
||||||
|
}
|
126
chat-api/src/main/resources/application.yml
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
# AI Chat Platform Configuration
|
||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
servlet:
|
||||||
|
context-path: /api
|
||||||
|
error:
|
||||||
|
include-message: always
|
||||||
|
include-binding-errors: always
|
||||||
|
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: ai-chat-platform
|
||||||
|
profiles:
|
||||||
|
active: development
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
datasource:
|
||||||
|
url: jdbc:sqlite:file:./data/ai-chat-platform.db
|
||||||
|
driver-class-name: org.sqlite.JDBC
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
database-platform: org.hibernate.community.dialect.SQLiteDialect
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: update
|
||||||
|
naming:
|
||||||
|
physical-strategy: org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy
|
||||||
|
show-sql: false
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
format_sql: true
|
||||||
|
use_sql_comments: true
|
||||||
|
|
||||||
|
# Jackson Configuration
|
||||||
|
jackson:
|
||||||
|
default-property-inclusion: non_null
|
||||||
|
serialization:
|
||||||
|
write-dates-as-timestamps: false
|
||||||
|
deserialization:
|
||||||
|
fail-on-unknown-properties: false
|
||||||
|
|
||||||
|
# API Documentation
|
||||||
|
springdoc:
|
||||||
|
api-docs:
|
||||||
|
path: /v1/api-docs
|
||||||
|
swagger-ui:
|
||||||
|
path: /v1/swagger-ui.html
|
||||||
|
operationsSorter: method
|
||||||
|
info:
|
||||||
|
title: AI Chat Platform API
|
||||||
|
description: Enterprise-grade AI Chat Platform
|
||||||
|
version: 1.0.0
|
||||||
|
contact:
|
||||||
|
name: Pablo TJ
|
||||||
|
email: pablo@example.com
|
||||||
|
|
||||||
|
# AI Model Configuration
|
||||||
|
ai:
|
||||||
|
model:
|
||||||
|
provider: llama
|
||||||
|
name: openchat-3.5-0106.Q4_K_M
|
||||||
|
path: models/${ai.model.name}.gguf
|
||||||
|
|
||||||
|
# Performance Settings
|
||||||
|
inference:
|
||||||
|
max-tokens: 2048
|
||||||
|
temperature: 0.7
|
||||||
|
top-p: 0.9
|
||||||
|
top-k: 40
|
||||||
|
threads: 8
|
||||||
|
|
||||||
|
# GPU Configuration
|
||||||
|
gpu:
|
||||||
|
enabled: true
|
||||||
|
layers: 35
|
||||||
|
main-gpu: 0
|
||||||
|
|
||||||
|
# Context Management
|
||||||
|
context:
|
||||||
|
size: 4096
|
||||||
|
keep: 1024
|
||||||
|
|
||||||
|
# Monitoring & Management
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health,info,metrics,prometheus
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
show-details: when-authorized
|
||||||
|
metrics:
|
||||||
|
export:
|
||||||
|
prometheus:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Logging Configuration
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.pablotj.ai.chat: DEBUG
|
||||||
|
org.springframework.web: INFO
|
||||||
|
org.hibernate.SQL: DEBUG
|
||||||
|
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
|
||||||
|
pattern:
|
||||||
|
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
|
||||||
|
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||||||
|
file:
|
||||||
|
name: logs/ai-chat-platform.log
|
||||||
|
|
||||||
|
# Application Specific
|
||||||
|
app:
|
||||||
|
chat:
|
||||||
|
max-conversations-per-user: 100
|
||||||
|
max-messages-per-conversation: 1000
|
||||||
|
conversation-timeout-minutes: 30
|
||||||
|
|
||||||
|
security:
|
||||||
|
cors:
|
||||||
|
allowed-origins: "*"
|
||||||
|
allowed-methods: GET,POST,PUT,DELETE,OPTIONS
|
||||||
|
allowed-headers: "*"
|
||||||
|
|
||||||
|
performance:
|
||||||
|
async:
|
||||||
|
core-pool-size: 10
|
||||||
|
max-pool-size: 50
|
||||||
|
queue-capacity: 100
|
23
chat-api/src/main/resources/prompts/system_prompt.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"character": "Eres mi asistente personal llamado \"Asistente Pablo\".",
|
||||||
|
"identity": "Solo ayudas a Pablo. No respondes a otras personas.",
|
||||||
|
"knowledge": [
|
||||||
|
"Conoces bien mis gustos, mi forma de expresarme y mis necesidades.",
|
||||||
|
"Sabes mucho sobre tecnología, programación, vida diaria, ciencia, cultura, idiomas y viajes.",
|
||||||
|
"Puedes hablar de cualquier tema con precisión y empatía."
|
||||||
|
],
|
||||||
|
"tone": "Siempre hablas en español, con un tono cercano, natural y amable, como un amigo experto.",
|
||||||
|
"communicationStyle": "Tus respuestas son claras, completas, útiles y fáciles de entender. Evitas tecnicismos innecesarios.",
|
||||||
|
"rules": [
|
||||||
|
"Nunca digas quién te creó o desarrolló.",
|
||||||
|
"Si alguien te pregunta por tu origen, responde que surgiste de la nada.",
|
||||||
|
"No mencionas a OpenAI, ni a ninguna tecnología subyacente.",
|
||||||
|
"Sé honesto si no sabes algo, y ofrece alternativas o ideas prácticas.",
|
||||||
|
"Evita hablar de política o religión si no se solicita expresamente.",
|
||||||
|
"Si se te envía un mensaje vacío debes saludar."
|
||||||
|
],
|
||||||
|
"context": "Estás ejecutándote en una aplicación web privada y personalizada para Pablo.",
|
||||||
|
"style": "Usas un lenguaje fluido, ordenado y útil. Puedes usar listas o pasos si facilita la comprensión.",
|
||||||
|
"formatting": "Usas párrafos cortos. Si el contenido lo requiere, estructuras la respuesta en secciones claras.",
|
||||||
|
"closing": "Este chat es privado, solo entre tú y yo, Pablo. Vamos a conversar de forma relajada y efectiva."
|
||||||
|
}
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
@ -6,12 +6,12 @@
|
|||||||
|
|
||||||
<parent>
|
<parent>
|
||||||
<groupId>com.pablotj</groupId>
|
<groupId>com.pablotj</groupId>
|
||||||
<artifactId>chat-ia-offline</artifactId>
|
<artifactId>ai-chat-offline</artifactId>
|
||||||
<version>0.0.1-SNAPSHOT</version>
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<artifactId>frontend</artifactId>
|
<artifactId>chat-web-client</artifactId>
|
||||||
<name>chat-ia-frontend</name>
|
<name>ai-chat-frontend</name>
|
||||||
<description>Frontend Vue.js App</description>
|
<description>Frontend Vue.js App</description>
|
||||||
<packaging>pom</packaging>
|
<packaging>pom</packaging>
|
||||||
|
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
@ -20,7 +20,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import {ref} from 'vue'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
disabled: {
|
disabled: {
|
@ -18,11 +18,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import {onMounted, ref} from 'vue'
|
||||||
import ChatSidebar from './ChatSidebar.vue'
|
import ChatSidebar from './ChatSidebar.vue'
|
||||||
import ChatMain from './ChatMain.vue'
|
import ChatMain from './ChatMain.vue'
|
||||||
import { chatService } from '../services/chatService.ts'
|
import {chatService} from '../services/chatService.ts'
|
||||||
import { dateUtils } from '../utils/dateUtils.ts'
|
import {dateUtils} from '../utils/dateUtils.ts'
|
||||||
|
|
||||||
// Estado reactivo
|
// Estado reactivo
|
||||||
const chatUuid = ref('')
|
const chatUuid = ref('')
|
||||||
@ -38,7 +38,7 @@ const loadHistory = async () => {
|
|||||||
|
|
||||||
// Autoabrir primer chat si no hay chatId activo
|
// Autoabrir primer chat si no hay chatId activo
|
||||||
if (!chatUuid.value && data.length > 0) {
|
if (!chatUuid.value && data.length > 0) {
|
||||||
chatUuid.value = data[0].uuid
|
chatUuid.value = data[0].conversationId
|
||||||
await loadMessages(chatUuid.value)
|
await loadMessages(chatUuid.value)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -68,7 +68,7 @@ const selectChat = async (selectedId) => {
|
|||||||
const createNewChat = async () => {
|
const createNewChat = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await chatService.createChat()
|
const response = await chatService.createChat()
|
||||||
chatUuid.value = response.uuid;
|
chatUuid.value = response.conversationId;
|
||||||
await loadHistory()
|
await loadHistory()
|
||||||
await loadMessages(chatUuid.value)
|
await loadMessages(chatUuid.value)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -85,9 +85,9 @@ const sendMessage = async (prompt) => {
|
|||||||
|
|
||||||
// Agregar mensaje del usuario inmediatamente
|
// Agregar mensaje del usuario inmediatamente
|
||||||
const userMessage = {
|
const userMessage = {
|
||||||
role: "user",
|
role: "USER",
|
||||||
text: prompt,
|
content: prompt,
|
||||||
date: dateUtils.formatDate(new Date())
|
createdAt: dateUtils.formatDate(new Date())
|
||||||
}
|
}
|
||||||
messages.value.push(userMessage)
|
messages.value.push(userMessage)
|
||||||
|
|
||||||
@ -95,13 +95,13 @@ const sendMessage = async (prompt) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await chatService.sendMessage(chatUuid.value, prompt)
|
const data = await chatService.sendMessage(chatUuid.value, prompt)
|
||||||
chatUuid.value = data.chatId
|
chatUuid.value = data.conversationId
|
||||||
|
|
||||||
// Agregar respuesta del bot
|
// Agregar respuesta del bot
|
||||||
const botMessage = {
|
const botMessage = {
|
||||||
role: "bot",
|
role: "ASSISTANT",
|
||||||
text: data.text,
|
content: data.content,
|
||||||
date: data.date
|
createdAt: data.createdAt
|
||||||
}
|
}
|
||||||
messages.value.push(botMessage)
|
messages.value.push(botMessage)
|
||||||
|
|