From c8c3c6124b486cbd656563968b53f86796e2fc45 Mon Sep 17 00:00:00 2001 From: Choi wontak <76509639+RoundTable02@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:02:31 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EB=82=B4=EC=9A=A9?= =?UTF-8?q?=EC=9D=84=20=EB=B6=84=ED=95=A0=ED=95=98=EC=97=AC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/ChatRoomPersistenceAdapter.java | 14 ++ .../chat_room/ChatRoomCommandHelper.java | 4 + .../port/out/chat/SaveChatMessagePort.java | 4 + .../service/chat/ChatMessageService.java | 23 +- .../application/service/chat/ChatService.java | 24 +- .../service/chat/ChatSseSender.java | 11 + .../cmc/malmo/util/ChatMessageSplitter.java | 106 +++++++++ .../malmo/util/ChatMessageSplitterTest.java | 208 ++++++++++++++++++ 8 files changed, 388 insertions(+), 6 deletions(-) create mode 100644 src/main/java/makeus/cmc/malmo/util/ChatMessageSplitter.java create mode 100644 src/test/java/makeus/cmc/malmo/util/ChatMessageSplitterTest.java diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java index 4a732e54..2a44a6f5 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java @@ -82,6 +82,20 @@ public ChatMessage saveChatMessage(ChatMessage chatMessage) { return chatMessageMapper.toDomain(savedEntity); } + @Override + public List saveChatMessages(List chatMessages) { + if (chatMessages == null || chatMessages.isEmpty()) { + return List.of(); + } + List entities = chatMessages.stream() + .map(chatMessageMapper::toEntity) + .toList(); + List savedEntities = chatMessageRepository.saveAll(entities); + return savedEntities.stream() + .map(chatMessageMapper::toDomain) + .toList(); + } + @Override public Optional loadChatRoomById(ChatRoomId chatRoomId) { return chatRoomRepository.findById(chatRoomId.getValue()) diff --git a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomCommandHelper.java b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomCommandHelper.java index 06d156c3..accec09c 100644 --- a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomCommandHelper.java +++ b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomCommandHelper.java @@ -31,4 +31,8 @@ public void deleteChatRooms(List chatRoomIds) { public ChatMessage saveChatMessage(ChatMessage chatMessage) { return saveChatMessagePort.saveChatMessage(chatMessage); } + + public List saveChatMessages(List chatMessages) { + return saveChatMessagePort.saveChatMessages(chatMessages); + } } diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveChatMessagePort.java b/src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveChatMessagePort.java index bb1fac3e..7a7d2e37 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveChatMessagePort.java +++ b/src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveChatMessagePort.java @@ -2,6 +2,10 @@ import makeus.cmc.malmo.domain.model.chat.ChatMessage; +import java.util.List; + public interface SaveChatMessagePort { ChatMessage saveChatMessage(ChatMessage chatMessage); + + List saveChatMessages(List chatMessages); } diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java index 232685fb..03463fc6 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java @@ -30,11 +30,13 @@ import makeus.cmc.malmo.domain.value.id.CoupleId; import makeus.cmc.malmo.domain.value.id.CoupleQuestionId; import makeus.cmc.malmo.domain.value.id.MemberId; +import makeus.cmc.malmo.util.ChatMessageSplitter; import org.springframework.stereotype.Service; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; @Slf4j @Service @@ -287,8 +289,23 @@ private CompletableFuture requestNextStageOpening(Member member, ChatRoom } private void saveAiMessage(MemberId memberId, ChatRoomId chatRoomId, int level, int detailedLevel, String fullAnswer) { - ChatMessage aiTextMessage = chatRoomDomainService.createAiMessage(chatRoomId, level, detailedLevel, fullAnswer); - ChatMessage savedMessage = chatRoomCommandHelper.saveChatMessage(aiTextMessage); - chatSseSender.sendAiResponseId(memberId, savedMessage.getId()); + // fullAnswer를 문장 단위로 분할하고 세 문장씩 그룹화 + List groupedTexts = ChatMessageSplitter.splitIntoGroups(fullAnswer); + + // 각 그룹을 ChatMessage로 생성 + List chatMessages = groupedTexts.stream() + .map(groupText -> chatRoomDomainService.createAiMessage(chatRoomId, level, detailedLevel, groupText)) + .collect(Collectors.toList()); + + // bulk 저장 + List savedMessages = chatRoomCommandHelper.saveChatMessages(chatMessages); + + // 저장된 메시지들의 ID 리스트 추출 + List messageIds = savedMessages.stream() + .map(ChatMessage::getId) + .collect(Collectors.toList()); + + // SSE로 ID 리스트 전송 + chatSseSender.sendAiResponseIds(memberId, messageIds); } } diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatService.java index 3e9764ff..b7d73040 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatService.java @@ -19,10 +19,13 @@ import makeus.cmc.malmo.domain.value.id.ChatRoomId; import makeus.cmc.malmo.domain.value.id.MemberId; import makeus.cmc.malmo.domain.value.state.ChatRoomState; +import makeus.cmc.malmo.util.ChatMessageSplitter; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; //import static makeus.cmc.malmo.util.GlobalConstants.FINAL_MESSAGE; @@ -112,8 +115,23 @@ private ChatMessage saveUserMessage(ChatRoom chatRoom, String message) { } private void saveAiMessage(MemberId memberId, ChatRoomId chatRoomId, int level, int detailedLevel, String fullAnswer) { - ChatMessage aiTextMessage = chatRoomDomainService.createAiMessage(chatRoomId, level, detailedLevel, fullAnswer); - ChatMessage savedMessage = chatRoomCommandHelper.saveChatMessage(aiTextMessage); - chatSseSender.sendAiResponseId(memberId, savedMessage.getId()); + // fullAnswer를 문장 단위로 분할하고 세 문장씩 그룹화 + List groupedTexts = ChatMessageSplitter.splitIntoGroups(fullAnswer); + + // 각 그룹을 ChatMessage로 생성 + List chatMessages = groupedTexts.stream() + .map(groupText -> chatRoomDomainService.createAiMessage(chatRoomId, level, detailedLevel, groupText)) + .collect(Collectors.toList()); + + // bulk 저장 + List savedMessages = chatRoomCommandHelper.saveChatMessages(chatMessages); + + // 저장된 메시지들의 ID 리스트 추출 + List messageIds = savedMessages.stream() + .map(ChatMessage::getId) + .collect(Collectors.toList()); + + // SSE로 ID 리스트 전송 + chatSseSender.sendAiResponseIds(memberId, messageIds); } } diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatSseSender.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatSseSender.java index 72f9e4db..fb087400 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatSseSender.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatSseSender.java @@ -5,6 +5,8 @@ import makeus.cmc.malmo.domain.value.id.MemberId; import org.springframework.stereotype.Service; +import java.util.List; + @Service @RequiredArgsConstructor public class ChatSseSender { @@ -32,6 +34,15 @@ public void sendAiResponseId(MemberId memberId, Long messageId) { )); } + public void sendAiResponseIds(MemberId memberId, List messageIds) { + sendSseEventPort.sendToMember( + memberId, + new SendSseEventPort.NotificationEvent( + SendSseEventPort.SseEventType.AI_RESPONSE_ID, + messageIds + )); + } + public void sendError(MemberId memberId, String errorMessage) { sendSseEventPort.sendToMember( memberId, diff --git a/src/main/java/makeus/cmc/malmo/util/ChatMessageSplitter.java b/src/main/java/makeus/cmc/malmo/util/ChatMessageSplitter.java new file mode 100644 index 00000000..bbaba7ae --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/util/ChatMessageSplitter.java @@ -0,0 +1,106 @@ +package makeus.cmc.malmo.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 채팅 메시지를 문장 단위로 분할하고 그룹화하는 유틸리티 클래스 + */ +public class ChatMessageSplitter { + + /** + * 문장 종결 부호 패턴 (규칙 변경 시 이 값만 수정하면 됨) + */ + private static final Pattern SENTENCE_END_PATTERN = Pattern.compile("[.!?]"); + + /** + * 그룹당 문장 수 (규칙 변경 시 이 값만 수정하면 됨) + */ + private static final int SENTENCES_PER_GROUP = 3; + + /** + * 텍스트를 문장부호 기준으로 문장 단위로 분할합니다. + * 문장부호(. ! ?) 하나만 나와도 문장 종결로 간주합니다. + * + * @param text 분할할 텍스트 + * @return 문장 단위로 분할된 리스트 (문장부호 포함) + */ + public static List splitIntoSentences(String text) { + if (text == null || text.trim().isEmpty()) { + return new ArrayList<>(); + } + + List sentences = new ArrayList<>(); + Matcher matcher = SENTENCE_END_PATTERN.matcher(text); + + int lastIndex = 0; + while (matcher.find()) { + int endIndex = matcher.end(); + String sentence = text.substring(lastIndex, endIndex); + // 첫 번째 문장만 앞뒤 공백 제거, 나머지는 앞 공백 유지 + if (sentences.isEmpty()) { + sentence = sentence.trim(); + } else { + // 앞 공백은 유지하되, 전체가 공백만 있는 경우는 제외 + sentence = sentence.trim().isEmpty() ? sentence : sentence; + } + if (!sentence.trim().isEmpty()) { + sentences.add(sentence); + } + lastIndex = endIndex; + } + + // 마지막 문장부호 이후의 텍스트 처리 + if (lastIndex < text.length()) { + String remaining = text.substring(lastIndex); + if (!remaining.trim().isEmpty()) { + // 첫 번째 문장이 아니면 앞 공백 유지 + if (!sentences.isEmpty()) { + sentences.add(remaining); + } else { + sentences.add(remaining.trim()); + } + } + } + + return sentences; + } + + /** + * 텍스트를 문장 단위로 분할한 후, 세 문장씩 그룹화합니다. + * 예: 10문장인 경우 4개의 그룹으로 생성됩니다. + * + * @param text 그룹화할 텍스트 + * @return 세 문장씩 그룹화된 텍스트 리스트 + */ + public static List splitIntoGroups(String text) { + List sentences = splitIntoSentences(text); + + if (sentences.isEmpty()) { + return new ArrayList<>(); + } + + List groups = new ArrayList<>(); + StringBuilder currentGroup = new StringBuilder(); + + for (int i = 0; i < sentences.size(); i++) { + if (i > 0 && i % SENTENCES_PER_GROUP == 0) { + // 세 문장이 모였으면 그룹 완성 + groups.add(currentGroup.toString()); + currentGroup = new StringBuilder(); + } + + // sentences는 이미 적절한 공백을 포함하고 있으므로 그대로 추가 + currentGroup.append(sentences.get(i)); + } + + // 마지막 남은 문장들 처리 + if (currentGroup.length() > 0) { + groups.add(currentGroup.toString()); + } + + return groups; + } +} diff --git a/src/test/java/makeus/cmc/malmo/util/ChatMessageSplitterTest.java b/src/test/java/makeus/cmc/malmo/util/ChatMessageSplitterTest.java new file mode 100644 index 00000000..ed0e5e8d --- /dev/null +++ b/src/test/java/makeus/cmc/malmo/util/ChatMessageSplitterTest.java @@ -0,0 +1,208 @@ +package makeus.cmc.malmo.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("ChatMessageSplitter 클래스 테스트") +class ChatMessageSplitterTest { + + @Test + @DisplayName("문장부호 기준으로 문장을 분할한다") + void splitIntoSentences_문장부호_기준_분할() { + // given + String text = "첫 번째 문장입니다. 두 번째 문장입니다! 세 번째 문장입니다?"; + + // when + List sentences = ChatMessageSplitter.splitIntoSentences(text); + + // then + assertThat(sentences).hasSize(3); + assertThat(sentences.get(0)).isEqualTo("첫 번째 문장입니다."); + assertThat(sentences.get(1)).isEqualTo(" 두 번째 문장입니다!"); + assertThat(sentences.get(2)).isEqualTo(" 세 번째 문장입니다?"); + } + + @Test + @DisplayName("문장부호 하나만 나와도 문장 종결로 간주한다") + void splitIntoSentences_문장부호_하나만_나와도_종결() { + // given + String text = "문장입니다. 또 다른 문장! 마지막 문장?"; + + // when + List sentences = ChatMessageSplitter.splitIntoSentences(text); + + // then + assertThat(sentences).hasSize(3); + } + + @Test + @DisplayName("세 문장씩 그룹화한다") + void splitIntoGroups_세_문장씩_그룹화() { + // given + String text = "문장1. 문장2! 문장3? 문장4. 문장5! 문장6? 문장7."; + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).hasSize(3); + assertThat(groups.get(0)).isEqualTo("문장1. 문장2! 문장3?"); + assertThat(groups.get(1)).isEqualTo(" 문장4. 문장5! 문장6?"); + assertThat(groups.get(2)).isEqualTo(" 문장7."); + } + + @Test + @DisplayName("10문장인 경우 4개의 그룹으로 생성한다") + void splitIntoGroups_10문장_4개_그룹() { + // given + String text = "1. 2! 3? 4. 5! 6? 7. 8! 9? 10."; + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).hasSize(4); + assertThat(groups.get(0)).isEqualTo("1. 2! 3?"); + assertThat(groups.get(1)).isEqualTo(" 4. 5! 6?"); + assertThat(groups.get(2)).isEqualTo(" 7. 8! 9?"); + assertThat(groups.get(3)).isEqualTo(" 10."); + } + + @Test + @DisplayName("정확히 3의 배수 문장인 경우 그룹화한다") + void splitIntoGroups_3의_배수_문장() { + // given + String text = "문장1. 문장2! 문장3? 문장4. 문장5! 문장6?"; + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).hasSize(2); + assertThat(groups.get(0)).isEqualTo("문장1. 문장2! 문장3?"); + assertThat(groups.get(1)).isEqualTo(" 문장4. 문장5! 문장6?"); + } + + @Test + @DisplayName("문장이 1개인 경우 1개 그룹으로 생성한다") + void splitIntoGroups_문장_1개() { + // given + String text = "단일 문장입니다."; + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).hasSize(1); + assertThat(groups.get(0)).isEqualTo("단일 문장입니다."); + } + + @Test + @DisplayName("문장이 2개인 경우 1개 그룹으로 생성한다") + void splitIntoGroups_문장_2개() { + // given + String text = "첫 번째 문장. 두 번째 문장!"; + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).hasSize(1); + assertThat(groups.get(0)).isEqualTo("첫 번째 문장. 두 번째 문장!"); + } + + @Test + @DisplayName("문장부호가 없는 경우 전체를 하나의 그룹으로 처리한다") + void splitIntoGroups_문장부호_없음() { + // given + String text = "문장부호가 없는 텍스트입니다"; + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).hasSize(1); + assertThat(groups.get(0)).isEqualTo("문장부호가 없는 텍스트입니다"); + } + + @Test + @DisplayName("빈 문자열인 경우 빈 리스트를 반환한다") + void splitIntoGroups_빈_문자열() { + // given + String text = ""; + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).isEmpty(); + } + + @Test + @DisplayName("공백만 있는 경우 빈 리스트를 반환한다") + void splitIntoGroups_공백만_있음() { + // given + String text = " "; + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).isEmpty(); + } + + @Test + @DisplayName("다양한 문장부호 조합을 처리한다") + void splitIntoGroups_다양한_문장부호_조합() { + // given + String text = "문장1. 문장2! 문장3? 문장4. 문장5!"; + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).hasSize(2); + assertThat(groups.get(0)).isEqualTo("문장1. 문장2! 문장3?"); + assertThat(groups.get(1)).isEqualTo(" 문장4. 문장5!"); + } + + @Test + @DisplayName("문장부호 뒤에 공백이 없는 경우도 처리한다") + void splitIntoGroups_문장부호_뒤_공백_없음() { + // given + String text = "문장1.문장2!문장3?"; + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).hasSize(1); + assertThat(groups.get(0)).contains("문장1."); + assertThat(groups.get(0)).contains("문장2!"); + assertThat(groups.get(0)).contains("문장3?"); + } + + @Test + @DisplayName("긴 텍스트도 올바르게 처리한다") + void splitIntoGroups_긴_텍스트() { + // given + StringBuilder sb = new StringBuilder(); + for (int i = 1; i <= 15; i++) { + sb.append("문장").append(i).append("."); + if (i < 15) { + sb.append(" "); + } + } + String text = sb.toString(); + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).hasSize(5); // 15문장 / 3 = 5그룹 + } +}