From bdaa8d2463378acd606bfdb49190351f87f4820f Mon Sep 17 00:00:00 2001 From: Pablo de la Torre Jamardo Date: Sun, 20 Jul 2025 17:29:34 +0200 Subject: [PATCH] Apply hexagonal architecture and clean code principles --- backend/pom.xml | 73 ------- .../ia/chat/boot/IAChatBootApplication.java | 13 -- .../controller/ChatRestController.java | 67 ------ .../application/prompt/PromptBuilder.java | 39 ---- .../application/prompt/PromptDefinition.java | 69 ------ .../application/prompt/PromptTemplates.java | 53 ----- .../session/ChatSessionManager.java | 42 ---- .../usecase/ChatHistoryUseCase.java | 20 -- .../boot/application/usecase/ChatUseCase.java | 56 ----- .../exception/BusinessLogicException.java | 12 -- .../chat/boot/domain/model/ChatIdentity.java | 6 - .../chat/boot/domain/model/ChatMessage.java | 14 -- .../boot/domain/port/ChatMessageStore.java | 13 -- .../chat/boot/domain/service/ChatService.java | 6 - .../infraestructure/llm/LlmModelClient.java | 30 --- .../infraestructure/llm/LlmModelLoader.java | 51 ----- .../ia/chat/boot/persistence/ChatEntity.java | 74 ------- .../boot/persistence/ChatJpaRepository.java | 13 -- .../ia/chat/boot/persistence/ChatMapper.java | 15 -- .../boot/persistence/ChatMessageEntity.java | 45 ---- .../persistence/ChatMessageJpaRepository.java | 11 - .../boot/persistence/ChatMessageMapper.java | 23 -- .../persistence/SqliteChatMessageStore.java | 78 ------- backend/src/main/resources/application.yml | 21 -- chat-api/pom.xml | 144 +++++++++++++ .../ai/chat/AiChatPlatformApplication.java | 27 +++ .../chat/application/dto/ConversationDto.java | 57 +++++ .../dto/ConversationMessageDto.java | 58 ++++++ .../dto/ConversationSummaryDto.java | 42 ++++ .../mapper/ConversationMapper.java | 49 +++++ .../usecase/CreateConversationUseCase.java | 58 ++++++ .../GetConversationHistoryUseCase.java | 50 +++++ .../GetConversationMessagesUseCase.java | 59 ++++++ .../usecase/ProcessUserMessageUseCase.java | 93 +++++++++ .../AiServiceUnavailableException.java | 15 ++ .../ConversationNotFoundException.java | 20 ++ .../domain/exception/DomainException.java | 15 ++ .../ai/chat/domain/model/Conversation.java | 197 ++++++++++++++++++ .../ai/chat/domain/model/ConversationId.java | 69 ++++++ .../domain/model/ConversationMessage.java | 165 +++++++++++++++ .../chat/domain/model/ConversationStatus.java | 53 +++++ .../domain/model/ConversationSummary.java | 140 +++++++++++++ .../ai/chat/domain/model/MessageContent.java | 79 +++++++ .../ai/chat/domain/model/MessageId.java | 63 ++++++ .../ai/chat/domain/model/MessageMetadata.java | 94 +++++++++ .../ai/chat/domain/model/MessageRole.java | 48 +++++ .../repository/ConversationRepository.java | 75 +++++++ .../domain/service/AiConversationService.java | 35 ++++ .../ai/LlamaAiConversationService.java | 132 ++++++++++++ .../infrastructure/ai/LlamaModelManager.java | 128 ++++++++++++ .../ai/prompt/ConversationPromptBuilder.java | 105 ++++++++++ .../ApplicationConfiguration.java | 38 ++++ .../configuration/MetricsConfiguration.java | 24 +++ .../configuration/OpenApiConfiguration.java | 44 ++++ .../health/AiServiceHealthIndicator.java | 46 ++++ .../entity/ConversationEntity.java | 171 +++++++++++++++ .../entity/ConversationMessageEntity.java | 161 ++++++++++++++ .../entity/ConversationStatusEntity.java | 11 + .../persistence/entity/MessageRoleEntity.java | 10 + .../mapper/ConversationEntityMapper.java | 107 ++++++++++ .../repository/ConversationJpaRepository.java | 54 +++++ .../repository/JpaConversationRepository.java | 109 ++++++++++ .../dto/CreateConversationRequest.java | 25 +++ .../presentation/dto/SendMessageRequest.java | 18 ++ .../presentation/exception/ErrorResponse.java | 82 ++++++++ .../exception/GlobalExceptionHandler.java | 137 ++++++++++++ .../rest/ConversationController.java | 155 ++++++++++++++ chat-api/src/main/resources/application.yml | 126 +++++++++++ .../resources/prompts/default_prompt.json | 2 +- .../main/resources/prompts/system_prompt.json | 23 ++ .../chat/boot/IAChatBootApplicationTests.java | 0 {frontend => chat-web-client}/.gitignore | 0 .../dist/assets/index-DIsRwQnG.css | 0 .../dist/assets/index-lEa7rgri.js | 0 .../dist/favicon/apple-touch-icon.png | Bin .../dist/favicon/favicon-96x96.png | Bin .../dist/favicon/favicon.ico | Bin .../dist/favicon/favicon.svg | 0 .../dist/favicon/web-app-manifest-192x192.png | Bin .../dist/favicon/web-app-manifest-512x512.png | Bin {frontend => chat-web-client}/dist/index.html | 0 {frontend => chat-web-client}/dist/robots.txt | 0 .../dist/site.webmanifest | 0 {frontend => chat-web-client}/index.html | 0 .../package-lock.json | 0 {frontend => chat-web-client}/package.json | 0 {frontend => chat-web-client}/pom.xml | 8 +- .../public/favicon/apple-touch-icon.png | Bin .../public/favicon/favicon-96x96.png | Bin .../public/favicon/favicon.ico | Bin .../public/favicon/favicon.svg | 0 .../favicon/web-app-manifest-192x192.png | Bin .../favicon/web-app-manifest-512x512.png | Bin .../public/robots.txt | 0 .../public/site.webmanifest | 0 {frontend => chat-web-client}/src/App.vue | 0 .../src/assets/styles.css | 0 .../src/components/ChatForm.vue | 2 +- .../src/components/ChatLayout.vue | 24 +-- .../src/components/ChatMain.vue | 0 .../src/components/ChatMessages.vue | 12 +- .../src/components/ChatSidebar.vue | 8 +- .../src/components/Spinner.vue | 0 {frontend => chat-web-client}/src/main.ts | 2 +- .../src/services/chatService.ts | 16 +- .../src/utils/dateUtils.ts | 0 {frontend => chat-web-client}/vite.config.js | 2 +- pom.xml | 12 +- 108 files changed, 3455 insertions(+), 888 deletions(-) delete mode 100644 backend/pom.xml delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/IAChatBootApplication.java delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/adapter/controller/ChatRestController.java delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/application/prompt/PromptBuilder.java delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/application/prompt/PromptDefinition.java delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/application/prompt/PromptTemplates.java delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/application/session/ChatSessionManager.java delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/application/usecase/ChatHistoryUseCase.java delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/application/usecase/ChatUseCase.java delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/domain/exception/BusinessLogicException.java delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/domain/model/ChatIdentity.java delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/domain/model/ChatMessage.java delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/domain/port/ChatMessageStore.java delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/domain/service/ChatService.java delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/infraestructure/llm/LlmModelClient.java delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/infraestructure/llm/LlmModelLoader.java delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatEntity.java delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatJpaRepository.java delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatMapper.java delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatMessageEntity.java delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatMessageJpaRepository.java delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatMessageMapper.java delete mode 100644 backend/src/main/java/com/pablotj/ia/chat/boot/persistence/SqliteChatMessageStore.java delete mode 100644 backend/src/main/resources/application.yml create mode 100644 chat-api/pom.xml create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/AiChatPlatformApplication.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/application/dto/ConversationDto.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/application/dto/ConversationMessageDto.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/application/dto/ConversationSummaryDto.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/application/mapper/ConversationMapper.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/CreateConversationUseCase.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/GetConversationHistoryUseCase.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/GetConversationMessagesUseCase.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/ProcessUserMessageUseCase.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/domain/exception/AiServiceUnavailableException.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/domain/exception/ConversationNotFoundException.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/domain/exception/DomainException.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/domain/model/Conversation.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationId.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationMessage.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationStatus.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationSummary.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageContent.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageId.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageMetadata.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageRole.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/domain/repository/ConversationRepository.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/domain/service/AiConversationService.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/ai/LlamaAiConversationService.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/ai/LlamaModelManager.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/ai/prompt/ConversationPromptBuilder.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/configuration/ApplicationConfiguration.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/configuration/MetricsConfiguration.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/configuration/OpenApiConfiguration.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/health/AiServiceHealthIndicator.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/entity/ConversationEntity.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/entity/ConversationMessageEntity.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/entity/ConversationStatusEntity.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/entity/MessageRoleEntity.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/mapper/ConversationEntityMapper.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/repository/ConversationJpaRepository.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/repository/JpaConversationRepository.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/presentation/dto/CreateConversationRequest.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/presentation/dto/SendMessageRequest.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/presentation/exception/ErrorResponse.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/presentation/exception/GlobalExceptionHandler.java create mode 100644 chat-api/src/main/java/com/pablotj/ai/chat/presentation/rest/ConversationController.java create mode 100644 chat-api/src/main/resources/application.yml rename {backend => chat-api}/src/main/resources/prompts/default_prompt.json (99%) create mode 100644 chat-api/src/main/resources/prompts/system_prompt.json rename {backend => chat-api}/src/test/java/com/pablotj/ia/chat/boot/IAChatBootApplicationTests.java (100%) rename {frontend => chat-web-client}/.gitignore (100%) rename {frontend => chat-web-client}/dist/assets/index-DIsRwQnG.css (100%) rename {frontend => chat-web-client}/dist/assets/index-lEa7rgri.js (100%) rename {frontend => chat-web-client}/dist/favicon/apple-touch-icon.png (100%) rename {frontend => chat-web-client}/dist/favicon/favicon-96x96.png (100%) rename {frontend => chat-web-client}/dist/favicon/favicon.ico (100%) rename {frontend => chat-web-client}/dist/favicon/favicon.svg (100%) rename {frontend => chat-web-client}/dist/favicon/web-app-manifest-192x192.png (100%) rename {frontend => chat-web-client}/dist/favicon/web-app-manifest-512x512.png (100%) rename {frontend => chat-web-client}/dist/index.html (100%) rename {frontend => chat-web-client}/dist/robots.txt (100%) rename {frontend => chat-web-client}/dist/site.webmanifest (100%) rename {frontend => chat-web-client}/index.html (100%) rename {frontend => chat-web-client}/package-lock.json (100%) rename {frontend => chat-web-client}/package.json (100%) rename {frontend => chat-web-client}/pom.xml (72%) rename {frontend => chat-web-client}/public/favicon/apple-touch-icon.png (100%) rename {frontend => chat-web-client}/public/favicon/favicon-96x96.png (100%) rename {frontend => chat-web-client}/public/favicon/favicon.ico (100%) rename {frontend => chat-web-client}/public/favicon/favicon.svg (100%) rename {frontend => chat-web-client}/public/favicon/web-app-manifest-192x192.png (100%) rename {frontend => chat-web-client}/public/favicon/web-app-manifest-512x512.png (100%) rename {frontend => chat-web-client}/public/robots.txt (100%) rename {frontend => chat-web-client}/public/site.webmanifest (100%) rename {frontend => chat-web-client}/src/App.vue (100%) rename {frontend => chat-web-client}/src/assets/styles.css (100%) rename {frontend => chat-web-client}/src/components/ChatForm.vue (98%) rename {frontend => chat-web-client}/src/components/ChatLayout.vue (85%) rename {frontend => chat-web-client}/src/components/ChatMain.vue (100%) rename {frontend => chat-web-client}/src/components/ChatMessages.vue (92%) rename {frontend => chat-web-client}/src/components/ChatSidebar.vue (88%) rename {frontend => chat-web-client}/src/components/Spinner.vue (100%) rename {frontend => chat-web-client}/src/main.ts (87%) rename {frontend => chat-web-client}/src/services/chatService.ts (76%) rename {frontend => chat-web-client}/src/utils/dateUtils.ts (100%) rename {frontend => chat-web-client}/vite.config.js (86%) diff --git a/backend/pom.xml b/backend/pom.xml deleted file mode 100644 index 0b124c1..0000000 --- a/backend/pom.xml +++ /dev/null @@ -1,73 +0,0 @@ - - 4.0.0 - - - com.pablotj - chat-ia-offline - 0.0.1-SNAPSHOT - - - backend - chat-ia-frontend - Backend Spring Boot - jar - - - - - - org.springframework.boot - spring-boot-starter-web - - - - - org.springdoc - springdoc-openapi-starter-webmvc-ui - 2.8.9 - - - - - de.kherud - llama - 4.2.0 - - - - - org.xerial - sqlite-jdbc - 3.45.1.0 - - - - org.hibernate.orm - hibernate-community-dialects - - - - org.springframework.boot - spring-boot-starter-data-jpa - - - - - org.springframework.boot - spring-boot-starter-test - test - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - - \ No newline at end of file diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/IAChatBootApplication.java b/backend/src/main/java/com/pablotj/ia/chat/boot/IAChatBootApplication.java deleted file mode 100644 index e3f7df6..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/IAChatBootApplication.java +++ /dev/null @@ -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); - } - -} diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/adapter/controller/ChatRestController.java b/backend/src/main/java/com/pablotj/ia/chat/boot/adapter/controller/ChatRestController.java deleted file mode 100644 index baa830b..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/adapter/controller/ChatRestController.java +++ /dev/null @@ -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> getChatHistory() { - LOGGER.debug("Accessing to chat"); - return ResponseEntity.ok(chatHistoryUseCase.chats()); - } - - @GetMapping("{chatId}") - public ResponseEntity> getChatMessages(@PathVariable("chatId") String chatId) { - LOGGER.debug("Accessing to chat messages"); - return ResponseEntity.ok(chatUseCase.getMessages(chatId)); - } - - @PostMapping(produces = "application/json") - public ResponseEntity newChat() { - return ResponseEntity.ok(chatUseCase.createChat()); - } - - @PutMapping(value = "{chatId}", consumes = "application/x-www-form-urlencoded", produces = "application/json") - public ResponseEntity 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); - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/application/prompt/PromptBuilder.java b/backend/src/main/java/com/pablotj/ia/chat/boot/application/prompt/PromptBuilder.java deleted file mode 100644 index 65bdb84..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/application/prompt/PromptBuilder.java +++ /dev/null @@ -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 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} -} \ No newline at end of file diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/application/prompt/PromptDefinition.java b/backend/src/main/java/com/pablotj/ia/chat/boot/application/prompt/PromptDefinition.java deleted file mode 100644 index 2ff90a5..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/application/prompt/PromptDefinition.java +++ /dev/null @@ -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 knowledge; - private String tone; - private String communicationStyle; - private List 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 getKnowledge() { return knowledge; } - public void setKnowledge(List 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 getRules() { return rules; } - public void setRules(List 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; } -} diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/application/prompt/PromptTemplates.java b/backend/src/main/java/com/pablotj/ia/chat/boot/application/prompt/PromptTemplates.java deleted file mode 100644 index e78eda2..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/application/prompt/PromptTemplates.java +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/application/session/ChatSessionManager.java b/backend/src/main/java/com/pablotj/ia/chat/boot/application/session/ChatSessionManager.java deleted file mode 100644 index 22c3216..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/application/session/ChatSessionManager.java +++ /dev/null @@ -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 getMessages(String chatUuid) { - List messages = chatMessageStore.getMessages(chatUuid); - List 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 messages) { - messages.removeIf(m -> ObjectUtils.isEmpty(m.text())); - chatMessageStore.saveMessages(chatUuid, messages); - } - - -} \ No newline at end of file diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/application/usecase/ChatHistoryUseCase.java b/backend/src/main/java/com/pablotj/ia/chat/boot/application/usecase/ChatHistoryUseCase.java deleted file mode 100644 index bde04e4..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/application/usecase/ChatHistoryUseCase.java +++ /dev/null @@ -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 chats() { - return chatMessageStore.getChats(); - } -} diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/application/usecase/ChatUseCase.java b/backend/src/main/java/com/pablotj/ia/chat/boot/application/usecase/ChatUseCase.java deleted file mode 100644 index c650f66..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/application/usecase/ChatUseCase.java +++ /dev/null @@ -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 getMessages(String chatId) { - return sessionManager.getMessages(chatId); - } - - public ChatMessage processUserPrompt(String prompt, String chatId) { - List 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; - } -} diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/domain/exception/BusinessLogicException.java b/backend/src/main/java/com/pablotj/ia/chat/boot/domain/exception/BusinessLogicException.java deleted file mode 100644 index f5d06dd..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/domain/exception/BusinessLogicException.java +++ /dev/null @@ -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); - } -} diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/domain/model/ChatIdentity.java b/backend/src/main/java/com/pablotj/ia/chat/boot/domain/model/ChatIdentity.java deleted file mode 100644 index a962412..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/domain/model/ChatIdentity.java +++ /dev/null @@ -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 { -} \ No newline at end of file diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/domain/model/ChatMessage.java b/backend/src/main/java/com/pablotj/ia/chat/boot/domain/model/ChatMessage.java deleted file mode 100644 index 948740e..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/domain/model/ChatMessage.java +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/domain/port/ChatMessageStore.java b/backend/src/main/java/com/pablotj/ia/chat/boot/domain/port/ChatMessageStore.java deleted file mode 100644 index f6ad445..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/domain/port/ChatMessageStore.java +++ /dev/null @@ -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 getChats(); - List getMessages(String chatUuid); - void saveMessages(String chatUuid, List messages); -} diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/domain/service/ChatService.java b/backend/src/main/java/com/pablotj/ia/chat/boot/domain/service/ChatService.java deleted file mode 100644 index dde17ea..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/domain/service/ChatService.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.pablotj.ia.chat.boot.domain.service; - -public interface ChatService { - - String chat(String promptWithHistory); -} diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/infraestructure/llm/LlmModelClient.java b/backend/src/main/java/com/pablotj/ia/chat/boot/infraestructure/llm/LlmModelClient.java deleted file mode 100644 index 1a418fd..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/infraestructure/llm/LlmModelClient.java +++ /dev/null @@ -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|>", ""); - } -} diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/infraestructure/llm/LlmModelLoader.java b/backend/src/main/java/com/pablotj/ia/chat/boot/infraestructure/llm/LlmModelLoader.java deleted file mode 100644 index b159483..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/infraestructure/llm/LlmModelLoader.java +++ /dev/null @@ -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(); - } -} diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatEntity.java b/backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatEntity.java deleted file mode 100644 index 74044da..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatEntity.java +++ /dev/null @@ -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 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 getMessages() { - return messages; - } - - public void setMessages(List messages) { - this.messages = messages; - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatJpaRepository.java b/backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatJpaRepository.java deleted file mode 100644 index c80ef8b..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatJpaRepository.java +++ /dev/null @@ -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 { - - Optional findOneByUuid(String chatUuid); - - List findAllByOrderByCreatedDateDesc(); - -} \ No newline at end of file diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatMapper.java b/backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatMapper.java deleted file mode 100644 index 3891e42..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatMapper.java +++ /dev/null @@ -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()); - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatMessageEntity.java b/backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatMessageEntity.java deleted file mode 100644 index 37e6f45..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatMessageEntity.java +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatMessageJpaRepository.java b/backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatMessageJpaRepository.java deleted file mode 100644 index fc2cb9a..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatMessageJpaRepository.java +++ /dev/null @@ -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 { - - List findByChatUuidOrderByIdAsc(String chatUuid); - - void deleteByChatUuid(String chatUuid); -} \ No newline at end of file diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatMessageMapper.java b/backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatMessageMapper.java deleted file mode 100644 index 6924c50..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/persistence/ChatMessageMapper.java +++ /dev/null @@ -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()); - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/pablotj/ia/chat/boot/persistence/SqliteChatMessageStore.java b/backend/src/main/java/com/pablotj/ia/chat/boot/persistence/SqliteChatMessageStore.java deleted file mode 100644 index ccb5802..0000000 --- a/backend/src/main/java/com/pablotj/ia/chat/boot/persistence/SqliteChatMessageStore.java +++ /dev/null @@ -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 getChats() { - return chatJpaRepository.findAllByOrderByCreatedDateDesc().stream() - .map(ChatMapper::toDomain) - .toList(); - } - - @Override - public List getMessages(String chatUuid) { - return chatMessageJpaRepository.findByChatUuidOrderByIdAsc(chatUuid) - .stream() - .map(ChatMessageMapper::toDomain) - .toList(); - } - - @Override - @Transactional - public void saveMessages(String chatUuid, List 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(); - } -} \ No newline at end of file diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml deleted file mode 100644 index a944928..0000000 --- a/backend/src/main/resources/application.yml +++ /dev/null @@ -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 diff --git a/chat-api/pom.xml b/chat-api/pom.xml new file mode 100644 index 0000000..528d598 --- /dev/null +++ b/chat-api/pom.xml @@ -0,0 +1,144 @@ + + 4.0.0 + + + com.pablotj + ai-chat-offline + 1.0.0-SNAPSHOT + + + chat-api + AI Chat Platform - Backend + Enterprise-grade AI Chat Platform Backend + jar + + + 21 + 3.2.0 + 2.8.9 + 4.2.0 + 3.45.1.0 + 1.5.5.Final + 1.12.0 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-actuator + ${spring-boot.version} + + + + + org.springframework.boot + spring-boot-actuator-autoconfigure + ${spring-boot.version} + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.xerial + sqlite-jdbc + ${sqlite.version} + + + + org.hibernate.orm + hibernate-community-dialects + + + + + de.kherud + llama + ${llama-java.version} + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + provided + + + + + io.micrometer + micrometer-core + ${micrometer.version} + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.testcontainers + junit-jupiter + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + ${java.version} + ${java.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + + + + diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/AiChatPlatformApplication.java b/chat-api/src/main/java/com/pablotj/ai/chat/AiChatPlatformApplication.java new file mode 100644 index 0000000..3f70c70 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/AiChatPlatformApplication.java @@ -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 + *

+ * 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); + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/application/dto/ConversationDto.java b/chat-api/src/main/java/com/pablotj/ai/chat/application/dto/ConversationDto.java new file mode 100644 index 0000000..ff41fdf --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/application/dto/ConversationDto.java @@ -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 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(); + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/application/dto/ConversationMessageDto.java b/chat-api/src/main/java/com/pablotj/ai/chat/application/dto/ConversationMessageDto.java new file mode 100644 index 0000000..2809d30 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/application/dto/ConversationMessageDto.java @@ -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 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(); + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/application/dto/ConversationSummaryDto.java b/chat-api/src/main/java/com/pablotj/ai/chat/application/dto/ConversationSummaryDto.java new file mode 100644 index 0000000..9925083 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/application/dto/ConversationSummaryDto.java @@ -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(); + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/application/mapper/ConversationMapper.java b/chat-api/src/main/java/com/pablotj/ai/chat/application/mapper/ConversationMapper.java new file mode 100644 index 0000000..0681d4c --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/application/mapper/ConversationMapper.java @@ -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; + } +} \ No newline at end of file diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/CreateConversationUseCase.java b/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/CreateConversationUseCase.java new file mode 100644 index 0000000..9abf37e --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/CreateConversationUseCase.java @@ -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); + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/GetConversationHistoryUseCase.java b/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/GetConversationHistoryUseCase.java new file mode 100644 index 0000000..37a4701 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/GetConversationHistoryUseCase.java @@ -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 execute() { + logger.debug("Retrieving conversation history"); + + List conversations = conversationRepository + .findAllActiveOrderedByCreationDate() + .stream() + .map(conversationMapper::toSummaryDto) + .toList(); + + logger.debug("Retrieved {} conversations", conversations.size()); + + return conversations; + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/GetConversationMessagesUseCase.java b/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/GetConversationMessagesUseCase.java new file mode 100644 index 0000000..4ac928b --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/GetConversationMessagesUseCase.java @@ -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 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 messages = conversation.getMessages() + .stream() + .map(conversationMapper::toMessageDto) + .toList(); + + logger.debug("Retrieved {} messages for conversation: {}", messages.size(), conversationId); + + return messages; + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/ProcessUserMessageUseCase.java b/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/ProcessUserMessageUseCase.java new file mode 100644 index 0000000..8fa1c1d --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/ProcessUserMessageUseCase.java @@ -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); + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/exception/AiServiceUnavailableException.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/exception/AiServiceUnavailableException.java new file mode 100644 index 0000000..5f18d58 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/exception/AiServiceUnavailableException.java @@ -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); + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/exception/ConversationNotFoundException.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/exception/ConversationNotFoundException.java new file mode 100644 index 0000000..43144a1 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/exception/ConversationNotFoundException.java @@ -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; + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/exception/DomainException.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/exception/DomainException.java new file mode 100644 index 0000000..00a7de3 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/exception/DomainException.java @@ -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); + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/Conversation.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/Conversation.java new file mode 100644 index 0000000..de6653b --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/Conversation.java @@ -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 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 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 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 getLastMessage() { + return messages.isEmpty() ? + Optional.empty() : + Optional.of(messages.get(messages.size() - 1)); + } + + public List 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 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 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); + } + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationId.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationId.java new file mode 100644 index 0000000..ddbd5d1 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationId.java @@ -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; + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationMessage.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationMessage.java new file mode 100644 index 0000000..a029626 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationMessage.java @@ -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); + } + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationStatus.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationStatus.java new file mode 100644 index 0000000..aba7bae --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationStatus.java @@ -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; + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationSummary.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationSummary.java new file mode 100644 index 0000000..6e836f9 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationSummary.java @@ -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; + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageContent.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageContent.java new file mode 100644 index 0000000..d1adbd8 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageContent.java @@ -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; + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageId.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageId.java new file mode 100644 index 0000000..3064866 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageId.java @@ -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; + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageMetadata.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageMetadata.java new file mode 100644 index 0000000..d2ebcb3 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageMetadata.java @@ -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 properties; + + private MessageMetadata(Map properties) { + this.properties = Collections.unmodifiableMap(new HashMap<>(properties)); + } + + public static MessageMetadata empty() { + return new MessageMetadata(Collections.emptyMap()); + } + + public static MessageMetadata of(Map properties) { + return new MessageMetadata(properties != null ? properties : Collections.emptyMap()); + } + + public static Builder builder() { + return new Builder(); + } + + public Optional getProperty(String key) { + return Optional.ofNullable(properties.get(key)); + } + + @SuppressWarnings("unchecked") + public Optional getProperty(String key, Class type) { + return getProperty(key) + .filter(type::isInstance) + .map(value -> (T) value); + } + + public Map getAllProperties() { + return properties; + } + + public boolean isEmpty() { + return properties.isEmpty(); + } + + public MessageMetadata withProperty(String key, Object value) { + Map 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 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 properties) { + if (properties != null) { + this.properties.putAll(properties); + } + return this; + } + + public MessageMetadata build() { + return new MessageMetadata(properties); + } + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageRole.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageRole.java new file mode 100644 index 0000000..bc37c53 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageRole.java @@ -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; + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/repository/ConversationRepository.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/repository/ConversationRepository.java new file mode 100644 index 0000000..f16827a --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/repository/ConversationRepository.java @@ -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 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 findByStatus(ConversationStatus status); + + /** + * Finds all active conversations ordered by creation date (newest first). + * + * @return list of active conversations + */ + List 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); +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/service/AiConversationService.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/service/AiConversationService.java new file mode 100644 index 0000000..e942f4d --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/service/AiConversationService.java @@ -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 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(); +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/ai/LlamaAiConversationService.java b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/ai/LlamaAiConversationService.java new file mode 100644 index 0000000..827f35d --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/ai/LlamaAiConversationService.java @@ -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 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(); + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/ai/LlamaModelManager.java b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/ai/LlamaModelManager.java new file mode 100644 index 0000000..71510b1 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/ai/LlamaModelManager.java @@ -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); + } + } + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/ai/prompt/ConversationPromptBuilder.java b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/ai/prompt/ConversationPromptBuilder.java new file mode 100644 index 0000000..b5c244c --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/ai/prompt/ConversationPromptBuilder.java @@ -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 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"; + }; + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/configuration/ApplicationConfiguration.java b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/configuration/ApplicationConfiguration.java new file mode 100644 index 0000000..3fa6d1a --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/configuration/ApplicationConfiguration.java @@ -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; + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/configuration/MetricsConfiguration.java b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/configuration/MetricsConfiguration.java new file mode 100644 index 0000000..9388e1d --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/configuration/MetricsConfiguration.java @@ -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 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")); + })); + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/configuration/OpenApiConfiguration.java b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/configuration/OpenApiConfiguration.java new file mode 100644 index 0000000..f23e0eb --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/configuration/OpenApiConfiguration.java @@ -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"))); + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/health/AiServiceHealthIndicator.java b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/health/AiServiceHealthIndicator.java new file mode 100644 index 0000000..45a036c --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/health/AiServiceHealthIndicator.java @@ -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(); + } + } +} +*/ \ No newline at end of file diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/entity/ConversationEntity.java b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/entity/ConversationEntity.java new file mode 100644 index 0000000..4cdc13b --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/entity/ConversationEntity.java @@ -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 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 getMessages() { + return messages; + } + + public void setMessages(List 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); + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/entity/ConversationMessageEntity.java b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/entity/ConversationMessageEntity.java new file mode 100644 index 0000000..afb3056 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/entity/ConversationMessageEntity.java @@ -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 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 getMetadata() { + return metadata; + } + + public void setMetadata(Map 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); + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/entity/ConversationStatusEntity.java b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/entity/ConversationStatusEntity.java new file mode 100644 index 0000000..70ec6b6 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/entity/ConversationStatusEntity.java @@ -0,0 +1,11 @@ +package com.pablotj.ai.chat.infrastructure.persistence.entity; + +/** + * JPA enumeration for conversation status. + */ +public enum ConversationStatusEntity { + ACTIVE, + ARCHIVED, + DELETED, + SUSPENDED +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/entity/MessageRoleEntity.java b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/entity/MessageRoleEntity.java new file mode 100644 index 0000000..cdb73a9 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/entity/MessageRoleEntity.java @@ -0,0 +1,10 @@ +package com.pablotj.ai.chat.infrastructure.persistence.entity; + +/** + * JPA enumeration for message roles. + */ +public enum MessageRoleEntity { + USER, + ASSISTANT, + SYSTEM +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/mapper/ConversationEntityMapper.java b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/mapper/ConversationEntityMapper.java new file mode 100644 index 0000000..b53d066 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/mapper/ConversationEntityMapper.java @@ -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 toMessageEntities(List messages); + + List toMessageDomains(List 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 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; + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/repository/ConversationJpaRepository.java b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/repository/ConversationJpaRepository.java new file mode 100644 index 0000000..fcc81b0 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/repository/ConversationJpaRepository.java @@ -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 { + + /** + * Finds a conversation by its UUID. + */ + Optional findByUuid(String uuid); + + /** + * Finds conversations by status ordered by creation date descending. + */ + List 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 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 findConversationsWithMoreThanMessages(@Param("messageCount") int messageCount); +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/repository/JpaConversationRepository.java b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/repository/JpaConversationRepository.java new file mode 100644 index 0000000..5008793 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/infrastructure/persistence/repository/JpaConversationRepository.java @@ -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 findById(ConversationId conversationId) { + logger.debug("Finding conversation by ID: {}", conversationId); + + return jpaRepository.findByUuid(conversationId.getUuid()) + .map(entityMapper::toDomain); + } + + @Override + @Transactional(readOnly = true) + public List 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 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); + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/presentation/dto/CreateConversationRequest.java b/chat-api/src/main/java/com/pablotj/ai/chat/presentation/dto/CreateConversationRequest.java new file mode 100644 index 0000000..e323680 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/presentation/dto/CreateConversationRequest.java @@ -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); + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/presentation/dto/SendMessageRequest.java b/chat-api/src/main/java/com/pablotj/ai/chat/presentation/dto/SendMessageRequest.java new file mode 100644 index 0000000..cd3642d --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/presentation/dto/SendMessageRequest.java @@ -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 +) { +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/presentation/exception/ErrorResponse.java b/chat-api/src/main/java/com/pablotj/ai/chat/presentation/exception/ErrorResponse.java new file mode 100644 index 0000000..63b6527 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/presentation/exception/ErrorResponse.java @@ -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 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 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 validationErrors) { + this.validationErrors = validationErrors; + return this; + } + + public ErrorResponse build() { + return new ErrorResponse(timestamp, status, error, message, path, validationErrors); + } + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/presentation/exception/GlobalExceptionHandler.java b/chat-api/src/main/java/com/pablotj/ai/chat/presentation/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..5258f82 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/presentation/exception/GlobalExceptionHandler.java @@ -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 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 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 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 handleValidationExceptions( + MethodArgumentNotValidException ex, WebRequest request) { + + logger.warn("Validation error: {}", ex.getMessage()); + + Map 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 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 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); + } +} diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/presentation/rest/ConversationController.java b/chat-api/src/main/java/com/pablotj/ai/chat/presentation/rest/ConversationController.java new file mode 100644 index 0000000..3fef7d1 --- /dev/null +++ b/chat-api/src/main/java/com/pablotj/ai/chat/presentation/rest/ConversationController.java @@ -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 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> getConversationHistory() { + logger.debug("Retrieving conversation history"); + + List 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> getConversationMessages( + @Parameter(description = "Unique conversation identifier", required = true) + @PathVariable String conversationId) { + + logger.debug("Retrieving messages for conversation: {}", conversationId); + + List 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 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); + } +} diff --git a/chat-api/src/main/resources/application.yml b/chat-api/src/main/resources/application.yml new file mode 100644 index 0000000..0d282e9 --- /dev/null +++ b/chat-api/src/main/resources/application.yml @@ -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 diff --git a/backend/src/main/resources/prompts/default_prompt.json b/chat-api/src/main/resources/prompts/default_prompt.json similarity index 99% rename from backend/src/main/resources/prompts/default_prompt.json rename to chat-api/src/main/resources/prompts/default_prompt.json index 09e0c3e..deced76 100644 --- a/backend/src/main/resources/prompts/default_prompt.json +++ b/chat-api/src/main/resources/prompts/default_prompt.json @@ -20,4 +20,4 @@ "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." -} \ No newline at end of file +} diff --git a/chat-api/src/main/resources/prompts/system_prompt.json b/chat-api/src/main/resources/prompts/system_prompt.json new file mode 100644 index 0000000..deced76 --- /dev/null +++ b/chat-api/src/main/resources/prompts/system_prompt.json @@ -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." +} diff --git a/backend/src/test/java/com/pablotj/ia/chat/boot/IAChatBootApplicationTests.java b/chat-api/src/test/java/com/pablotj/ia/chat/boot/IAChatBootApplicationTests.java similarity index 100% rename from backend/src/test/java/com/pablotj/ia/chat/boot/IAChatBootApplicationTests.java rename to chat-api/src/test/java/com/pablotj/ia/chat/boot/IAChatBootApplicationTests.java diff --git a/frontend/.gitignore b/chat-web-client/.gitignore similarity index 100% rename from frontend/.gitignore rename to chat-web-client/.gitignore diff --git a/frontend/dist/assets/index-DIsRwQnG.css b/chat-web-client/dist/assets/index-DIsRwQnG.css similarity index 100% rename from frontend/dist/assets/index-DIsRwQnG.css rename to chat-web-client/dist/assets/index-DIsRwQnG.css diff --git a/frontend/dist/assets/index-lEa7rgri.js b/chat-web-client/dist/assets/index-lEa7rgri.js similarity index 100% rename from frontend/dist/assets/index-lEa7rgri.js rename to chat-web-client/dist/assets/index-lEa7rgri.js diff --git a/frontend/dist/favicon/apple-touch-icon.png b/chat-web-client/dist/favicon/apple-touch-icon.png similarity index 100% rename from frontend/dist/favicon/apple-touch-icon.png rename to chat-web-client/dist/favicon/apple-touch-icon.png diff --git a/frontend/dist/favicon/favicon-96x96.png b/chat-web-client/dist/favicon/favicon-96x96.png similarity index 100% rename from frontend/dist/favicon/favicon-96x96.png rename to chat-web-client/dist/favicon/favicon-96x96.png diff --git a/frontend/dist/favicon/favicon.ico b/chat-web-client/dist/favicon/favicon.ico similarity index 100% rename from frontend/dist/favicon/favicon.ico rename to chat-web-client/dist/favicon/favicon.ico diff --git a/frontend/dist/favicon/favicon.svg b/chat-web-client/dist/favicon/favicon.svg similarity index 100% rename from frontend/dist/favicon/favicon.svg rename to chat-web-client/dist/favicon/favicon.svg diff --git a/frontend/dist/favicon/web-app-manifest-192x192.png b/chat-web-client/dist/favicon/web-app-manifest-192x192.png similarity index 100% rename from frontend/dist/favicon/web-app-manifest-192x192.png rename to chat-web-client/dist/favicon/web-app-manifest-192x192.png diff --git a/frontend/dist/favicon/web-app-manifest-512x512.png b/chat-web-client/dist/favicon/web-app-manifest-512x512.png similarity index 100% rename from frontend/dist/favicon/web-app-manifest-512x512.png rename to chat-web-client/dist/favicon/web-app-manifest-512x512.png diff --git a/frontend/dist/index.html b/chat-web-client/dist/index.html similarity index 100% rename from frontend/dist/index.html rename to chat-web-client/dist/index.html diff --git a/frontend/dist/robots.txt b/chat-web-client/dist/robots.txt similarity index 100% rename from frontend/dist/robots.txt rename to chat-web-client/dist/robots.txt diff --git a/frontend/dist/site.webmanifest b/chat-web-client/dist/site.webmanifest similarity index 100% rename from frontend/dist/site.webmanifest rename to chat-web-client/dist/site.webmanifest diff --git a/frontend/index.html b/chat-web-client/index.html similarity index 100% rename from frontend/index.html rename to chat-web-client/index.html diff --git a/frontend/package-lock.json b/chat-web-client/package-lock.json similarity index 100% rename from frontend/package-lock.json rename to chat-web-client/package-lock.json diff --git a/frontend/package.json b/chat-web-client/package.json similarity index 100% rename from frontend/package.json rename to chat-web-client/package.json diff --git a/frontend/pom.xml b/chat-web-client/pom.xml similarity index 72% rename from frontend/pom.xml rename to chat-web-client/pom.xml index 3def794..79f3ff3 100644 --- a/frontend/pom.xml +++ b/chat-web-client/pom.xml @@ -6,12 +6,12 @@ com.pablotj - chat-ia-offline - 0.0.1-SNAPSHOT + ai-chat-offline + 1.0.0-SNAPSHOT - frontend - chat-ia-frontend + chat-web-client + ai-chat-frontend Frontend Vue.js App pom diff --git a/frontend/public/favicon/apple-touch-icon.png b/chat-web-client/public/favicon/apple-touch-icon.png similarity index 100% rename from frontend/public/favicon/apple-touch-icon.png rename to chat-web-client/public/favicon/apple-touch-icon.png diff --git a/frontend/public/favicon/favicon-96x96.png b/chat-web-client/public/favicon/favicon-96x96.png similarity index 100% rename from frontend/public/favicon/favicon-96x96.png rename to chat-web-client/public/favicon/favicon-96x96.png diff --git a/frontend/public/favicon/favicon.ico b/chat-web-client/public/favicon/favicon.ico similarity index 100% rename from frontend/public/favicon/favicon.ico rename to chat-web-client/public/favicon/favicon.ico diff --git a/frontend/public/favicon/favicon.svg b/chat-web-client/public/favicon/favicon.svg similarity index 100% rename from frontend/public/favicon/favicon.svg rename to chat-web-client/public/favicon/favicon.svg diff --git a/frontend/public/favicon/web-app-manifest-192x192.png b/chat-web-client/public/favicon/web-app-manifest-192x192.png similarity index 100% rename from frontend/public/favicon/web-app-manifest-192x192.png rename to chat-web-client/public/favicon/web-app-manifest-192x192.png diff --git a/frontend/public/favicon/web-app-manifest-512x512.png b/chat-web-client/public/favicon/web-app-manifest-512x512.png similarity index 100% rename from frontend/public/favicon/web-app-manifest-512x512.png rename to chat-web-client/public/favicon/web-app-manifest-512x512.png diff --git a/frontend/public/robots.txt b/chat-web-client/public/robots.txt similarity index 100% rename from frontend/public/robots.txt rename to chat-web-client/public/robots.txt diff --git a/frontend/public/site.webmanifest b/chat-web-client/public/site.webmanifest similarity index 100% rename from frontend/public/site.webmanifest rename to chat-web-client/public/site.webmanifest diff --git a/frontend/src/App.vue b/chat-web-client/src/App.vue similarity index 100% rename from frontend/src/App.vue rename to chat-web-client/src/App.vue diff --git a/frontend/src/assets/styles.css b/chat-web-client/src/assets/styles.css similarity index 100% rename from frontend/src/assets/styles.css rename to chat-web-client/src/assets/styles.css diff --git a/frontend/src/components/ChatForm.vue b/chat-web-client/src/components/ChatForm.vue similarity index 98% rename from frontend/src/components/ChatForm.vue rename to chat-web-client/src/components/ChatForm.vue index 2dfe56f..54ba3d8 100644 --- a/frontend/src/components/ChatForm.vue +++ b/chat-web-client/src/components/ChatForm.vue @@ -20,7 +20,7 @@