From 865a93c6252049b781054910e4650a51b67aa0e5 Mon Sep 17 00:00:00 2001 From: Jansoon Date: Fri, 19 Dec 2025 23:14:04 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=20=EC=83=9D=EC=84=B1=20AI=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20(#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sofa/linkiving/LinkivingApplication.java | 2 +- .../domain/chat/ai/AiTitleClient.java | 10 -- ...iTitleClient.java => MockTitleClient.java} | 8 +- .../domain/chat/ai/RagTitleClient.java | 46 +++++++++ .../domain/chat/ai/RagTitleFeign.java | 17 ++++ .../linkiving/domain/chat/ai/TitleClient.java | 10 ++ .../chat/dto/request/TitleGenerateReq.java | 6 ++ .../chat/dto/response/TitleGenerateRes.java | 6 ++ .../domain/chat/facade/ChatFacade.java | 6 +- .../domain/chat/ai/RagTitleClientTest.java | 96 +++++++++++++++++++ .../domain/chat/facade/ChatFacadeTest.java | 6 +- .../integration/ChatApiIntegrationTest.java | 7 +- 12 files changed, 196 insertions(+), 24 deletions(-) delete mode 100644 src/main/java/com/sofa/linkiving/domain/chat/ai/AiTitleClient.java rename src/main/java/com/sofa/linkiving/domain/chat/ai/{MockAiTitleClient.java => MockTitleClient.java} (50%) create mode 100644 src/main/java/com/sofa/linkiving/domain/chat/ai/RagTitleClient.java create mode 100644 src/main/java/com/sofa/linkiving/domain/chat/ai/RagTitleFeign.java create mode 100644 src/main/java/com/sofa/linkiving/domain/chat/ai/TitleClient.java create mode 100644 src/main/java/com/sofa/linkiving/domain/chat/dto/request/TitleGenerateReq.java create mode 100644 src/main/java/com/sofa/linkiving/domain/chat/dto/response/TitleGenerateRes.java create mode 100644 src/test/java/com/sofa/linkiving/domain/chat/ai/RagTitleClientTest.java diff --git a/src/main/java/com/sofa/linkiving/LinkivingApplication.java b/src/main/java/com/sofa/linkiving/LinkivingApplication.java index b7319694..12c429ce 100644 --- a/src/main/java/com/sofa/linkiving/LinkivingApplication.java +++ b/src/main/java/com/sofa/linkiving/LinkivingApplication.java @@ -7,7 +7,7 @@ @SpringBootApplication @EnableJpaAuditing -@EnableFeignClients(basePackages = "com.sofa.linkiving.infra.feign") +@EnableFeignClients public class LinkivingApplication { public static void main(String[] args) { diff --git a/src/main/java/com/sofa/linkiving/domain/chat/ai/AiTitleClient.java b/src/main/java/com/sofa/linkiving/domain/chat/ai/AiTitleClient.java deleted file mode 100644 index bd1a9799..00000000 --- a/src/main/java/com/sofa/linkiving/domain/chat/ai/AiTitleClient.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.sofa.linkiving.domain.chat.ai; - -public interface AiTitleClient { - /** - * AI 서버에 요약 요청을 보냅니다. - * @param firstChat 채팅 시작 대화 - * @return 제목 - */ - String generateSummary(String firstChat); -} diff --git a/src/main/java/com/sofa/linkiving/domain/chat/ai/MockAiTitleClient.java b/src/main/java/com/sofa/linkiving/domain/chat/ai/MockTitleClient.java similarity index 50% rename from src/main/java/com/sofa/linkiving/domain/chat/ai/MockAiTitleClient.java rename to src/main/java/com/sofa/linkiving/domain/chat/ai/MockTitleClient.java index ee8c1ba7..f4005dca 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/ai/MockAiTitleClient.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/ai/MockTitleClient.java @@ -1,13 +1,13 @@ package com.sofa.linkiving.domain.chat.ai; -import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; @Component -@Primary -public class MockAiTitleClient implements AiTitleClient { +@Profile("test") +public class MockTitleClient implements TitleClient { @Override - public String generateSummary(String firstChat) { + public String generateTitle(String firstChat) { return String.format("임시 제목[%s]", firstChat); } } diff --git a/src/main/java/com/sofa/linkiving/domain/chat/ai/RagTitleClient.java b/src/main/java/com/sofa/linkiving/domain/chat/ai/RagTitleClient.java new file mode 100644 index 00000000..e6992d3c --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/chat/ai/RagTitleClient.java @@ -0,0 +1,46 @@ +package com.sofa.linkiving.domain.chat.ai; + +import java.util.List; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import com.sofa.linkiving.domain.chat.dto.request.TitleGenerateReq; +import com.sofa.linkiving.domain.chat.dto.response.TitleGenerateRes; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +@Profile("!test") +@RequiredArgsConstructor +public class RagTitleClient implements TitleClient { + + private static final int MAX_TITLE_LENGTH = 100; + private final RagTitleFeign ragTitleFeign; + + @Override + public String generateTitle(String firstChat) { + try { + List response = ragTitleFeign.generateTitle(new TitleGenerateReq(firstChat)); + + if (response == null || response.isEmpty()) { + return truncateTitle(firstChat); + } + + return response.get(0).title(); + + } catch (Exception e) { + log.error("AI 서버 통신 실패. 기본 제목으로 대체합니다. error={}", e.getMessage()); + return truncateTitle(firstChat); + } + } + + private String truncateTitle(String originalTitle) { + if (originalTitle.length() <= MAX_TITLE_LENGTH) { + return originalTitle; + } + return originalTitle.substring(0, MAX_TITLE_LENGTH); + } +} diff --git a/src/main/java/com/sofa/linkiving/domain/chat/ai/RagTitleFeign.java b/src/main/java/com/sofa/linkiving/domain/chat/ai/RagTitleFeign.java new file mode 100644 index 00000000..79ebb642 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/chat/ai/RagTitleFeign.java @@ -0,0 +1,17 @@ +package com.sofa.linkiving.domain.chat.ai; + +import java.util.List; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import com.sofa.linkiving.domain.chat.dto.request.TitleGenerateReq; +import com.sofa.linkiving.domain.chat.dto.response.TitleGenerateRes; +import com.sofa.linkiving.infra.feign.GlobalFeignConfig; + +@FeignClient(name = "ai-title-client", url = "${ai.server.url}", configuration = GlobalFeignConfig.class) +public interface RagTitleFeign { + @PostMapping("/webhook/title-generate") + List generateTitle(@RequestBody TitleGenerateReq request); +} diff --git a/src/main/java/com/sofa/linkiving/domain/chat/ai/TitleClient.java b/src/main/java/com/sofa/linkiving/domain/chat/ai/TitleClient.java new file mode 100644 index 00000000..5329ecb8 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/chat/ai/TitleClient.java @@ -0,0 +1,10 @@ +package com.sofa.linkiving.domain.chat.ai; + +public interface TitleClient { + /** + * AI 서버에 첫 채팅 내용을 토대로 채팅방 제목 생성 요청을 보냅니다. + * @param firstChat 채팅 시작 대화 + * @return 제목 + */ + String generateTitle(String firstChat); +} diff --git a/src/main/java/com/sofa/linkiving/domain/chat/dto/request/TitleGenerateReq.java b/src/main/java/com/sofa/linkiving/domain/chat/dto/request/TitleGenerateReq.java new file mode 100644 index 00000000..5c86babb --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/chat/dto/request/TitleGenerateReq.java @@ -0,0 +1,6 @@ +package com.sofa.linkiving.domain.chat.dto.request; + +public record TitleGenerateReq( + String firstMessage +) { +} diff --git a/src/main/java/com/sofa/linkiving/domain/chat/dto/response/TitleGenerateRes.java b/src/main/java/com/sofa/linkiving/domain/chat/dto/response/TitleGenerateRes.java new file mode 100644 index 00000000..b0ae7531 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/chat/dto/response/TitleGenerateRes.java @@ -0,0 +1,6 @@ +package com.sofa.linkiving.domain.chat.dto.response; + +public record TitleGenerateRes( + String title +) { +} diff --git a/src/main/java/com/sofa/linkiving/domain/chat/facade/ChatFacade.java b/src/main/java/com/sofa/linkiving/domain/chat/facade/ChatFacade.java index d80bccdb..3b463cd9 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/facade/ChatFacade.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/facade/ChatFacade.java @@ -5,7 +5,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.sofa.linkiving.domain.chat.ai.AiTitleClient; +import com.sofa.linkiving.domain.chat.ai.TitleClient; import com.sofa.linkiving.domain.chat.dto.internal.MessagesDto; import com.sofa.linkiving.domain.chat.dto.response.ChatsRes; import com.sofa.linkiving.domain.chat.dto.response.CreateChatRes; @@ -25,7 +25,7 @@ public class ChatFacade { private final ChatService chatService; private final MessageService messageService; private final FeedbackService feedbackService; - private final AiTitleClient aiTitleClient; + private final TitleClient titleClient; public MessagesRes getMessages(Member member, Long chatId, Long lastId, int size) { Chat chat = chatService.getChat(chatId, member); @@ -35,7 +35,7 @@ public MessagesRes getMessages(Member member, Long chatId, Long lastId, int size @Transactional public CreateChatRes createChat(String firstChat, Member member) { - String title = aiTitleClient.generateSummary(firstChat); + String title = titleClient.generateTitle(firstChat); Chat chat = chatService.createChat(title, member); return CreateChatRes.from(chat, firstChat); diff --git a/src/test/java/com/sofa/linkiving/domain/chat/ai/RagTitleClientTest.java b/src/test/java/com/sofa/linkiving/domain/chat/ai/RagTitleClientTest.java new file mode 100644 index 00000000..61f1b0f2 --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/chat/ai/RagTitleClientTest.java @@ -0,0 +1,96 @@ +package com.sofa.linkiving.domain.chat.ai; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.sofa.linkiving.domain.chat.dto.request.TitleGenerateReq; +import com.sofa.linkiving.domain.chat.dto.response.TitleGenerateRes; + +@ExtendWith(MockitoExtension.class) +public class RagTitleClientTest { + + @InjectMocks + private RagTitleClient ragTitleClient; + + @Mock + private RagTitleFeign ragTitleFeign; + + @Test + @DisplayName("AI 서버 통신 성공 시 생성된 제목을 반환한다") + void shouldReturnGeneratedTitleWhenApiCallSucceeds() { + // given + String firstChat = "안녕하세요"; + String generatedTitle = "인사말"; + + TitleGenerateRes resDto = mock(TitleGenerateRes.class); + given(resDto.title()).willReturn(generatedTitle); + + given(ragTitleFeign.generateTitle(any(TitleGenerateReq.class))) + .willReturn(List.of(resDto)); + + // when + String result = ragTitleClient.generateTitle(firstChat); + + // then + assertThat(result).isEqualTo(generatedTitle); + verify(ragTitleFeign).generateTitle(any(TitleGenerateReq.class)); + } + + @Test + @DisplayName("AI 서버 응답이 빈 리스트일 경우 요청한 첫 메시지(firstChat)를 그대로 반환한다") + void shouldReturnFirstChatWhenResponseIsEmpty() { + // given + String firstChat = "안녕하세요"; + + given(ragTitleFeign.generateTitle(any(TitleGenerateReq.class))) + .willReturn(Collections.emptyList()); + + // when + String result = ragTitleClient.generateTitle(firstChat); + + // then + assertThat(result).isEqualTo(firstChat); + } + + @Test + @DisplayName("AI 서버 응답이 null일 경우 요청한 첫 메시지(firstChat)를 그대로 반환한다") + void shouldReturnFirstChatWhenResponseIsNull() { + // given + String firstChat = "안녕하세요"; + + given(ragTitleFeign.generateTitle(any(TitleGenerateReq.class))) + .willReturn(null); + + // when + String result = ragTitleClient.generateTitle(firstChat); + + // then + assertThat(result).isEqualTo(firstChat); + } + + @Test + @DisplayName("AI 서버 통신 중 예외 발생 시 로그를 남기고 첫 메시지(firstChat)를 그대로 반환한다") + void shouldReturnFirstChatWhenExceptionOccurs() { + // given + String firstChat = "안녕하세요"; + + given(ragTitleFeign.generateTitle(any(TitleGenerateReq.class))) + .willThrow(new RuntimeException("API Connection Failed")); + + // when + String result = ragTitleClient.generateTitle(firstChat); + + // then + assertThat(result).isEqualTo(firstChat); + } +} diff --git a/src/test/java/com/sofa/linkiving/domain/chat/facade/ChatFacadeTest.java b/src/test/java/com/sofa/linkiving/domain/chat/facade/ChatFacadeTest.java index 9b06f688..268af832 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/facade/ChatFacadeTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/facade/ChatFacadeTest.java @@ -13,7 +13,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.sofa.linkiving.domain.chat.ai.AiTitleClient; +import com.sofa.linkiving.domain.chat.ai.TitleClient; import com.sofa.linkiving.domain.chat.dto.internal.MessagesDto; import com.sofa.linkiving.domain.chat.dto.response.ChatsRes; import com.sofa.linkiving.domain.chat.dto.response.CreateChatRes; @@ -39,7 +39,7 @@ public class ChatFacadeTest { private FeedbackService feedbackService; @Mock - private AiTitleClient aiTitleClient; + private TitleClient titleClient; @Mock private Member member; @@ -105,7 +105,7 @@ void shouldReturnCreateChatResWhenCreateChat() { given(mockChat.getId()).willReturn(chatId); given(mockChat.getTitle()).willReturn(generatedTitle); - given(aiTitleClient.generateSummary(firstChat)).willReturn(generatedTitle); + given(titleClient.generateTitle(firstChat)).willReturn(generatedTitle); given(chatService.createChat(generatedTitle, member)).willReturn(mockChat); // when diff --git a/src/test/java/com/sofa/linkiving/domain/chat/integration/ChatApiIntegrationTest.java b/src/test/java/com/sofa/linkiving/domain/chat/integration/ChatApiIntegrationTest.java index 766de9eb..ba6171ed 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/integration/ChatApiIntegrationTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/integration/ChatApiIntegrationTest.java @@ -22,7 +22,7 @@ import org.springframework.transaction.annotation.Transactional; import com.fasterxml.jackson.databind.ObjectMapper; -import com.sofa.linkiving.domain.chat.ai.AiTitleClient; +import com.sofa.linkiving.domain.chat.ai.TitleClient; import com.sofa.linkiving.domain.chat.dto.request.CreateChatReq; import com.sofa.linkiving.domain.chat.dto.response.MessageRes; import com.sofa.linkiving.domain.chat.dto.response.MessagesRes; @@ -73,7 +73,7 @@ public class ChatApiIntegrationTest { private SummaryRepository summaryRepository; @Autowired - private AiTitleClient aiTitleClient; + private TitleClient titleClient; @Autowired private ChatFacade chatFacade; @@ -186,7 +186,7 @@ void shouldCreateChatSuccessfullyWhenValidRequest() throws Exception { // given String firstChatContent = "AI 관련 최신 뉴스 알려줘"; CreateChatReq req = new CreateChatReq(firstChatContent); - String title = aiTitleClient.generateSummary(firstChatContent); + String title = titleClient.generateTitle(firstChatContent); // when & then mockMvc.perform(post(BASE_URL) @@ -248,6 +248,7 @@ void shouldReturnChatListWhenGetChats() throws Exception { .title("Chat 2") .build()); + // 생성 시간 차이를 두기 위해 잠시 대기 (정렬 테스트) Thread.sleep(10); Chat chat1 = chatRepository.save(Chat.builder()