From 7f3053436bd55b3ee684a2af2b59a11677328d4a Mon Sep 17 00:00:00 2001 From: jinia Date: Sun, 8 Feb 2026 10:31:09 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=9C=EB=84=A4=EB=9F=B4=20=EC=97=90?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TestContainerAbstractSkeleton.kt | 22 +- .../ai/domain/agent/AgentOrchestrator.kt | 54 ++- .../ai/domain/agent/GeneralChatAgent.kt | 53 +++ .../ai/domain/agent/IntentRouterAgent.kt | 40 +- .../co/jiniaslog/ai/domain/agent/RagAgent.kt | 23 +- .../kr/co/jiniaslog/ai/service/AiConfig.kt | 18 +- .../jiniaslog/ai/service/ChatMemoryService.kt | 76 ---- .../ai/service/ChatMemoryServiceTests.kt | 402 ------------------ 8 files changed, 171 insertions(+), 517 deletions(-) create mode 100644 service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/GeneralChatAgent.kt delete mode 100644 service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/service/ChatMemoryService.kt delete mode 100644 service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/service/ChatMemoryServiceTests.kt diff --git a/mains/monolith-main/src/test/kotlin/kr/co/jiniaslog/TestContainerAbstractSkeleton.kt b/mains/monolith-main/src/test/kotlin/kr/co/jiniaslog/TestContainerAbstractSkeleton.kt index 603c450d..fbff6518 100644 --- a/mains/monolith-main/src/test/kotlin/kr/co/jiniaslog/TestContainerAbstractSkeleton.kt +++ b/mains/monolith-main/src/test/kotlin/kr/co/jiniaslog/TestContainerAbstractSkeleton.kt @@ -6,6 +6,7 @@ import io.mockk.mockk import io.restassured.RestAssured import kr.co.jiniaslog.ai.domain.agent.AgentOrchestrator import kr.co.jiniaslog.ai.domain.agent.AgentResponse +import kr.co.jiniaslog.ai.domain.agent.GeneralChatAgent import kr.co.jiniaslog.ai.domain.agent.Intent import kr.co.jiniaslog.ai.domain.agent.IntentRouterAgent import kr.co.jiniaslog.ai.domain.agent.MemoManagementAgent @@ -107,11 +108,16 @@ class ContextWithTestContainerConfig { return mockk(relaxed = true) } + @Bean(name = ["generalChatClient"]) + fun generalChatClient(): ChatClient { + return mockk(relaxed = true) + } + @Bean @Primary fun intentRouterAgent(): IntentRouterAgent { return mockk { - every { classify(any()) } returns Intent.QUESTION + every { classify(any(), any()) } returns Intent.QUESTION } } @@ -119,7 +125,15 @@ class ContextWithTestContainerConfig { @Primary fun ragAgent(): RagAgent { return mockk { - every { chat(any(), any(), any(), any()) } returns "테스트 응답입니다." + every { chat(any(), any(), any()) } returns "테스트 응답입니다." + } + } + + @Bean + @Primary + fun generalChatAgent(): GeneralChatAgent { + return mockk { + every { chat(any(), any()) } returns "테스트 응답입니다." } } @@ -152,7 +166,9 @@ class ContextWithTestContainerConfig { fun agentOrchestrator( intentRouterAgent: IntentRouterAgent, ragAgent: RagAgent, - memoManagementAgent: MemoManagementAgent + generalChatAgent: GeneralChatAgent, + memoManagementAgent: MemoManagementAgent, + chatMemory: org.springframework.ai.chat.memory.ChatMemory ): AgentOrchestrator { return mockk { every { process(any(), any(), any()) } returns AgentResponse.ChatResponse("테스트 응답입니다.") diff --git a/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/AgentOrchestrator.kt b/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/AgentOrchestrator.kt index 53ba8f56..cdfead79 100644 --- a/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/AgentOrchestrator.kt +++ b/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/AgentOrchestrator.kt @@ -1,20 +1,26 @@ package kr.co.jiniaslog.ai.domain.agent +import org.springframework.ai.chat.memory.ChatMemory +import org.springframework.ai.chat.messages.AssistantMessage +import org.springframework.ai.chat.messages.UserMessage import org.springframework.stereotype.Service /** * Agent Orchestrator - Multi-Agent 아키텍처의 중앙 조정자 * * 토큰 최적화 전략: - * 1. Intent Router (경량 모델): 의도만 분류, 최소 토큰 사용 + * 1. Intent Router (경량 모델): 의도만 분류, 최소 토큰 사용 + 대화 히스토리로 맥락 유지 * 2. RAG Agent (풀 모델): RetrievalAugmentationAdvisor가 자동으로 VectorStore 검색 - * 3. Memo Management Agent (중간 모델): Tool Calling으로 메모/폴더 관리 + * 3. General Chat Agent (경량 모델): 일반 대화 처리, Memory만 사용 + * 4. Memo Management Agent (중간 모델): Tool Calling으로 메모/폴더 관리 */ @Service class AgentOrchestrator( private val intentRouter: IntentRouterAgent, private val ragAgent: RagAgent, - private val memoManagementAgent: MemoManagementAgent + private val generalChatAgent: GeneralChatAgent, + private val memoManagementAgent: MemoManagementAgent, + private val chatMemory: ChatMemory ) { /** * 메시지를 처리하고 적절한 Agent로 라우팅합니다. @@ -29,32 +35,29 @@ class AgentOrchestrator( sessionId: Long, authorId: Long ): AgentResponse { - // 1. Intent 분류 (경량 모델) - val intent = intentRouter.classify(message) + // 1. 최근 대화 히스토리를 가져와서 Intent Router에 전달 (맥락 기반 분류) + val conversationHistory = buildConversationHistory(sessionId.toString()) - // 2. Intent에 따라 적절한 Agent로 라우팅 + // 2. Intent 분류 (경량 모델 + 대화 맥락) + val intent = intentRouter.classify(message, conversationHistory) + + // 3. Intent에 따라 적절한 Agent로 라우팅 return when (intent) { Intent.QUESTION -> { - // RetrievalAugmentationAdvisor가 자동으로 VectorStore 검색 val response = ragAgent.chat( message = message, sessionId = sessionId, - authorId = authorId, - useRag = true + authorId = authorId ) AgentResponse.ChatResponse(response) } Intent.MEMO_MANAGEMENT -> { - // 메모/폴더 관리 Agent로 라우팅 (대화 히스토리 유지를 위해 sessionId 전달) memoManagementAgent.process(message, authorId, sessionId) } Intent.GENERAL_CHAT -> { - // 일반 대화는 RAG 비활성화 - val response = ragAgent.chat( + val response = generalChatAgent.chat( message = message, - sessionId = sessionId, - authorId = authorId, - useRag = false + sessionId = sessionId ) AgentResponse.ChatResponse(response) } @@ -67,4 +70,25 @@ class AgentOrchestrator( fun classifyIntent(message: String): Intent { return intentRouter.classify(message) } + + /** + * ChatMemory에서 최근 대화 히스토리를 가져와서 텍스트로 변환합니다. + * IntentRouter가 후속 메시지의 맥락을 파악할 수 있도록 합니다. + */ + private fun buildConversationHistory(conversationId: String): String { + val messages = try { + chatMemory.get(conversationId).takeLast(6) + } catch (e: Exception) { + emptyList() + } + if (messages.isEmpty()) return "" + + return messages.joinToString("\n") { msg -> + when (msg) { + is UserMessage -> "사용자: ${msg.text ?: ""}" + is AssistantMessage -> "어시스턴트: ${(msg.text ?: "").take(200)}" + else -> "" + } + }.trim() + } } diff --git a/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/GeneralChatAgent.kt b/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/GeneralChatAgent.kt new file mode 100644 index 00000000..21716f1b --- /dev/null +++ b/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/GeneralChatAgent.kt @@ -0,0 +1,53 @@ +package kr.co.jiniaslog.ai.domain.agent + +import org.springframework.ai.chat.client.ChatClient +import org.springframework.ai.chat.memory.ChatMemory +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +/** + * General Chat Agent - 일반 대화 처리 + * + * RAG 없이 대화 히스토리만 유지하는 경량 에이전트 + * GENERAL_CHAT 인텐트에서 사용됩니다. + */ +@Component +class GeneralChatAgent( + @Qualifier("generalChatClient") private val chatClient: ChatClient +) { + companion object { + private const val SYSTEM_PROMPT_TEMPLATE = """당신은 사용자의 개인 지식 관리 시스템 AI 어시스턴트입니다. +사용자와 자연스럽게 대화하며 도움을 제공합니다. +이전 대화 맥락을 참고하여 일관된 대화를 이어갑니다. +답변은 한국어로, 명확하고 간결하게 제공합니다. + +## 현재 시간 정보 +%s""" + } + + internal fun buildSystemPrompt(): String { + val now = LocalDateTime.now() + val timeInfo = """현재: ${now.format(DateTimeFormatter.ofPattern("yyyy년 MM월 dd일 (E) HH:mm"))} +오늘: ${now.format(DateTimeFormatter.ofPattern("MM월 dd일 (E)"))} +내일: ${now.plusDays(1).format(DateTimeFormatter.ofPattern("MM월 dd일 (E)"))} +모레: ${now.plusDays(2).format(DateTimeFormatter.ofPattern("MM월 dd일 (E)"))}""" + + return SYSTEM_PROMPT_TEMPLATE.format(timeInfo) + } + + fun chat( + message: String, + sessionId: Long + ): String { + return chatClient.prompt() + .system(buildSystemPrompt()) + .user(message) + .advisors { advisorSpec -> + advisorSpec.param(ChatMemory.CONVERSATION_ID, sessionId.toString()) + } + .call() + .content() ?: "응답을 생성할 수 없습니다." + } +} diff --git a/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/IntentRouterAgent.kt b/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/IntentRouterAgent.kt index 91a3f11f..5ae12136 100644 --- a/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/IntentRouterAgent.kt +++ b/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/IntentRouterAgent.kt @@ -7,6 +7,7 @@ import org.springframework.stereotype.Component /** * Intent Router Agent - 경량 모델을 사용하여 사용자 메시지의 의도를 분류합니다. * 토큰 최적화를 위해 최소한의 프롬프트와 단순한 응답만 사용합니다. + * 대화 히스토리를 참조하여 후속 메시지의 맥락을 파악합니다. */ @Component class IntentRouterAgent( @@ -36,12 +37,47 @@ GENERAL_CHAT (일반 대화): 메시지: %s +의도:""" + + private const val ROUTER_PROMPT_WITH_HISTORY = """사용자 메시지의 의도를 분류하세요. + +## 최근 대화 맥락 +%s + +## 분류 기준 + +QUESTION (질문): +- 질문 어미: ~냐, ~니, ~야?, ~어?, ~나?, ~까?, ~해?, ~뭐야, ~있어?, ~뭐있어 +- 예: "내일 뭐있냐", "회의 언제야?", "약속 뭐있어?", "다음주에 뭐해?" + +MEMO_MANAGEMENT (메모 관리): +- 진술 어미: ~있다, ~이다, ~해야함, ~한다, ~했다, ~할거다 +- 명령 어미: ~해줘, ~저장해, ~기록해, ~만들어줘, ~삭제해, ~수정해 +- 예: "5시에 약속있다", "내일 회의다", "우유 사야함", "메모 삭제해줘" + +GENERAL_CHAT (일반 대화): +- 인사, 감사, 잡담 +- 예: "안녕", "고마워", "뭐해?" + +## 핵심 구분법 +- "~냐", "~니", "~야?", "~어?" = 질문 → QUESTION +- "~있다", "~이다", "~한다" = 진술 → MEMO_MANAGEMENT +- 후속 요청("그래", "해봐", "알려줘", "평가해봐" 등)은 이전 대화의 의도를 이어갑니다 + +메시지: %s + 의도:""" } - fun classify(message: String): Intent { + fun classify(message: String, conversationHistory: String = ""): Intent { + val prompt = if (conversationHistory.isNotEmpty()) { + ROUTER_PROMPT_WITH_HISTORY.format(conversationHistory, message) + } else { + ROUTER_PROMPT.format(message) + } + val response = chatClient.prompt() - .user(ROUTER_PROMPT.format(message)) + .user(prompt) .call() .content() ?: "GENERAL_CHAT" diff --git a/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/RagAgent.kt b/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/RagAgent.kt index aae7e435..eb207f1e 100644 --- a/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/RagAgent.kt +++ b/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/RagAgent.kt @@ -16,13 +16,6 @@ import java.time.format.DateTimeFormatter * 2. VectorStoreDocumentRetriever: authorId 필터 적용한 문서 검색 * 3. ContextualQueryAugmenter: 검색 결과를 프롬프트에 자동 병합 * 4. MessageChatMemoryAdvisor: 대화 히스토리 자동 관리 - * - * 동작 플로우: - * 1. 사용자 질문 입력 - * 2. RewriteQueryTransformer가 질문 재작성 (검색 최적화) - * 3. VectorStoreDocumentRetriever가 authorId 필터로 검색 - * 4. ContextualQueryAugmenter가 컨텍스트 병합 - * 5. ChatModel이 최종 답변 생성 */ @Component class RagAgent( @@ -53,23 +46,17 @@ class RagAgent( fun chat( message: String, sessionId: Long, - authorId: Long, - useRag: Boolean = true + authorId: Long ): String { return chatClient.prompt() .system(buildSystemPrompt()) .user(message) .advisors { advisorSpec -> - // Chat Memory - conversationId 설정 advisorSpec.param(ChatMemory.CONVERSATION_ID, sessionId.toString()) - - // RAG - authorId 필터 적용 (useRag=true일 때만) - if (useRag) { - advisorSpec.param( - VectorStoreDocumentRetriever.FILTER_EXPRESSION, - "authorId == '$authorId'" - ) - } + advisorSpec.param( + VectorStoreDocumentRetriever.FILTER_EXPRESSION, + "authorId == '$authorId'" + ) } .call() .content() ?: "응답을 생성할 수 없습니다." diff --git a/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/service/AiConfig.kt b/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/service/AiConfig.kt index 287227e1..976a4f66 100644 --- a/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/service/AiConfig.kt +++ b/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/service/AiConfig.kt @@ -132,7 +132,23 @@ class AiConfig { } /** - * 3. Memo Agent용 ChatClient (Tool Calling 지원) + * 3. 일반 대화용 ChatClient (Memory만, RAG 없음) + * GENERAL_CHAT 인텐트에서 사용 - RAG 노이즈 없이 대화 히스토리만 유지 + */ + @Bean("generalChatClient") + fun generalChatClient( + @Qualifier("googleGenAiChatModel") chatModel: ChatModel, + chatMemory: ChatMemory + ): ChatClient { + return ChatClient.builder(chatModel) + .defaultAdvisors( + MessageChatMemoryAdvisor.builder(chatMemory).build() + ) + .build() + } + + /** + * 4. Memo Agent용 ChatClient (Tool Calling 지원) * Tools는 런타임에 주입됨 */ @Bean("memoChatClient") diff --git a/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/service/ChatMemoryService.kt b/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/service/ChatMemoryService.kt deleted file mode 100644 index 619ebfc6..00000000 --- a/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/service/ChatMemoryService.kt +++ /dev/null @@ -1,76 +0,0 @@ -package kr.co.jiniaslog.ai.service - -import org.springframework.ai.chat.messages.AssistantMessage -import org.springframework.ai.chat.messages.Message -import org.springframework.ai.chat.messages.UserMessage -import org.springframework.stereotype.Service -import java.util.concurrent.ConcurrentHashMap - -/** - * In-memory chat history management for Spring AI 1.1.2 - * - * Spring AI 1.1.2의 제한된 ChatMemory API를 대체하기 위한 커스텀 구현 - * - conversationId(sessionId)별로 메시지 히스토리를 메모리에 저장 - * - 최근 N개 메시지만 유지하여 컨텍스트 윈도우 관리 - */ -@Service -class ChatMemoryService { - - private val conversations = ConcurrentHashMap>() - - companion object { - private const val DEFAULT_MAX_MESSAGES = 20 // 최근 20개 메시지만 유지 - } - - /** - * Add user message to conversation history - */ - fun addUserMessage(conversationId: String, content: String) { - val messages = conversations.getOrPut(conversationId) { mutableListOf() } - messages.add(UserMessage(content)) - trimMessages(conversationId) - } - - /** - * Add assistant message to conversation history - */ - fun addAssistantMessage(conversationId: String, content: String) { - val messages = conversations.getOrPut(conversationId) { mutableListOf() } - messages.add(AssistantMessage(content)) - trimMessages(conversationId) - } - - /** - * Get conversation history for a given conversationId - */ - fun getMessages(conversationId: String): List { - return conversations[conversationId]?.toList() ?: emptyList() - } - - /** - * Clear conversation history for a given conversationId - */ - fun clear(conversationId: String) { - conversations.remove(conversationId) - } - - /** - * Clear all conversation histories - */ - fun clearAll() { - conversations.clear() - } - - /** - * Trim messages to keep only the most recent ones within max limit - */ - private fun trimMessages(conversationId: String, maxMessages: Int = DEFAULT_MAX_MESSAGES) { - val messages = conversations[conversationId] ?: return - if (messages.size > maxMessages) { - val toRemove = messages.size - maxMessages - repeat(toRemove) { - messages.removeAt(0) - } - } - } -} diff --git a/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/service/ChatMemoryServiceTests.kt b/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/service/ChatMemoryServiceTests.kt deleted file mode 100644 index d2e56dec..00000000 --- a/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/service/ChatMemoryServiceTests.kt +++ /dev/null @@ -1,402 +0,0 @@ -package kr.co.jiniaslog.ai.service - -import io.kotest.matchers.collections.shouldBeEmpty -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.shouldBe -import kr.co.jiniaslog.shared.SimpleUnitTestContext -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.springframework.ai.chat.messages.AssistantMessage -import org.springframework.ai.chat.messages.UserMessage - -class ChatMemoryServiceTests : SimpleUnitTestContext() { - - private lateinit var chatMemoryService: ChatMemoryService - - @BeforeEach - fun setUp() { - chatMemoryService = ChatMemoryService() - } - - @Nested - inner class `addUserMessage 테스트` { - @Test - fun `유저 메시지를 대화에 추가할 수 있다`() { - // given - val conversationId = "conv-1" - val content = "안녕하세요" - - // when - chatMemoryService.addUserMessage(conversationId, content) - - // then - val messages = chatMemoryService.getMessages(conversationId) - messages shouldHaveSize 1 - messages[0] shouldBe UserMessage(content) - } - - @Test - fun `새로운 conversationId는 새 리스트를 생성한다`() { - // given - val conversationId1 = "conv-1" - val conversationId2 = "conv-2" - - // when - chatMemoryService.addUserMessage(conversationId1, "첫 번째 대화") - chatMemoryService.addUserMessage(conversationId2, "두 번째 대화") - - // then - val messages1 = chatMemoryService.getMessages(conversationId1) - val messages2 = chatMemoryService.getMessages(conversationId2) - - messages1 shouldHaveSize 1 - messages2 shouldHaveSize 1 - (messages1[0] as UserMessage).text shouldBe "첫 번째 대화" - (messages2[0] as UserMessage).text shouldBe "두 번째 대화" - } - - @Test - fun `동일한 conversationId에 여러 메시지를 추가할 수 있다`() { - // given - val conversationId = "conv-1" - - // when - chatMemoryService.addUserMessage(conversationId, "메시지 1") - chatMemoryService.addUserMessage(conversationId, "메시지 2") - chatMemoryService.addUserMessage(conversationId, "메시지 3") - - // then - val messages = chatMemoryService.getMessages(conversationId) - messages shouldHaveSize 3 - (messages[0] as UserMessage).text shouldBe "메시지 1" - (messages[1] as UserMessage).text shouldBe "메시지 2" - (messages[2] as UserMessage).text shouldBe "메시지 3" - } - } - - @Nested - inner class `addAssistantMessage 테스트` { - @Test - fun `어시스턴트 메시지를 대화에 추가할 수 있다`() { - // given - val conversationId = "conv-1" - val content = "무엇을 도와드릴까요?" - - // when - chatMemoryService.addAssistantMessage(conversationId, content) - - // then - val messages = chatMemoryService.getMessages(conversationId) - messages shouldHaveSize 1 - messages[0] shouldBe AssistantMessage(content) - } - - @Test - fun `새로운 conversationId는 새 리스트를 생성한다`() { - // given - val conversationId1 = "conv-1" - val conversationId2 = "conv-2" - - // when - chatMemoryService.addAssistantMessage(conversationId1, "응답 1") - chatMemoryService.addAssistantMessage(conversationId2, "응답 2") - - // then - val messages1 = chatMemoryService.getMessages(conversationId1) - val messages2 = chatMemoryService.getMessages(conversationId2) - - messages1 shouldHaveSize 1 - messages2 shouldHaveSize 1 - (messages1[0] as AssistantMessage).text shouldBe "응답 1" - (messages2[0] as AssistantMessage).text shouldBe "응답 2" - } - - @Test - fun `유저 메시지와 어시스턴트 메시지를 섞어서 추가할 수 있다`() { - // given - val conversationId = "conv-1" - - // when - chatMemoryService.addUserMessage(conversationId, "질문 1") - chatMemoryService.addAssistantMessage(conversationId, "답변 1") - chatMemoryService.addUserMessage(conversationId, "질문 2") - chatMemoryService.addAssistantMessage(conversationId, "답변 2") - - // then - val messages = chatMemoryService.getMessages(conversationId) - messages shouldHaveSize 4 - messages[0] shouldBe UserMessage("질문 1") - messages[1] shouldBe AssistantMessage("답변 1") - messages[2] shouldBe UserMessage("질문 2") - messages[3] shouldBe AssistantMessage("답변 2") - } - } - - @Nested - inner class `getMessages 테스트` { - @Test - fun `존재하는 대화의 메시지를 반환한다`() { - // given - val conversationId = "conv-1" - chatMemoryService.addUserMessage(conversationId, "안녕") - chatMemoryService.addAssistantMessage(conversationId, "반갑습니다") - - // when - val messages = chatMemoryService.getMessages(conversationId) - - // then - messages shouldHaveSize 2 - (messages[0] as UserMessage).text shouldBe "안녕" - (messages[1] as AssistantMessage).text shouldBe "반갑습니다" - } - - @Test - fun `존재하지 않는 대화의 경우 빈 리스트를 반환한다`() { - // given - val nonExistentConversationId = "non-existent" - - // when - val messages = chatMemoryService.getMessages(nonExistentConversationId) - - // then - messages.shouldBeEmpty() - } - - @Test - fun `반환된 리스트는 원본과 독립적이다`() { - // given - val conversationId = "conv-1" - chatMemoryService.addUserMessage(conversationId, "메시지 1") - - // when - val messages1 = chatMemoryService.getMessages(conversationId) - chatMemoryService.addUserMessage(conversationId, "메시지 2") - val messages2 = chatMemoryService.getMessages(conversationId) - - // then - messages1 shouldHaveSize 1 - messages2 shouldHaveSize 2 - } - } - - @Nested - inner class `clear 테스트` { - @Test - fun `특정 대화의 히스토리를 제거한다`() { - // given - val conversationId = "conv-1" - chatMemoryService.addUserMessage(conversationId, "메시지") - chatMemoryService.addAssistantMessage(conversationId, "응답") - - // when - chatMemoryService.clear(conversationId) - - // then - val messages = chatMemoryService.getMessages(conversationId) - messages.shouldBeEmpty() - } - - @Test - fun `다른 대화의 히스토리는 영향을 받지 않는다`() { - // given - val conversationId1 = "conv-1" - val conversationId2 = "conv-2" - chatMemoryService.addUserMessage(conversationId1, "대화 1") - chatMemoryService.addUserMessage(conversationId2, "대화 2") - - // when - chatMemoryService.clear(conversationId1) - - // then - val messages1 = chatMemoryService.getMessages(conversationId1) - val messages2 = chatMemoryService.getMessages(conversationId2) - - messages1.shouldBeEmpty() - messages2 shouldHaveSize 1 - } - - @Test - fun `존재하지 않는 대화를 clear해도 예외가 발생하지 않는다`() { - // given - val nonExistentConversationId = "non-existent" - - // when & then (예외가 발생하지 않아야 함) - chatMemoryService.clear(nonExistentConversationId) - } - } - - @Nested - inner class `clearAll 테스트` { - @Test - fun `모든 대화 히스토리를 제거한다`() { - // given - chatMemoryService.addUserMessage("conv-1", "메시지 1") - chatMemoryService.addUserMessage("conv-2", "메시지 2") - chatMemoryService.addUserMessage("conv-3", "메시지 3") - - // when - chatMemoryService.clearAll() - - // then - chatMemoryService.getMessages("conv-1").shouldBeEmpty() - chatMemoryService.getMessages("conv-2").shouldBeEmpty() - chatMemoryService.getMessages("conv-3").shouldBeEmpty() - } - - @Test - fun `clearAll 후에도 새 메시지를 추가할 수 있다`() { - // given - chatMemoryService.addUserMessage("conv-1", "이전 메시지") - chatMemoryService.clearAll() - - // when - chatMemoryService.addUserMessage("conv-1", "새 메시지") - - // then - val messages = chatMemoryService.getMessages("conv-1") - messages shouldHaveSize 1 - (messages[0] as UserMessage).text shouldBe "새 메시지" - } - } - - @Nested - inner class `trimMessages 테스트` { - @Test - fun `최대 개수를 초과하면 가장 오래된 메시지부터 제거된다`() { - // given - val conversationId = "conv-1" - - // 21개의 메시지 추가 (DEFAULT_MAX_MESSAGES = 20) - repeat(21) { index -> - chatMemoryService.addUserMessage(conversationId, "메시지 ${index + 1}") - } - - // when - val messages = chatMemoryService.getMessages(conversationId) - - // then - messages shouldHaveSize 20 - (messages[0] as UserMessage).text shouldBe "메시지 2" // 첫 번째 메시지가 제거됨 - (messages[19] as UserMessage).text shouldBe "메시지 21" - } - - @Test - fun `최대 개수 미만일 때는 메시지가 제거되지 않는다`() { - // given - val conversationId = "conv-1" - - // 10개의 메시지 추가 - repeat(10) { index -> - chatMemoryService.addUserMessage(conversationId, "메시지 ${index + 1}") - } - - // when - val messages = chatMemoryService.getMessages(conversationId) - - // then - messages shouldHaveSize 10 - (messages[0] as UserMessage).text shouldBe "메시지 1" - (messages[9] as UserMessage).text shouldBe "메시지 10" - } - - @Test - fun `최대 개수와 정확히 같을 때는 메시지가 제거되지 않는다`() { - // given - val conversationId = "conv-1" - - // 정확히 20개의 메시지 추가 - repeat(20) { index -> - chatMemoryService.addUserMessage(conversationId, "메시지 ${index + 1}") - } - - // when - val messages = chatMemoryService.getMessages(conversationId) - - // then - messages shouldHaveSize 20 - (messages[0] as UserMessage).text shouldBe "메시지 1" - (messages[19] as UserMessage).text shouldBe "메시지 20" - } - - @Test - fun `존재하지 않는 대화에 대해서는 아무 일도 일어나지 않는다`() { - // given - val nonExistentConversationId = "non-existent" - - // when (addUserMessage가 내부적으로 trimMessages를 호출하지 않는 시나리오) - // trimMessages는 private이므로 간접적으로 테스트 - - // then - val messages = chatMemoryService.getMessages(nonExistentConversationId) - messages.shouldBeEmpty() - } - - @Test - fun `여러 번 최대 개수를 초과해도 항상 20개만 유지된다`() { - // given - val conversationId = "conv-1" - - // 25개의 메시지를 추가 - repeat(25) { index -> - chatMemoryService.addUserMessage(conversationId, "메시지 ${index + 1}") - } - - // when - val messages = chatMemoryService.getMessages(conversationId) - - // then - messages shouldHaveSize 20 - (messages[0] as UserMessage).text shouldBe "메시지 6" // 처음 5개가 제거됨 - (messages[19] as UserMessage).text shouldBe "메시지 25" - } - - @Test - fun `대화 추가 시마다 trim이 자동으로 실행된다`() { - // given - val conversationId = "conv-1" - - // 먼저 19개 추가 - repeat(19) { index -> - chatMemoryService.addUserMessage(conversationId, "메시지 ${index + 1}") - } - - // when - chatMemoryService.addUserMessage(conversationId, "메시지 20") // 20개째 - chatMemoryService.addUserMessage(conversationId, "메시지 21") // 21개째, trim 발생 - - // then - val messages = chatMemoryService.getMessages(conversationId) - messages shouldHaveSize 20 - (messages[0] as UserMessage).text shouldBe "메시지 2" // 첫 번째 메시지 제거됨 - (messages[19] as UserMessage).text shouldBe "메시지 21" - } - } - - @Nested - inner class `동시성 테스트` { - @Test - fun `서로 다른 대화는 독립적으로 관리된다`() { - // given - val conversationId1 = "conv-1" - val conversationId2 = "conv-2" - val conversationId3 = "conv-3" - - // when - chatMemoryService.addUserMessage(conversationId1, "대화1-메시지1") - chatMemoryService.addUserMessage(conversationId2, "대화2-메시지1") - chatMemoryService.addUserMessage(conversationId1, "대화1-메시지2") - chatMemoryService.addAssistantMessage(conversationId2, "대화2-응답1") - chatMemoryService.addUserMessage(conversationId3, "대화3-메시지1") - - // then - val messages1 = chatMemoryService.getMessages(conversationId1) - val messages2 = chatMemoryService.getMessages(conversationId2) - val messages3 = chatMemoryService.getMessages(conversationId3) - - messages1 shouldHaveSize 2 - messages2 shouldHaveSize 2 - messages3 shouldHaveSize 1 - } - } -}