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