diff --git a/buildSrc/src/main/kotlin/plugin/kover-features.gradle.kts b/buildSrc/src/main/kotlin/plugin/kover-features.gradle.kts index 508849d4..65c8c7a3 100644 --- a/buildSrc/src/main/kotlin/plugin/kover-features.gradle.kts +++ b/buildSrc/src/main/kotlin/plugin/kover-features.gradle.kts @@ -24,6 +24,7 @@ koverReport { "*LlmServiceImpl*", "*MemoQueryServiceAdapter*", "*MemoCommandServiceAdapter*", + "*MemoQueryClientAdapter*", ) // DTO 및 데이터 클래스 (equals, hashCode, toString 등 자동 생성) classes("*Dto", "*Dto\$*", "*Request", "*Request\$*", "*Response", "*Response\$*") 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 fbff6518..fa564ead 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.CompoundIntentHandler import kr.co.jiniaslog.ai.domain.agent.GeneralChatAgent import kr.co.jiniaslog.ai.domain.agent.Intent import kr.co.jiniaslog.ai.domain.agent.IntentRouterAgent @@ -80,6 +81,7 @@ class ContextWithTestContainerConfig { content = "테스트 메모 내용입니다." ) every { getAllMemosByAuthorId(any()) } returns emptyList() + every { searchByKeyword(any(), any(), any()) } returns emptyList() } } @@ -117,7 +119,8 @@ class ContextWithTestContainerConfig { @Primary fun intentRouterAgent(): IntentRouterAgent { return mockk { - every { classify(any(), any()) } returns Intent.QUESTION + every { classify(any(), any()) } returns Intent.KNOWLEDGE_QUERY + every { classifyWithSubIntents(any(), any()) } returns Pair(Intent.KNOWLEDGE_QUERY, emptyList()) } } @@ -161,6 +164,12 @@ class ContextWithTestContainerConfig { } } + @Bean + @Primary + fun compoundIntentHandler(): CompoundIntentHandler { + return mockk(relaxed = true) + } + @Bean @Primary fun agentOrchestrator( @@ -168,11 +177,12 @@ class ContextWithTestContainerConfig { ragAgent: RagAgent, generalChatAgent: GeneralChatAgent, memoManagementAgent: MemoManagementAgent, + compoundIntentHandler: CompoundIntentHandler, chatMemory: org.springframework.ai.chat.memory.ChatMemory ): AgentOrchestrator { return mockk { every { process(any(), any(), any()) } returns AgentResponse.ChatResponse("테스트 응답입니다.") - every { classifyIntent(any()) } returns Intent.QUESTION + every { classifyIntent(any()) } returns Intent.KNOWLEDGE_QUERY } } } diff --git a/service/ai-second-brain/adapter/ai-second-brain-out-memo/src/main/kotlin/kr/co/jiniaslog/ai/adapter/outbound/memo/MemoQueryClientAdapter.kt b/service/ai-second-brain/adapter/ai-second-brain-out-memo/src/main/kotlin/kr/co/jiniaslog/ai/adapter/outbound/memo/MemoQueryClientAdapter.kt index ab0611c2..f9afb750 100644 --- a/service/ai-second-brain/adapter/ai-second-brain-out-memo/src/main/kotlin/kr/co/jiniaslog/ai/adapter/outbound/memo/MemoQueryClientAdapter.kt +++ b/service/ai-second-brain/adapter/ai-second-brain-out-memo/src/main/kotlin/kr/co/jiniaslog/ai/adapter/outbound/memo/MemoQueryClientAdapter.kt @@ -31,4 +31,13 @@ class MemoQueryClientAdapter( ) } } + + override fun searchByKeyword(authorId: Long, keyword: String, limit: Int): List { + return getAllMemosByAuthorId(authorId) + .filter { memo -> + memo.title.contains(keyword, ignoreCase = true) || + memo.content.contains(keyword, ignoreCase = true) + } + .take(limit) + } } 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 cdfead79..48ecd710 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 @@ -20,6 +20,7 @@ class AgentOrchestrator( private val ragAgent: RagAgent, private val generalChatAgent: GeneralChatAgent, private val memoManagementAgent: MemoManagementAgent, + private val compoundIntentHandler: CompoundIntentHandler, private val chatMemory: ChatMemory ) { /** @@ -38,12 +39,12 @@ class AgentOrchestrator( // 1. 최근 대화 히스토리를 가져와서 Intent Router에 전달 (맥락 기반 분류) val conversationHistory = buildConversationHistory(sessionId.toString()) - // 2. Intent 분류 (경량 모델 + 대화 맥락) - val intent = intentRouter.classify(message, conversationHistory) + // 2. Intent 분류 (경량 모델 + 대화 맥락) - 복합 의도도 감지 + val (intent, subIntents) = intentRouter.classifyWithSubIntents(message, conversationHistory) // 3. Intent에 따라 적절한 Agent로 라우팅 return when (intent) { - Intent.QUESTION -> { + Intent.KNOWLEDGE_QUERY -> { val response = ragAgent.chat( message = message, sessionId = sessionId, @@ -51,9 +52,14 @@ class AgentOrchestrator( ) AgentResponse.ChatResponse(response) } - Intent.MEMO_MANAGEMENT -> { + Intent.MEMO_WRITE, + Intent.MEMO_ORGANIZE, + Intent.MEMO_SEARCH -> { memoManagementAgent.process(message, authorId, sessionId) } + Intent.COMPOUND -> { + compoundIntentHandler.process(message, subIntents, sessionId, authorId) + } Intent.GENERAL_CHAT -> { val response = generalChatAgent.chat( message = message, diff --git a/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/CompoundIntentHandler.kt b/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/CompoundIntentHandler.kt new file mode 100644 index 00000000..8ae60c72 --- /dev/null +++ b/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/CompoundIntentHandler.kt @@ -0,0 +1,107 @@ +package kr.co.jiniaslog.ai.domain.agent + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component + +/** + * Compound Intent Handler - 복합 의도를 순차적으로 처리합니다. + * + * 하나의 메시지에 여러 의도가 포함된 경우: + * 예: "메모하고 관련 자료 찾아줘" → MEMO_WRITE + KNOWLEDGE_QUERY + * + * 각 서브 의도를 순서대로 처리하고, 결과를 통합하여 반환합니다. + */ +@Component +class CompoundIntentHandler( + private val ragAgent: RagAgent, + private val generalChatAgent: GeneralChatAgent, + private val memoManagementAgent: MemoManagementAgent +) { + private val log = LoggerFactory.getLogger(javaClass) + + /** + * 복합 의도를 순차적으로 처리합니다. + * + * @param message 원본 사용자 메시지 + * @param subIntents 처리할 의도 목록 + * @param sessionId 세션 ID + * @param authorId 작성자 ID + * @return CompoundResponse (각 서브 응답을 포함한 통합 응답) + */ + fun process( + message: String, + subIntents: List, + sessionId: Long, + authorId: Long + ): AgentResponse { + if (subIntents.isEmpty()) { + return AgentResponse.ChatResponse("요청을 처리할 수 없습니다.") + } + + val results = mutableListOf() + + for (intent in subIntents) { + try { + val response = routeToAgent(message, intent, sessionId, authorId) + results.add(response) + } catch (e: Exception) { + log.error("복합 의도 처리 중 오류 발생 (intent=$intent): ${e.message}", e) + results.add(AgentResponse.Error("${intent.name} 처리 중 오류: ${e.message}")) + } + } + + return mergeResponses(results) + } + + private fun routeToAgent( + message: String, + intent: Intent, + sessionId: Long, + authorId: Long + ): AgentResponse { + return when (intent) { + Intent.KNOWLEDGE_QUERY -> { + val response = ragAgent.chat(message, sessionId, authorId) + AgentResponse.ChatResponse(response) + } + Intent.MEMO_WRITE, Intent.MEMO_ORGANIZE, Intent.MEMO_SEARCH -> { + memoManagementAgent.process(message, authorId, sessionId) + } + Intent.GENERAL_CHAT -> { + val response = generalChatAgent.chat(message, sessionId) + AgentResponse.ChatResponse(response) + } + Intent.COMPOUND -> { + // COMPOUND가 재귀적으로 들어오면 안 되지만 방어적 처리 + AgentResponse.ChatResponse("요청을 처리했습니다.") + } + } + } + + /** + * 여러 AgentResponse를 하나의 통합 응답으로 합칩니다. + */ + private fun mergeResponses(responses: List): AgentResponse { + if (responses.size == 1) return responses.first() + + val messageParts = mutableListOf() + + for (response in responses) { + when (response) { + is AgentResponse.ChatResponse -> messageParts.add(response.content) + is AgentResponse.MemoCreated -> messageParts.add(response.message) + is AgentResponse.MemoUpdated -> messageParts.add(response.message) + is AgentResponse.MemoMoved -> messageParts.add(response.message) + is AgentResponse.FolderCreated -> messageParts.add(response.message) + is AgentResponse.FolderRenamed -> messageParts.add(response.message) + is AgentResponse.FolderMoved -> messageParts.add(response.message) + is AgentResponse.Deleted -> messageParts.add(response.message) + is AgentResponse.MemoList -> messageParts.add(response.message) + is AgentResponse.FolderList -> messageParts.add(response.message) + is AgentResponse.Error -> messageParts.add("[오류] ${response.message}") + } + } + + return AgentResponse.ChatResponse(messageParts.joinToString("\n\n---\n\n")) + } +} diff --git a/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/Intent.kt b/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/Intent.kt index 7b68fbf9..69b5441c 100644 --- a/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/Intent.kt +++ b/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/agent/Intent.kt @@ -1,7 +1,10 @@ package kr.co.jiniaslog.ai.domain.agent enum class Intent { - QUESTION, // 질문 - RAG 필요 - MEMO_MANAGEMENT, // 메모/폴더 관리 요청 (생성, 수정, 삭제, 이동, 조회) - GENERAL_CHAT // 일반 대화 + KNOWLEDGE_QUERY, // 지식 검색/질문 (기존 QUESTION 대체) + MEMO_WRITE, // 메모 생성/수정 (기존 MEMO_MANAGEMENT에서 분리) + MEMO_ORGANIZE, // 폴더 관리/이동/정리 + MEMO_SEARCH, // 메모 검색/조회 + GENERAL_CHAT, // 일반 대화 (유지) + COMPOUND // 복합 의도 (여러 의도가 섞인 요청) } 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 5ae12136..aee4b971 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 @@ -4,11 +4,6 @@ import org.springframework.ai.chat.client.ChatClient import org.springframework.beans.factory.annotation.Qualifier import org.springframework.stereotype.Component -/** - * Intent Router Agent - 경량 모델을 사용하여 사용자 메시지의 의도를 분류합니다. - * 토큰 최적화를 위해 최소한의 프롬프트와 단순한 응답만 사용합니다. - * 대화 히스토리를 참조하여 후속 메시지의 맥락을 파악합니다. - */ @Component class IntentRouterAgent( @Qualifier("lightweightChatClient") private val chatClient: ChatClient @@ -18,22 +13,71 @@ class IntentRouterAgent( ## 분류 기준 -QUESTION (질문): -- 질문 어미: ~냐, ~니, ~야?, ~어?, ~나?, ~까?, ~해?, ~뭐야, ~있어?, ~뭐있어 -- 예: "내일 뭐있냐", "회의 언제야?", "약속 뭐있어?", "다음주에 뭐해?" - -MEMO_MANAGEMENT (메모 관리): +KNOWLEDGE_QUERY (지식 검색/질문): +- 이미 저장된 지식/메모에 대해 질문하거나 검색하는 경우 +- 질문 어미: ~냐, ~니, ~야?, ~어?, ~나?, ~까?, ~뭐야, ~있어?, ~뭐있어, ~알려줘, ~뭐였지, ~어떻게 됐더라 +- 예시: + "내일 뭐있냐" → KNOWLEDGE_QUERY + "회의 언제야?" → KNOWLEDGE_QUERY + "약속 뭐있어?" → KNOWLEDGE_QUERY + "지난주에 뭐 메모했지?" → KNOWLEDGE_QUERY + "프로젝트 관련 내용 알려줘" → KNOWLEDGE_QUERY + "JVM 관련 내용 찾아봐" → KNOWLEDGE_QUERY + +MEMO_WRITE (메모 생성/수정): +- 새로운 정보를 기록하거나 기존 메모를 수정하는 경우 - 진술 어미: ~있다, ~이다, ~해야함, ~한다, ~했다, ~할거다 -- 명령 어미: ~해줘, ~저장해, ~기록해, ~만들어줘, ~삭제해, ~수정해 -- 예: "5시에 약속있다", "내일 회의다", "우유 사야함", "메모 삭제해줘" +- 명령 어미: ~기록해, ~저장해, ~적어줘, ~메모해, ~노트해, ~수정해, ~고쳐줘, ~변경해 +- 예시: + "5시에 약속있다" → MEMO_WRITE + "내일 회의다" → MEMO_WRITE + "우유 사야함" → MEMO_WRITE + "메모 수정해줘" → MEMO_WRITE + "제목 바꿔줘" → MEMO_WRITE + "오늘 배운 내용 정리해서 저장해" → MEMO_WRITE + +MEMO_ORGANIZE (폴더/메모 정리): +- 폴더 생성/삭제/이름변경, 메모를 폴더로 이동, 정리하는 경우 +- 예시: + "새 폴더 만들어줘" → MEMO_ORGANIZE + "메모 폴더로 정리해" → MEMO_ORGANIZE + "폴더 이름 바꿔줘" → MEMO_ORGANIZE + "폴더 삭제해줘" → MEMO_ORGANIZE + "메모 다른 폴더로 옮겨줘" → MEMO_ORGANIZE + "메모 삭제해줘" → MEMO_ORGANIZE + +MEMO_SEARCH (메모 목록/검색): +- 메모나 폴더의 목록을 조회하거나 특정 메모를 찾는 경우 +- 예시: + "메모 목록 보여줘" → MEMO_SEARCH + "폴더 목록 보여줘" → MEMO_SEARCH + "어떤 메모들 있어?" → MEMO_SEARCH + "전체 메모 보여줘" → MEMO_SEARCH GENERAL_CHAT (일반 대화): -- 인사, 감사, 잡담 -- 예: "안녕", "고마워", "뭐해?" +- 인사, 감사, 잡담, 시스템과 관련 없는 대화 +- 예시: + "안녕" → GENERAL_CHAT + "고마워" → GENERAL_CHAT + "뭐해?" → GENERAL_CHAT + "오늘 날씨 어때?" → GENERAL_CHAT + +COMPOUND (복합 의도): +- 하나의 메시지에 2개 이상의 의도가 포함된 경우 +- 예시: + "메모하고 관련 자료 찾아줘" → COMPOUND[MEMO_WRITE,KNOWLEDGE_QUERY] + "폴더 만들고 메모 옮겨줘" → COMPOUND[MEMO_ORGANIZE,MEMO_ORGANIZE] + "이거 저장하고 비슷한 메모 있나 찾아봐" → COMPOUND[MEMO_WRITE,KNOWLEDGE_QUERY] ## 핵심 구분법 -- "~냐", "~니", "~야?", "~어?" = 질문 → QUESTION -- "~있다", "~이다", "~한다" = 진술 → MEMO_MANAGEMENT +- 정보를 물어보는 것 = KNOWLEDGE_QUERY +- 새로운 정보를 기록/수정하는 것 = MEMO_WRITE +- 폴더/메모를 정리/이동/삭제하는 것 = MEMO_ORGANIZE +- 목록을 조회하는 것 = MEMO_SEARCH +- 인사/잡담 = GENERAL_CHAT +- 두 가지 이상이 섞인 것 = COMPOUND[의도1,의도2] + +반드시 위 분류 중 하나만 응답하세요. COMPOUND인 경우 COMPOUND[의도1,의도2] 형식으로 응답하세요. 메시지: %s @@ -46,30 +90,69 @@ GENERAL_CHAT (일반 대화): ## 분류 기준 -QUESTION (질문): -- 질문 어미: ~냐, ~니, ~야?, ~어?, ~나?, ~까?, ~해?, ~뭐야, ~있어?, ~뭐있어 -- 예: "내일 뭐있냐", "회의 언제야?", "약속 뭐있어?", "다음주에 뭐해?" - -MEMO_MANAGEMENT (메모 관리): +KNOWLEDGE_QUERY (지식 검색/질문): +- 이미 저장된 지식/메모에 대해 질문하거나 검색하는 경우 +- 질문 어미: ~냐, ~니, ~야?, ~어?, ~나?, ~까?, ~뭐야, ~있어?, ~뭐있어, ~알려줘, ~뭐였지, ~어떻게 됐더라 +- 예시: + "내일 뭐있냐" → KNOWLEDGE_QUERY + "회의 언제야?" → KNOWLEDGE_QUERY + "프로젝트 관련 내용 알려줘" → KNOWLEDGE_QUERY + "JVM 관련 내용 찾아봐" → KNOWLEDGE_QUERY + +MEMO_WRITE (메모 생성/수정): +- 새로운 정보를 기록하거나 기존 메모를 수정하는 경우 - 진술 어미: ~있다, ~이다, ~해야함, ~한다, ~했다, ~할거다 -- 명령 어미: ~해줘, ~저장해, ~기록해, ~만들어줘, ~삭제해, ~수정해 -- 예: "5시에 약속있다", "내일 회의다", "우유 사야함", "메모 삭제해줘" +- 명령 어미: ~기록해, ~저장해, ~적어줘, ~메모해, ~노트해, ~수정해, ~고쳐줘, ~변경해 +- 예시: + "5시에 약속있다" → MEMO_WRITE + "내일 회의다" → MEMO_WRITE + "오늘 배운 내용 정리해서 저장해" → MEMO_WRITE + +MEMO_ORGANIZE (폴더/메모 정리): +- 폴더 생성/삭제/이름변경, 메모를 폴더로 이동, 정리하는 경우 +- 예시: + "새 폴더 만들어줘" → MEMO_ORGANIZE + "메모 정리해줘" → MEMO_ORGANIZE + "메모 삭제해줘" → MEMO_ORGANIZE + +MEMO_SEARCH (메모 목록/검색): +- 메모나 폴더의 목록을 조회하거나 특정 메모를 찾는 경우 +- 예시: + "메모 목록 보여줘" → MEMO_SEARCH + "어떤 메모들 있어?" → MEMO_SEARCH GENERAL_CHAT (일반 대화): -- 인사, 감사, 잡담 -- 예: "안녕", "고마워", "뭐해?" +- 인사, 감사, 잡담, 시스템과 관련 없는 대화 +- 예시: + "안녕" → GENERAL_CHAT + "고마워" → GENERAL_CHAT + +COMPOUND (복합 의도): +- 하나의 메시지에 2개 이상의 의도가 포함된 경우 +- 예시: + "메모하고 관련 자료 찾아줘" → COMPOUND[MEMO_WRITE,KNOWLEDGE_QUERY] ## 핵심 구분법 -- "~냐", "~니", "~야?", "~어?" = 질문 → QUESTION -- "~있다", "~이다", "~한다" = 진술 → MEMO_MANAGEMENT +- 정보를 물어보는 것 = KNOWLEDGE_QUERY +- 새로운 정보를 기록/수정하는 것 = MEMO_WRITE +- 폴더/메모를 정리/이동/삭제하는 것 = MEMO_ORGANIZE +- 목록을 조회하는 것 = MEMO_SEARCH +- 인사/잡담 = GENERAL_CHAT +- 두 가지 이상이 섞인 것 = COMPOUND[의도1,의도2] - 후속 요청("그래", "해봐", "알려줘", "평가해봐" 등)은 이전 대화의 의도를 이어갑니다 +반드시 위 분류 중 하나만 응답하세요. COMPOUND인 경우 COMPOUND[의도1,의도2] 형식으로 응답하세요. + 메시지: %s 의도:""" } - fun classify(message: String, conversationHistory: String = ""): Intent { + /** + * 메시지의 의도를 분류하고, 복합 의도인 경우 서브 의도 목록도 반환합니다. + * @return Pair> - (메인 의도, 복합 의도일 경우 서브 의도 목록) + */ + fun classifyWithSubIntents(message: String, conversationHistory: String = ""): Pair> { val prompt = if (conversationHistory.isNotEmpty()) { ROUTER_PROMPT_WITH_HISTORY.format(conversationHistory, message) } else { @@ -81,11 +164,53 @@ GENERAL_CHAT (일반 대화): .call() .content() ?: "GENERAL_CHAT" + return parseIntentResponse(response) + } + + /** + * 단일 Intent만 반환하는 기존 호환 메서드 + */ + fun classify(message: String, conversationHistory: String = ""): Intent { + return classifyWithSubIntents(message, conversationHistory).first + } + + /** + * LLM 응답을 파싱하여 Intent와 서브 Intent 목록을 반환합니다. + */ + internal fun parseIntentResponse(response: String): Pair> { + val trimmed = response.trim() + + // COMPOUND[INTENT1,INTENT2] 패턴 매칭 + val compoundPattern = """COMPOUND\[([^\]]+)]""".toRegex(RegexOption.IGNORE_CASE) + val compoundMatch = compoundPattern.find(trimmed) + if (compoundMatch != null) { + val subIntentStr = compoundMatch.groupValues[1] + val subIntents = subIntentStr.split(",").mapNotNull { parseSimpleIntent(it.trim()) } + return if (subIntents.size >= 2) { + Pair(Intent.COMPOUND, subIntents) + } else if (subIntents.size == 1) { + Pair(subIntents.first(), emptyList()) + } else { + Pair(Intent.GENERAL_CHAT, emptyList()) + } + } + + // 단순 Intent 매칭 + val intent = parseSimpleIntent(trimmed) ?: Intent.GENERAL_CHAT + return Pair(intent, emptyList()) + } + + private fun parseSimpleIntent(response: String): Intent? { return when { - response.contains("MEMO_MANAGEMENT", ignoreCase = true) -> Intent.MEMO_MANAGEMENT - response.contains("MEMO_CREATION", ignoreCase = true) -> Intent.MEMO_MANAGEMENT // 하위 호환 - response.contains("QUESTION", ignoreCase = true) -> Intent.QUESTION - else -> Intent.GENERAL_CHAT + response.contains("MEMO_WRITE", ignoreCase = true) -> Intent.MEMO_WRITE + response.contains("MEMO_ORGANIZE", ignoreCase = true) -> Intent.MEMO_ORGANIZE + response.contains("MEMO_SEARCH", ignoreCase = true) -> Intent.MEMO_SEARCH + response.contains("MEMO_MANAGEMENT", ignoreCase = true) -> Intent.MEMO_WRITE // 하위 호환 + response.contains("MEMO_CREATION", ignoreCase = true) -> Intent.MEMO_WRITE // 하위 호환 + response.contains("KNOWLEDGE_QUERY", ignoreCase = true) -> Intent.KNOWLEDGE_QUERY + response.contains("QUESTION", ignoreCase = true) -> Intent.KNOWLEDGE_QUERY // 하위 호환 + response.contains("GENERAL_CHAT", ignoreCase = true) -> Intent.GENERAL_CHAT + else -> null } } } 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 eb207f1e..c8f464cd 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 @@ -1,5 +1,6 @@ package kr.co.jiniaslog.ai.domain.agent +import kr.co.jiniaslog.ai.outbound.MemoQueryClient import org.springframework.ai.chat.client.ChatClient import org.springframework.ai.chat.memory.ChatMemory import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever @@ -16,40 +17,50 @@ import java.time.format.DateTimeFormatter * 2. VectorStoreDocumentRetriever: authorId 필터 적용한 문서 검색 * 3. ContextualQueryAugmenter: 검색 결과를 프롬프트에 자동 병합 * 4. MessageChatMemoryAdvisor: 대화 히스토리 자동 관리 + * 5. L3 키워드 검색 폴백: 임베딩 검색 보완 */ @Component class RagAgent( - @Qualifier("ragChatClient") private val chatClient: ChatClient + @Qualifier("ragChatClient") private val chatClient: ChatClient, + private val memoQueryClient: MemoQueryClient, ) { companion object { private const val SYSTEM_PROMPT_TEMPLATE = """당신은 사용자의 개인 지식 관리 시스템 AI 어시스턴트입니다. 사용자의 메모를 참조하여 정확하고 도움이 되는 답변을 제공합니다. -참조한 메모가 있다면 언급하고, 관련 메모가 없으면 솔직히 모른다고 답변하세요. -답변은 한국어로, 명확하고 간결하게 제공합니다. + +## 중요 규칙 +1. 참조한 메모가 있다면 반드시 출처를 표시하세요: [메모: 제목] +2. 관련 메모가 없으면 솔직히 "관련 메모를 찾지 못했습니다"라고 답변하세요. +3. 답변은 한국어로, 명확하고 간결하게 제공합니다. ## 현재 시간 정보 %s -사용자가 "내일", "모레", "다음주" 등 상대적 시간으로 질문하면, 위 정보를 참고하여 해당 날짜의 일정을 찾아주세요.""" +사용자가 "내일", "모레", "다음주" 등 상대적 시간으로 질문하면, 위 정보를 참고하여 해당 날짜의 일정을 찾아주세요. + +## 추가 참고 자료 (키워드 검색 결과) +%s""" } - internal fun buildSystemPrompt(): String { + internal fun buildSystemPrompt(additionalContext: String = ""): 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) + return SYSTEM_PROMPT_TEMPLATE.format(timeInfo, additionalContext) } fun chat( message: String, sessionId: Long, - authorId: Long + authorId: Long, ): String { + val keywordContext = buildKeywordContext(message, authorId) + return chatClient.prompt() - .system(buildSystemPrompt()) + .system(buildSystemPrompt(keywordContext)) .user(message) .advisors { advisorSpec -> advisorSpec.param(ChatMemory.CONVERSATION_ID, sessionId.toString()) @@ -61,4 +72,43 @@ class RagAgent( .call() .content() ?: "응답을 생성할 수 없습니다." } + + /** + * L3 키워드 검색으로 추가 컨텍스트를 구성합니다. + * 임베딩 검색(L2)과 병렬로 키워드 검색(L3)을 수행하여 + * 임베딩으로 놓칠 수 있는 관련 메모를 보완합니다. + */ + internal fun buildKeywordContext(message: String, authorId: Long): String { + val keywords = extractKeywords(message) + if (keywords.isEmpty()) return "없음" + + val memos = keywords.flatMap { keyword -> + memoQueryClient.searchByKeyword(authorId, keyword, limit = 3) + }.distinctBy { it.id }.take(5) + + if (memos.isEmpty()) return "없음" + + return memos.joinToString("\n\n") { memo -> + "[메모: ${memo.title}] (ID: ${memo.id})\n${memo.content.take(500)}" + } + } + + /** + * 메시지에서 검색에 유용한 키워드를 추출합니다. + * 조사, 어미 등을 제거하고 핵심 단어만 추출합니다. + */ + internal fun extractKeywords(message: String): List { + val stopWords = setOf( + "뭐", "어떤", "무슨", "언제", "어디", "왜", "어떻게", + "있냐", "있어", "있나", "없냐", "없어", "뭐야", "뭐있어", + "해줘", "알려줘", "보여줘", "찾아줘", "해봐", + "그거", "이거", "저거", "거기", "여기", + "안녕", "고마워", "감사" + ) + + return message.split(Regex("[\\s,?!.]+")) + .map { it.trim() } + .filter { it.length >= 2 && it !in stopWords } + .take(3) + } } diff --git a/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/chat/PersistentChatMemory.kt b/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/chat/PersistentChatMemory.kt new file mode 100644 index 00000000..6605f274 --- /dev/null +++ b/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/domain/chat/PersistentChatMemory.kt @@ -0,0 +1,68 @@ +package kr.co.jiniaslog.ai.domain.chat + +import org.springframework.ai.chat.memory.ChatMemory +import org.springframework.ai.chat.messages.AssistantMessage +import org.springframework.ai.chat.messages.Message +import org.springframework.ai.chat.messages.SystemMessage +import org.springframework.ai.chat.messages.UserMessage + +/** + * DB 기반 ChatMemory 구현 + * + * AiUseCasesFacade가 ChatMessage를 DB에 저장하므로, + * 이 구현체는 DB에서 읽기만 수행합니다. + * add()는 no-op으로 중복 저장을 방지합니다. + * + * 서버 재시작 후에도 대화 히스토리가 유지됩니다. + */ +class PersistentChatMemory( + private val chatMessageRepository: ChatMessageRepository, + private val maxMessages: Int = 20, +) : ChatMemory { + + /** + * No-op: AiUseCasesFacade가 이미 DB에 메시지를 저장합니다. + * MessageChatMemoryAdvisor가 호출하지만 중복 저장을 방지합니다. + */ + override fun add(conversationId: String, messages: List) { + // No-op - messages are persisted by AiUseCasesFacade + } + + /** + * DB에서 대화 히스토리를 로드하여 Spring AI Message로 변환합니다. + * 최근 maxMessages개만 반환합니다. + */ + override fun get(conversationId: String): List { + val sessionId = try { + ChatSessionId(conversationId.toLong()) + } catch (e: NumberFormatException) { + return emptyList() + } + + val chatMessages = chatMessageRepository.findAllBySessionId(sessionId) + + return chatMessages + .takeLast(maxMessages) + .map { chatMessage -> toSpringAiMessage(chatMessage) } + } + + /** + * 대화 히스토리를 DB에서 삭제합니다. + */ + override fun clear(conversationId: String) { + val sessionId = try { + ChatSessionId(conversationId.toLong()) + } catch (e: NumberFormatException) { + return + } + chatMessageRepository.deleteAllBySessionId(sessionId) + } + + private fun toSpringAiMessage(chatMessage: ChatMessage): Message { + return when (chatMessage.role) { + MessageRole.USER -> UserMessage(chatMessage.content) + MessageRole.ASSISTANT -> AssistantMessage(chatMessage.content) + MessageRole.SYSTEM -> SystemMessage(chatMessage.content) + } + } +} diff --git a/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/outbound/LlmService.kt b/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/outbound/LlmService.kt deleted file mode 100644 index 4698276c..00000000 --- a/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/outbound/LlmService.kt +++ /dev/null @@ -1,31 +0,0 @@ -package kr.co.jiniaslog.ai.outbound - -import kr.co.jiniaslog.ai.domain.chat.ChatMessage - -data class ChatContext( - val systemPrompt: String, - val conversationHistory: List, - val relevantDocuments: List, -) - -/** - * @deprecated AgentOrchestrator 사용 권장. - * Multi-Agent 아키텍처로 마이그레이션되었습니다. - * @see kr.co.jiniaslog.ai.domain.agent.AgentOrchestrator - */ -@Deprecated("Use AgentOrchestrator instead", ReplaceWith("AgentOrchestrator")) -interface LlmService { - fun chat(userMessage: String, context: ChatContext): String - fun classifyIntent(message: String): IntentType -} - -/** - * @deprecated Intent enum 사용 권장 - * @see kr.co.jiniaslog.ai.domain.agent.Intent - */ -@Deprecated("Use kr.co.jiniaslog.ai.agent.Intent instead") -enum class IntentType { - QUESTION, - MEMO_CREATION, - GENERAL_CHAT -} diff --git a/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/outbound/MemoQueryClient.kt b/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/outbound/MemoQueryClient.kt index 782e1511..c187b3c6 100644 --- a/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/outbound/MemoQueryClient.kt +++ b/service/ai-second-brain/ai-second-brain-core/src/main/kotlin/kr/co/jiniaslog/ai/outbound/MemoQueryClient.kt @@ -10,4 +10,5 @@ data class MemoInfo( interface MemoQueryClient { fun getMemoById(memoId: Long): MemoInfo? fun getAllMemosByAuthorId(authorId: Long): List + fun searchByKeyword(authorId: Long, keyword: String, limit: Int = 5): List } 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 976a4f66..7bcb9f54 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 @@ -1,9 +1,10 @@ package kr.co.jiniaslog.ai.service +import kr.co.jiniaslog.ai.domain.chat.ChatMessageRepository +import kr.co.jiniaslog.ai.domain.chat.PersistentChatMemory import org.springframework.ai.chat.client.ChatClient import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor import org.springframework.ai.chat.memory.ChatMemory -import org.springframework.ai.chat.memory.MessageWindowChatMemory import org.springframework.ai.chat.model.ChatModel import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor import org.springframework.ai.rag.generation.augmentation.ContextualQueryAugmenter @@ -36,13 +37,14 @@ class AiConfig { /** * ChatMemory 빈 - 대화 히스토리 관리 - * InMemoryChatMemory를 사용하여 최근 N개 메시지 유지 + * DB 기반 PersistentChatMemory를 사용하여 서버 재시작 후에도 대화 히스토리 유지 */ @Bean - fun chatMemory(): ChatMemory { - return MessageWindowChatMemory.builder() - .maxMessages(20) - .build() + fun chatMemory(chatMessageRepository: ChatMessageRepository): ChatMemory { + return PersistentChatMemory( + chatMessageRepository = chatMessageRepository, + maxMessages = 20, + ) } /** @@ -158,14 +160,4 @@ class AiConfig { return ChatClient.builder(chatModel) .build() } - - /** - * 기존 LlmServiceImpl 호환을 위한 기본 ChatClient - * @deprecated AgentOrchestrator 사용 권장 - */ - @Bean("chatClient") - fun chatClient(@Qualifier("googleGenAiChatModel") chatModel: ChatModel): ChatClient { - return ChatClient.builder(chatModel) - .build() - } } diff --git a/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/agent/AgentOrchestratorTests.kt b/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/agent/AgentOrchestratorTests.kt new file mode 100644 index 00000000..056a6347 --- /dev/null +++ b/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/agent/AgentOrchestratorTests.kt @@ -0,0 +1,213 @@ +package kr.co.jiniaslog.ai.domain.agent + +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kr.co.jiniaslog.shared.SimpleUnitTestContext +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.ai.chat.memory.ChatMemory +import org.springframework.ai.chat.messages.AssistantMessage +import org.springframework.ai.chat.messages.UserMessage + +class AgentOrchestratorTests : SimpleUnitTestContext() { + + private val intentRouter = mockk() + private val ragAgent = mockk() + private val generalChatAgent = mockk() + private val memoManagementAgent = mockk() + private val compoundIntentHandler = mockk() + private val chatMemory = mockk() + + private val orchestrator = AgentOrchestrator( + intentRouter = intentRouter, + ragAgent = ragAgent, + generalChatAgent = generalChatAgent, + memoManagementAgent = memoManagementAgent, + compoundIntentHandler = compoundIntentHandler, + chatMemory = chatMemory + ) + + @Nested + inner class `KNOWLEDGE_QUERY 라우팅 테스트` { + @Test + fun `KNOWLEDGE_QUERY 의도이면 ragAgent에 위임하고 ChatResponse를 반환한다`() { + // given + every { chatMemory.get(any()) } returns emptyList() + every { intentRouter.classifyWithSubIntents(any(), any()) } returns Pair(Intent.KNOWLEDGE_QUERY, emptyList()) + every { ragAgent.chat(any(), any(), any()) } returns "RAG 응답입니다." + + // when + val result = orchestrator.process("내일 뭐있냐", 1L, 100L) + + // then + result.shouldBeInstanceOf() + (result as AgentResponse.ChatResponse).content shouldBe "RAG 응답입니다." + verify(exactly = 1) { ragAgent.chat("내일 뭐있냐", 1L, 100L) } + } + } + + @Nested + inner class `MEMO_WRITE 라우팅 테스트` { + @Test + fun `MEMO_WRITE 의도이면 memoManagementAgent에 위임한다`() { + // given + every { chatMemory.get(any()) } returns emptyList() + every { intentRouter.classifyWithSubIntents(any(), any()) } returns Pair(Intent.MEMO_WRITE, emptyList()) + every { memoManagementAgent.process(any(), any(), any()) } returns AgentResponse.MemoCreated(1L, "제목", "생성됨") + + // when + val result = orchestrator.process("5시에 약속있다", 1L, 100L) + + // then + result.shouldBeInstanceOf() + verify(exactly = 1) { memoManagementAgent.process("5시에 약속있다", 100L, 1L) } + } + } + + @Nested + inner class `MEMO_ORGANIZE 라우팅 테스트` { + @Test + fun `MEMO_ORGANIZE 의도이면 memoManagementAgent에 위임한다`() { + // given + every { chatMemory.get(any()) } returns emptyList() + every { intentRouter.classifyWithSubIntents(any(), any()) } returns Pair(Intent.MEMO_ORGANIZE, emptyList()) + every { memoManagementAgent.process(any(), any(), any()) } returns AgentResponse.FolderCreated(10L, "업무", "생성됨") + + // when + val result = orchestrator.process("새 폴더 만들어줘", 1L, 100L) + + // then + result.shouldBeInstanceOf() + verify(exactly = 1) { memoManagementAgent.process("새 폴더 만들어줘", 100L, 1L) } + } + } + + @Nested + inner class `MEMO_SEARCH 라우팅 테스트` { + @Test + fun `MEMO_SEARCH 의도이면 memoManagementAgent에 위임한다`() { + // given + every { chatMemory.get(any()) } returns emptyList() + every { intentRouter.classifyWithSubIntents(any(), any()) } returns Pair(Intent.MEMO_SEARCH, emptyList()) + every { memoManagementAgent.process(any(), any(), any()) } returns AgentResponse.MemoList(emptyList(), "메모 없음") + + // when + val result = orchestrator.process("메모 목록 보여줘", 1L, 100L) + + // then + result.shouldBeInstanceOf() + verify(exactly = 1) { memoManagementAgent.process("메모 목록 보여줘", 100L, 1L) } + } + } + + @Nested + inner class `COMPOUND 라우팅 테스트` { + @Test + fun `COMPOUND 의도이면 compoundIntentHandler에 위임한다`() { + // given + val subIntents = listOf(Intent.MEMO_WRITE, Intent.KNOWLEDGE_QUERY) + every { chatMemory.get(any()) } returns emptyList() + every { intentRouter.classifyWithSubIntents(any(), any()) } returns Pair(Intent.COMPOUND, subIntents) + every { compoundIntentHandler.process(any(), any(), any(), any()) } returns AgentResponse.ChatResponse("복합 응답") + + // when + val result = orchestrator.process("메모하고 관련 자료 찾아줘", 1L, 100L) + + // then + result.shouldBeInstanceOf() + (result as AgentResponse.ChatResponse).content shouldBe "복합 응답" + verify(exactly = 1) { compoundIntentHandler.process("메모하고 관련 자료 찾아줘", subIntents, 1L, 100L) } + } + } + + @Nested + inner class `GENERAL_CHAT 라우팅 테스트` { + @Test + fun `GENERAL_CHAT 의도이면 generalChatAgent에 위임하고 ChatResponse를 반환한다`() { + // given + every { chatMemory.get(any()) } returns emptyList() + every { intentRouter.classifyWithSubIntents(any(), any()) } returns Pair(Intent.GENERAL_CHAT, emptyList()) + every { generalChatAgent.chat(any(), any()) } returns "안녕하세요!" + + // when + val result = orchestrator.process("안녕", 1L, 100L) + + // then + result.shouldBeInstanceOf() + (result as AgentResponse.ChatResponse).content shouldBe "안녕하세요!" + verify(exactly = 1) { generalChatAgent.chat("안녕", 1L) } + } + } + + @Nested + inner class `classifyIntent 테스트` { + @Test + fun `classifyIntent는 intentRouter의 classify 결과를 반환한다`() { + // given + every { intentRouter.classify(any()) } returns Intent.KNOWLEDGE_QUERY + + // when + val result = orchestrator.classifyIntent("뭐있어?") + + // then + result shouldBe Intent.KNOWLEDGE_QUERY + verify(exactly = 1) { intentRouter.classify("뭐있어?") } + } + } + + @Nested + inner class `buildConversationHistory 테스트` { + @Test + fun `chatMemory에 메시지가 있으면 대화 히스토리를 구성하여 intentRouter에 전달한다`() { + // given + val userMsg = UserMessage("안녕") + val assistantMsg = AssistantMessage("안녕하세요!") + every { chatMemory.get("1") } returns listOf(userMsg, assistantMsg) + every { intentRouter.classifyWithSubIntents(any(), any()) } returns Pair(Intent.GENERAL_CHAT, emptyList()) + every { generalChatAgent.chat(any(), any()) } returns "응답" + + // when + orchestrator.process("뭐해?", 1L, 100L) + + // then - conversationHistory 가 비어있지 않은 상태로 intentRouter 호출 + verify { + intentRouter.classifyWithSubIntents( + "뭐해?", + match { it.contains("사용자: 안녕") && it.contains("어시스턴트: 안녕하세요!") } + ) + } + } + + @Test + fun `chatMemory에서 예외가 발생하면 빈 히스토리로 처리한다`() { + // given + every { chatMemory.get(any()) } throws RuntimeException("메모리 오류") + every { intentRouter.classifyWithSubIntents(any(), any()) } returns Pair(Intent.GENERAL_CHAT, emptyList()) + every { generalChatAgent.chat(any(), any()) } returns "응답" + + // when + val result = orchestrator.process("안녕", 1L, 100L) + + // then - 예외 발생해도 정상 처리됨 + result.shouldBeInstanceOf() + verify { intentRouter.classifyWithSubIntents("안녕", "") } + } + + @Test + fun `chatMemory가 빈 리스트를 반환하면 빈 히스토리로 처리한다`() { + // given + every { chatMemory.get(any()) } returns emptyList() + every { intentRouter.classifyWithSubIntents(any(), any()) } returns Pair(Intent.GENERAL_CHAT, emptyList()) + every { generalChatAgent.chat(any(), any()) } returns "응답" + + // when + orchestrator.process("안녕", 1L, 100L) + + // then + verify { intentRouter.classifyWithSubIntents("안녕", "") } + } + } +} diff --git a/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/agent/AgentResponseTests.kt b/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/agent/AgentResponseTests.kt index ab7dda2e..5b198a5b 100644 --- a/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/agent/AgentResponseTests.kt +++ b/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/agent/AgentResponseTests.kt @@ -412,17 +412,31 @@ class IntentTests : SimpleUnitTestContext() { @Nested inner class `Intent enum 테스트` { @Test - fun `QUESTION 값이 존재한다`() { - val intent = Intent.QUESTION + fun `KNOWLEDGE_QUERY 값이 존재한다`() { + val intent = Intent.KNOWLEDGE_QUERY - intent shouldBe Intent.QUESTION + intent shouldBe Intent.KNOWLEDGE_QUERY } @Test - fun `MEMO_MANAGEMENT 값이 존재한다`() { - val intent = Intent.MEMO_MANAGEMENT + fun `MEMO_WRITE 값이 존재한다`() { + val intent = Intent.MEMO_WRITE - intent shouldBe Intent.MEMO_MANAGEMENT + intent shouldBe Intent.MEMO_WRITE + } + + @Test + fun `MEMO_ORGANIZE 값이 존재한다`() { + val intent = Intent.MEMO_ORGANIZE + + intent shouldBe Intent.MEMO_ORGANIZE + } + + @Test + fun `MEMO_SEARCH 값이 존재한다`() { + val intent = Intent.MEMO_SEARCH + + intent shouldBe Intent.MEMO_SEARCH } @Test @@ -433,10 +447,17 @@ class IntentTests : SimpleUnitTestContext() { } @Test - fun `모든 값이 3개 존재한다`() { + fun `COMPOUND 값이 존재한다`() { + val intent = Intent.COMPOUND + + intent shouldBe Intent.COMPOUND + } + + @Test + fun `모든 값이 6개 존재한다`() { val values = Intent.entries - values shouldHaveSize 3 + values shouldHaveSize 6 } } } diff --git a/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/agent/CompoundIntentHandlerTests.kt b/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/agent/CompoundIntentHandlerTests.kt new file mode 100644 index 00000000..1b6939c9 --- /dev/null +++ b/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/agent/CompoundIntentHandlerTests.kt @@ -0,0 +1,162 @@ +package kr.co.jiniaslog.ai.domain.agent + +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kr.co.jiniaslog.shared.SimpleUnitTestContext +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class CompoundIntentHandlerTests : SimpleUnitTestContext() { + + private val ragAgent = mockk() + private val generalChatAgent = mockk() + private val memoManagementAgent = mockk() + + private val handler = CompoundIntentHandler(ragAgent, generalChatAgent, memoManagementAgent) + + @Nested + inner class `복합 의도 처리 테스트` { + @Test + fun `MEMO_WRITE와 KNOWLEDGE_QUERY를 순차적으로 처리할 수 있다`() { + // given + every { memoManagementAgent.process(any(), any(), any()) } returns + AgentResponse.MemoCreated(1L, "테스트 메모", "메모가 생성되었습니다.") + every { ragAgent.chat(any(), any(), any()) } returns "관련 자료입니다." + + // when + val result = handler.process( + message = "메모하고 관련 자료 찾아줘", + subIntents = listOf(Intent.MEMO_WRITE, Intent.KNOWLEDGE_QUERY), + sessionId = 1L, + authorId = 1L + ) + + // then + result.shouldBeInstanceOf() + val content = (result as AgentResponse.ChatResponse).content + content shouldContain "메모가 생성되었습니다." + content shouldContain "관련 자료입니다." + } + + @Test + fun `단일 서브 의도는 해당 에이전트 응답을 그대로 반환한다`() { + // given + every { ragAgent.chat(any(), any(), any()) } returns "검색 결과입니다." + + // when + val result = handler.process( + message = "찾아줘", + subIntents = listOf(Intent.KNOWLEDGE_QUERY), + sessionId = 1L, + authorId = 1L + ) + + // then + result.shouldBeInstanceOf() + (result as AgentResponse.ChatResponse).content shouldBe "검색 결과입니다." + } + + @Test + fun `빈 서브 의도 목록은 기본 응답을 반환한다`() { + // when + val result = handler.process( + message = "테스트", + subIntents = emptyList(), + sessionId = 1L, + authorId = 1L + ) + + // then + result.shouldBeInstanceOf() + (result as AgentResponse.ChatResponse).content shouldBe "요청을 처리할 수 없습니다." + } + + @Test + fun `처리 중 오류가 발생하면 Error 응답을 포함한다`() { + // given + every { memoManagementAgent.process(any(), any(), any()) } throws RuntimeException("DB 오류") + every { ragAgent.chat(any(), any(), any()) } returns "검색 결과" + + // when + val result = handler.process( + message = "메모하고 찾아줘", + subIntents = listOf(Intent.MEMO_WRITE, Intent.KNOWLEDGE_QUERY), + sessionId = 1L, + authorId = 1L + ) + + // then + result.shouldBeInstanceOf() + val content = (result as AgentResponse.ChatResponse).content + content shouldContain "오류" + content shouldContain "검색 결과" + } + } + + @Nested + inner class `에이전트 라우팅 테스트` { + @Test + fun `KNOWLEDGE_QUERY는 RagAgent로 라우팅된다`() { + // given + every { ragAgent.chat(any(), any(), any()) } returns "결과" + + // when + handler.process("테스트", listOf(Intent.KNOWLEDGE_QUERY), 1L, 1L) + + // then + verify { ragAgent.chat(any(), any(), any()) } + } + + @Test + fun `MEMO_WRITE는 MemoManagementAgent로 라우팅된다`() { + // given + every { memoManagementAgent.process(any(), any(), any()) } returns AgentResponse.ChatResponse("ok") + + // when + handler.process("테스트", listOf(Intent.MEMO_WRITE), 1L, 1L) + + // then + verify { memoManagementAgent.process(any(), any(), any()) } + } + + @Test + fun `MEMO_ORGANIZE는 MemoManagementAgent로 라우팅된다`() { + // given + every { memoManagementAgent.process(any(), any(), any()) } returns AgentResponse.ChatResponse("ok") + + // when + handler.process("테스트", listOf(Intent.MEMO_ORGANIZE), 1L, 1L) + + // then + verify { memoManagementAgent.process(any(), any(), any()) } + } + + @Test + fun `MEMO_SEARCH는 MemoManagementAgent로 라우팅된다`() { + // given + every { memoManagementAgent.process(any(), any(), any()) } returns AgentResponse.ChatResponse("ok") + + // when + handler.process("테스트", listOf(Intent.MEMO_SEARCH), 1L, 1L) + + // then + verify { memoManagementAgent.process(any(), any(), any()) } + } + + @Test + fun `GENERAL_CHAT는 GeneralChatAgent로 라우팅된다`() { + // given + every { generalChatAgent.chat(any(), any()) } returns "안녕하세요" + + // when + handler.process("테스트", listOf(Intent.GENERAL_CHAT), 1L, 1L) + + // then + verify { generalChatAgent.chat(any(), any()) } + } + } +} diff --git a/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/agent/GeneralChatAgentTests.kt b/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/agent/GeneralChatAgentTests.kt new file mode 100644 index 00000000..b2aee114 --- /dev/null +++ b/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/agent/GeneralChatAgentTests.kt @@ -0,0 +1,86 @@ +package kr.co.jiniaslog.ai.domain.agent + +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotBeEmpty +import io.mockk.mockk +import kr.co.jiniaslog.shared.SimpleUnitTestContext +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.ai.chat.client.ChatClient +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +class GeneralChatAgentTests : SimpleUnitTestContext() { + + private val chatClient = mockk(relaxed = true) + private val agent = GeneralChatAgent(chatClient) + + @Nested + inner class `buildSystemPrompt 테스트` { + @Test + fun `시스템 프롬프트는 비어있지 않다`() { + // when + val prompt = agent.buildSystemPrompt() + + // then + prompt.shouldNotBeEmpty() + } + + @Test + fun `시스템 프롬프트에 현재 날짜 정보가 포함된다`() { + // when + val prompt = agent.buildSystemPrompt() + + // then + prompt shouldContain "현재" + } + + @Test + fun `시스템 프롬프트에 오늘 날짜 정보가 포함된다`() { + // when + val prompt = agent.buildSystemPrompt() + + // then + prompt shouldContain "오늘" + } + + @Test + fun `시스템 프롬프트에 내일 날짜 정보가 포함된다`() { + // when + val prompt = agent.buildSystemPrompt() + + // then + prompt shouldContain "내일" + } + + @Test + fun `시스템 프롬프트에 모레 날짜 정보가 포함된다`() { + // when + val prompt = agent.buildSystemPrompt() + + // then + prompt shouldContain "모레" + } + + @Test + fun `시스템 프롬프트에 기본 안내 문구가 포함된다`() { + // when + val prompt = agent.buildSystemPrompt() + + // then + prompt shouldContain "AI 어시스턴트" + } + + @Test + fun `시스템 프롬프트에 현재 연도가 포함된다`() { + // given + val currentYear = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy")) + + // when + val prompt = agent.buildSystemPrompt() + + // then + prompt shouldContain currentYear + } + } +} diff --git a/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/agent/IntentRouterAgentTests.kt b/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/agent/IntentRouterAgentTests.kt index 55e13310..a7bc5e68 100644 --- a/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/agent/IntentRouterAgentTests.kt +++ b/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/agent/IntentRouterAgentTests.kt @@ -1,5 +1,6 @@ package kr.co.jiniaslog.ai.domain.agent +import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk @@ -24,222 +25,364 @@ class IntentRouterAgentTests : SimpleUnitTestContext() { } @Nested - inner class `MEMO_MANAGEMENT 의도 분류 테스트` { + inner class `MEMO_WRITE 의도 분류 테스트` { @Test - fun `응답에 MEMO_MANAGEMENT가 포함되면 MEMO_MANAGEMENT로 분류된다`() { - // given - val chatClient = createMockChatClient("MEMO_MANAGEMENT") + fun `응답에 MEMO_WRITE가 포함되면 MEMO_WRITE로 분류된다`() { + val chatClient = createMockChatClient("MEMO_WRITE") val intentRouter = IntentRouterAgent(chatClient) - // when val intent = intentRouter.classify("5시에 약속있다") - // then - intent shouldBe Intent.MEMO_MANAGEMENT + intent shouldBe Intent.MEMO_WRITE } @Test - fun `응답에 memo_management가 포함되면 MEMO_MANAGEMENT로 분류된다 (대소문자 무시)`() { - // given - val chatClient = createMockChatClient("memo_management") + fun `응답에 memo_write가 포함되면 MEMO_WRITE로 분류된다 (대소문자 무시)`() { + val chatClient = createMockChatClient("memo_write") val intentRouter = IntentRouterAgent(chatClient) - // when val intent = intentRouter.classify("내일 회의다") - // then - intent shouldBe Intent.MEMO_MANAGEMENT + intent shouldBe Intent.MEMO_WRITE } @Test - fun `응답에 Memo_Management가 포함되면 MEMO_MANAGEMENT로 분류된다 (대소문자 무시)`() { - // given - val chatClient = createMockChatClient("Memo_Management") + fun `응답에 MEMO_MANAGEMENT가 포함되면 MEMO_WRITE로 분류된다 (하위 호환)`() { + val chatClient = createMockChatClient("MEMO_MANAGEMENT") val intentRouter = IntentRouterAgent(chatClient) - // when - val intent = intentRouter.classify("우유 사야함") + val intent = intentRouter.classify("메모 저장해줘") - // then - intent shouldBe Intent.MEMO_MANAGEMENT + intent shouldBe Intent.MEMO_WRITE } @Test - fun `응답에 MEMO_CREATION이 포함되면 MEMO_MANAGEMENT로 분류된다 (하위 호환)`() { - // given + fun `응답에 MEMO_CREATION이 포함되면 MEMO_WRITE로 분류된다 (하위 호환)`() { val chatClient = createMockChatClient("MEMO_CREATION") val intentRouter = IntentRouterAgent(chatClient) - // when - val intent = intentRouter.classify("메모 저장해줘") + val intent = intentRouter.classify("메모 기록해줘") - // then - intent shouldBe Intent.MEMO_MANAGEMENT + intent shouldBe Intent.MEMO_WRITE } @Test - fun `응답에 memo_creation이 포함되면 MEMO_MANAGEMENT로 분류된다 (하위 호환, 대소문자 무시)`() { - // given + fun `응답에 memo_creation이 포함되면 MEMO_WRITE로 분류된다 (하위 호환, 대소문자 무시)`() { val chatClient = createMockChatClient("memo_creation") val intentRouter = IntentRouterAgent(chatClient) - // when - val intent = intentRouter.classify("메모 기록해줘") + val intent = intentRouter.classify("노트 적어줘") - // then - intent shouldBe Intent.MEMO_MANAGEMENT + intent shouldBe Intent.MEMO_WRITE } + } + @Nested + inner class `MEMO_ORGANIZE 의도 분류 테스트` { @Test - fun `응답에 여러 키워드가 있을 때 MEMO_MANAGEMENT가 우선된다`() { - // given - val chatClient = createMockChatClient("MEMO_MANAGEMENT and QUESTION") + fun `응답에 MEMO_ORGANIZE가 포함되면 MEMO_ORGANIZE로 분류된다`() { + val chatClient = createMockChatClient("MEMO_ORGANIZE") val intentRouter = IntentRouterAgent(chatClient) - // when - val intent = intentRouter.classify("테스트 메시지") + val intent = intentRouter.classify("새 폴더 만들어줘") - // then - intent shouldBe Intent.MEMO_MANAGEMENT + intent shouldBe Intent.MEMO_ORGANIZE + } + + @Test + fun `응답에 memo_organize가 포함되면 MEMO_ORGANIZE로 분류된다 (대소문자 무시)`() { + val chatClient = createMockChatClient("memo_organize") + val intentRouter = IntentRouterAgent(chatClient) + + val intent = intentRouter.classify("메모 폴더로 정리해") + + intent shouldBe Intent.MEMO_ORGANIZE } } @Nested - inner class `QUESTION 의도 분류 테스트` { + inner class `MEMO_SEARCH 의도 분류 테스트` { @Test - fun `응답에 QUESTION이 포함되면 QUESTION으로 분류된다`() { - // given - val chatClient = createMockChatClient("QUESTION") + fun `응답에 MEMO_SEARCH가 포함되면 MEMO_SEARCH로 분류된다`() { + val chatClient = createMockChatClient("MEMO_SEARCH") + val intentRouter = IntentRouterAgent(chatClient) + + val intent = intentRouter.classify("메모 목록 보여줘") + + intent shouldBe Intent.MEMO_SEARCH + } + + @Test + fun `응답에 memo_search가 포함되면 MEMO_SEARCH로 분류된다 (대소문자 무시)`() { + val chatClient = createMockChatClient("memo_search") + val intentRouter = IntentRouterAgent(chatClient) + + val intent = intentRouter.classify("어떤 메모들 있어?") + + intent shouldBe Intent.MEMO_SEARCH + } + } + + @Nested + inner class `KNOWLEDGE_QUERY 의도 분류 테스트` { + @Test + fun `응답에 KNOWLEDGE_QUERY가 포함되면 KNOWLEDGE_QUERY로 분류된다`() { + val chatClient = createMockChatClient("KNOWLEDGE_QUERY") val intentRouter = IntentRouterAgent(chatClient) - // when val intent = intentRouter.classify("내일 뭐있냐") - // then - intent shouldBe Intent.QUESTION + intent shouldBe Intent.KNOWLEDGE_QUERY } @Test - fun `응답에 question이 포함되면 QUESTION으로 분류된다 (대소문자 무시)`() { - // given - val chatClient = createMockChatClient("question") + fun `응답에 knowledge_query가 포함되면 KNOWLEDGE_QUERY로 분류된다 (대소문자 무시)`() { + val chatClient = createMockChatClient("knowledge_query") val intentRouter = IntentRouterAgent(chatClient) - // when val intent = intentRouter.classify("회의 언제야?") - // then - intent shouldBe Intent.QUESTION + intent shouldBe Intent.KNOWLEDGE_QUERY } @Test - fun `응답에 Question이 포함되면 QUESTION으로 분류된다 (대소문자 무시)`() { - // given - val chatClient = createMockChatClient("Question") + fun `응답에 QUESTION이 포함되면 KNOWLEDGE_QUERY로 분류된다 (하위 호환)`() { + val chatClient = createMockChatClient("QUESTION") val intentRouter = IntentRouterAgent(chatClient) - // when val intent = intentRouter.classify("약속 뭐있어?") - // then - intent shouldBe Intent.QUESTION + intent shouldBe Intent.KNOWLEDGE_QUERY + } + + @Test + fun `응답에 question이 포함되면 KNOWLEDGE_QUERY로 분류된다 (하위 호환, 대소문자 무시)`() { + val chatClient = createMockChatClient("question") + val intentRouter = IntentRouterAgent(chatClient) + + val intent = intentRouter.classify("프로젝트 내용 알려줘") + + intent shouldBe Intent.KNOWLEDGE_QUERY } } @Nested inner class `GENERAL_CHAT 의도 분류 테스트` { @Test - fun `매칭되지 않는 응답은 GENERAL_CHAT으로 분류된다`() { - // given + fun `응답에 GENERAL_CHAT이 포함되면 GENERAL_CHAT으로 분류된다`() { val chatClient = createMockChatClient("GENERAL_CHAT") val intentRouter = IntentRouterAgent(chatClient) - // when val intent = intentRouter.classify("안녕") - // then intent shouldBe Intent.GENERAL_CHAT } @Test fun `알 수 없는 응답은 GENERAL_CHAT으로 분류된다`() { - // given val chatClient = createMockChatClient("UNKNOWN") val intentRouter = IntentRouterAgent(chatClient) - // when val intent = intentRouter.classify("고마워") - // then intent shouldBe Intent.GENERAL_CHAT } @Test fun `빈 응답은 GENERAL_CHAT으로 분류된다`() { - // given val chatClient = createMockChatClient("") val intentRouter = IntentRouterAgent(chatClient) - // when val intent = intentRouter.classify("뭐해?") - // then intent shouldBe Intent.GENERAL_CHAT } @Test fun `null 응답은 GENERAL_CHAT으로 분류된다`() { - // given val chatClient = createMockChatClient(null) val intentRouter = IntentRouterAgent(chatClient) - // when val intent = intentRouter.classify("테스트") - // then intent shouldBe Intent.GENERAL_CHAT } @Test fun `공백만 있는 응답은 GENERAL_CHAT으로 분류된다`() { - // given val chatClient = createMockChatClient(" ") val intentRouter = IntentRouterAgent(chatClient) - // when val intent = intentRouter.classify("공백 테스트") - // then intent shouldBe Intent.GENERAL_CHAT } } + @Nested + inner class `COMPOUND 의도 분류 테스트` { + @Test + fun `COMPOUND 패턴이 있으면 COMPOUND로 분류된다`() { + val chatClient = createMockChatClient("COMPOUND[MEMO_WRITE,KNOWLEDGE_QUERY]") + val intentRouter = IntentRouterAgent(chatClient) + + val intent = intentRouter.classify("메모하고 관련 자료 찾아줘") + + intent shouldBe Intent.COMPOUND + } + + @Test + fun `classifyWithSubIntents는 COMPOUND일 때 서브 의도 목록을 반환한다`() { + val chatClient = createMockChatClient("COMPOUND[MEMO_WRITE,KNOWLEDGE_QUERY]") + val intentRouter = IntentRouterAgent(chatClient) + + val (intent, subIntents) = intentRouter.classifyWithSubIntents("메모하고 관련 자료 찾아줘") + + intent shouldBe Intent.COMPOUND + subIntents shouldHaveSize 2 + subIntents[0] shouldBe Intent.MEMO_WRITE + subIntents[1] shouldBe Intent.KNOWLEDGE_QUERY + } + + @Test + fun `classifyWithSubIntents는 단순 의도일 때 빈 서브 의도 목록을 반환한다`() { + val chatClient = createMockChatClient("MEMO_WRITE") + val intentRouter = IntentRouterAgent(chatClient) + + val (intent, subIntents) = intentRouter.classifyWithSubIntents("메모 저장해줘") + + intent shouldBe Intent.MEMO_WRITE + subIntents shouldHaveSize 0 + } + + @Test + fun `COMPOUND 대소문자 무시 패턴도 인식된다`() { + val chatClient = createMockChatClient("compound[MEMO_ORGANIZE,MEMO_WRITE]") + val intentRouter = IntentRouterAgent(chatClient) + + val (intent, subIntents) = intentRouter.classifyWithSubIntents("폴더 만들고 메모 저장해") + + intent shouldBe Intent.COMPOUND + subIntents shouldHaveSize 2 + } + } + + @Nested + inner class `parseIntentResponse 테스트` { + private val chatClient = mockk(relaxed = true) + private val intentRouter = IntentRouterAgent(chatClient) + + @Test + fun `MEMO_WRITE 응답을 파싱한다`() { + val (intent, subIntents) = intentRouter.parseIntentResponse("MEMO_WRITE") + + intent shouldBe Intent.MEMO_WRITE + subIntents shouldHaveSize 0 + } + + @Test + fun `MEMO_ORGANIZE 응답을 파싱한다`() { + val (intent, subIntents) = intentRouter.parseIntentResponse("MEMO_ORGANIZE") + + intent shouldBe Intent.MEMO_ORGANIZE + subIntents shouldHaveSize 0 + } + + @Test + fun `MEMO_SEARCH 응답을 파싱한다`() { + val (intent, subIntents) = intentRouter.parseIntentResponse("MEMO_SEARCH") + + intent shouldBe Intent.MEMO_SEARCH + subIntents shouldHaveSize 0 + } + + @Test + fun `KNOWLEDGE_QUERY 응답을 파싱한다`() { + val (intent, subIntents) = intentRouter.parseIntentResponse("KNOWLEDGE_QUERY") + + intent shouldBe Intent.KNOWLEDGE_QUERY + subIntents shouldHaveSize 0 + } + + @Test + fun `GENERAL_CHAT 응답을 파싱한다`() { + val (intent, subIntents) = intentRouter.parseIntentResponse("GENERAL_CHAT") + + intent shouldBe Intent.GENERAL_CHAT + subIntents shouldHaveSize 0 + } + + @Test + fun `COMPOUND 응답을 파싱하면 서브 의도 목록이 반환된다`() { + val (intent, subIntents) = intentRouter.parseIntentResponse("COMPOUND[MEMO_WRITE,KNOWLEDGE_QUERY]") + + intent shouldBe Intent.COMPOUND + subIntents shouldHaveSize 2 + subIntents[0] shouldBe Intent.MEMO_WRITE + subIntents[1] shouldBe Intent.KNOWLEDGE_QUERY + } + + @Test + fun `COMPOUND에 서브 의도가 1개면 해당 의도로 분류된다`() { + val (intent, subIntents) = intentRouter.parseIntentResponse("COMPOUND[MEMO_WRITE]") + + intent shouldBe Intent.MEMO_WRITE + subIntents shouldHaveSize 0 + } + + @Test + fun `알 수 없는 응답은 GENERAL_CHAT으로 파싱된다`() { + val (intent, subIntents) = intentRouter.parseIntentResponse("UNKNOWN_INTENT") + + intent shouldBe Intent.GENERAL_CHAT + subIntents shouldHaveSize 0 + } + + @Test + fun `하위 호환 - MEMO_MANAGEMENT 응답은 MEMO_WRITE로 파싱된다`() { + val (intent, _) = intentRouter.parseIntentResponse("MEMO_MANAGEMENT") + + intent shouldBe Intent.MEMO_WRITE + } + + @Test + fun `하위 호환 - QUESTION 응답은 KNOWLEDGE_QUERY로 파싱된다`() { + val (intent, _) = intentRouter.parseIntentResponse("QUESTION") + + intent shouldBe Intent.KNOWLEDGE_QUERY + } + } + @Nested inner class `우선순위 테스트` { @Test - fun `MEMO_MANAGEMENT는 QUESTION보다 우선순위가 높다`() { - // given - val chatClient = createMockChatClient("MEMO_MANAGEMENT QUESTION") + fun `MEMO_WRITE는 KNOWLEDGE_QUERY보다 우선순위가 높다`() { + val chatClient = createMockChatClient("MEMO_WRITE KNOWLEDGE_QUERY") + val intentRouter = IntentRouterAgent(chatClient) + + val intent = intentRouter.classify("테스트") + + intent shouldBe Intent.MEMO_WRITE + } + + @Test + fun `MEMO_WRITE는 MEMO_ORGANIZE보다 우선순위가 높다`() { + val chatClient = createMockChatClient("MEMO_WRITE MEMO_ORGANIZE") val intentRouter = IntentRouterAgent(chatClient) - // when val intent = intentRouter.classify("테스트") - // then - intent shouldBe Intent.MEMO_MANAGEMENT + intent shouldBe Intent.MEMO_WRITE } @Test - fun `MEMO_CREATION은 QUESTION보다 우선순위가 높다`() { - // given - val chatClient = createMockChatClient("QUESTION MEMO_CREATION") + fun `MEMO_ORGANIZE는 MEMO_SEARCH보다 우선순위가 높다`() { + val chatClient = createMockChatClient("MEMO_ORGANIZE MEMO_SEARCH") val intentRouter = IntentRouterAgent(chatClient) - // when val intent = intentRouter.classify("테스트") - // then - intent shouldBe Intent.MEMO_MANAGEMENT + intent shouldBe Intent.MEMO_ORGANIZE } } @@ -247,41 +390,32 @@ class IntentRouterAgentTests : SimpleUnitTestContext() { inner class `엣지 케이스 테스트` { @Test fun `응답에 키워드가 부분 문자열로 포함되어도 매칭된다`() { - // given - val chatClient = createMockChatClient("The intent is MEMO_MANAGEMENT for this case") + val chatClient = createMockChatClient("The intent is MEMO_WRITE for this case") val intentRouter = IntentRouterAgent(chatClient) - // when val intent = intentRouter.classify("테스트") - // then - intent shouldBe Intent.MEMO_MANAGEMENT + intent shouldBe Intent.MEMO_WRITE } @Test fun `응답에 여러 줄이 있어도 정상 분류된다`() { - // given - val chatClient = createMockChatClient("Intent:\nQUESTION") + val chatClient = createMockChatClient("Intent:\nKNOWLEDGE_QUERY") val intentRouter = IntentRouterAgent(chatClient) - // when val intent = intentRouter.classify("테스트") - // then - intent shouldBe Intent.QUESTION + intent shouldBe Intent.KNOWLEDGE_QUERY } @Test fun `응답에 특수문자가 포함되어도 정상 분류된다`() { - // given - val chatClient = createMockChatClient("[MEMO_MANAGEMENT]") + val chatClient = createMockChatClient("[MEMO_WRITE]") val intentRouter = IntentRouterAgent(chatClient) - // when val intent = intentRouter.classify("테스트") - // then - intent shouldBe Intent.MEMO_MANAGEMENT + intent shouldBe Intent.MEMO_WRITE } } } diff --git a/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/agent/RagAgentTests.kt b/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/agent/RagAgentTests.kt index 574e7d5e..2f1d0fda 100644 --- a/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/agent/RagAgentTests.kt +++ b/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/agent/RagAgentTests.kt @@ -1,107 +1,180 @@ package kr.co.jiniaslog.ai.domain.agent +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldNotBeEmpty +import io.mockk.every import io.mockk.mockk +import kr.co.jiniaslog.ai.outbound.MemoInfo +import kr.co.jiniaslog.ai.outbound.MemoQueryClient import kr.co.jiniaslog.shared.SimpleUnitTestContext import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.springframework.ai.chat.client.ChatClient import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import io.kotest.matchers.collections.shouldContain as collectionShouldContain class RagAgentTests : SimpleUnitTestContext() { + private fun createRagAgent( + memoQueryClient: MemoQueryClient = mockk(relaxed = true), + ): RagAgent { + val chatClient = mockk(relaxed = true) + return RagAgent(chatClient, memoQueryClient) + } + @Nested inner class `buildSystemPrompt 테스트` { @Test fun `시스템 프롬프트를 생성할 수 있다`() { - // given - val chatClient = mockk(relaxed = true) - val ragAgent = RagAgent(chatClient) + val ragAgent = createRagAgent() - // when val systemPrompt = ragAgent.buildSystemPrompt() - // then systemPrompt.shouldNotBeEmpty() } @Test fun `시스템 프롬프트에 현재 시간 정보가 포함된다`() { - // given - val chatClient = mockk(relaxed = true) - val ragAgent = RagAgent(chatClient) + val ragAgent = createRagAgent() - // when val systemPrompt = ragAgent.buildSystemPrompt() - // then systemPrompt shouldContain "현재:" systemPrompt shouldContain LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy년 MM월 dd일")) } @Test fun `시스템 프롬프트에 오늘 날짜가 포함된다`() { - // given - val chatClient = mockk(relaxed = true) - val ragAgent = RagAgent(chatClient) - val today = LocalDateTime.now() - val expectedDate = today.format(DateTimeFormatter.ofPattern("MM월 dd일")) + val ragAgent = createRagAgent() + val expectedDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("MM월 dd일")) - // when val systemPrompt = ragAgent.buildSystemPrompt() - // then systemPrompt shouldContain "오늘:" systemPrompt shouldContain expectedDate } @Test fun `시스템 프롬프트에 내일 날짜가 포함된다`() { - // given - val chatClient = mockk(relaxed = true) - val ragAgent = RagAgent(chatClient) - val tomorrow = LocalDateTime.now().plusDays(1) - val expectedDate = tomorrow.format(DateTimeFormatter.ofPattern("MM월 dd일")) + val ragAgent = createRagAgent() + val expectedDate = LocalDateTime.now().plusDays(1).format(DateTimeFormatter.ofPattern("MM월 dd일")) - // when val systemPrompt = ragAgent.buildSystemPrompt() - // then systemPrompt shouldContain "내일:" systemPrompt shouldContain expectedDate } @Test fun `시스템 프롬프트에 모레 날짜가 포함된다`() { - // given - val chatClient = mockk(relaxed = true) - val ragAgent = RagAgent(chatClient) - val dayAfterTomorrow = LocalDateTime.now().plusDays(2) - val expectedDate = dayAfterTomorrow.format(DateTimeFormatter.ofPattern("MM월 dd일")) + val ragAgent = createRagAgent() + val expectedDate = LocalDateTime.now().plusDays(2).format(DateTimeFormatter.ofPattern("MM월 dd일")) - // when val systemPrompt = ragAgent.buildSystemPrompt() - // then systemPrompt shouldContain "모레:" systemPrompt shouldContain expectedDate } @Test fun `시스템 프롬프트에 기본 시스템 프롬프트 텍스트가 포함된다`() { - // given - val chatClient = mockk(relaxed = true) - val ragAgent = RagAgent(chatClient) + val ragAgent = createRagAgent() - // when val systemPrompt = ragAgent.buildSystemPrompt() - // then systemPrompt shouldContain "당신은 사용자의 개인 지식 관리 시스템 AI 어시스턴트입니다" systemPrompt shouldContain "사용자의 메모를 참조하여 정확하고 도움이 되는 답변을 제공합니다" systemPrompt shouldContain "현재 시간 정보" } + + @Test + fun `추가 컨텍스트가 시스템 프롬프트에 포함된다`() { + val ragAgent = createRagAgent() + + val systemPrompt = ragAgent.buildSystemPrompt("테스트 컨텍스트") + + systemPrompt shouldContain "테스트 컨텍스트" + } + } + + @Nested + inner class `extractKeywords 테스트` { + @Test + fun `메시지에서 키워드를 추출한다`() { + val ragAgent = createRagAgent() + + val keywords = ragAgent.extractKeywords("스프링 부트 설정 방법") + + keywords collectionShouldContain "스프링" + keywords collectionShouldContain "부트" + } + + @Test + fun `불용어는 제외된다`() { + val ragAgent = createRagAgent() + + val keywords = ragAgent.extractKeywords("뭐 알려줘 해줘") + + keywords.shouldBeEmpty() + } + + @Test + fun `최대 3개 키워드만 반환한다`() { + val ragAgent = createRagAgent() + + val keywords = ragAgent.extractKeywords("가나다라 마바사아 자차카타 파하가나") + + keywords shouldHaveSize 3 + } + + @Test + fun `2글자 미만 단어는 제외된다`() { + val ragAgent = createRagAgent() + + val keywords = ragAgent.extractKeywords("a 나 스프링") + + keywords collectionShouldContain "스프링" + } + } + + @Nested + inner class `buildKeywordContext 테스트` { + @Test + fun `키워드 검색 결과가 없으면 없음을 반환한다`() { + val memoQueryClient = mockk() + every { memoQueryClient.searchByKeyword(any(), any(), any()) } returns emptyList() + val ragAgent = createRagAgent(memoQueryClient) + + val context = ragAgent.buildKeywordContext("스프링", 1L) + + context shouldContain "없음" + } + + @Test + fun `키워드 검색 결과가 있으면 메모 출처와 내용이 포함된다`() { + val memoQueryClient = mockk() + every { memoQueryClient.searchByKeyword(1L, any(), 3) } returns emptyList() + every { memoQueryClient.searchByKeyword(1L, "스프링", 3) } returns listOf( + MemoInfo(id = 1L, authorId = 1L, title = "스프링 가이드", content = "스프링 설명") + ) + val ragAgent = createRagAgent(memoQueryClient) + + val context = ragAgent.buildKeywordContext("스프링 부트", 1L) + + context shouldContain "[메모: 스프링 가이드]" + context shouldContain "스프링 설명" + } + + @Test + fun `메시지에서 키워드를 추출할 수 없으면 없음을 반환한다`() { + val ragAgent = createRagAgent() + + val context = ragAgent.buildKeywordContext("뭐 해줘", 1L) + + context shouldContain "없음" + } } } diff --git a/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/chat/PersistentChatMemoryTests.kt b/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/chat/PersistentChatMemoryTests.kt new file mode 100644 index 00000000..ffe2bfe2 --- /dev/null +++ b/service/ai-second-brain/ai-second-brain-core/src/test/kotlin/kr/co/jiniaslog/ai/domain/chat/PersistentChatMemoryTests.kt @@ -0,0 +1,152 @@ +package kr.co.jiniaslog.ai.domain.chat + +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kr.co.jiniaslog.shared.SimpleUnitTestContext +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.SystemMessage +import org.springframework.ai.chat.messages.UserMessage + +class PersistentChatMemoryTests : SimpleUnitTestContext() { + + private val chatMessageRepository: ChatMessageRepository = mockk() + + private fun makeMessage(id: Long, sessionId: Long, role: MessageRole, content: String): ChatMessage { + return ChatMessage.from( + id = ChatMessageId(id), + sessionId = ChatSessionId(sessionId), + role = role, + content = content, + ) + } + + @Nested + inner class `get() 테스트` { + + @Test + fun `get()이 DB에서 메시지를 로드하여 Spring AI Message로 변환한다`() { + // given + val sessionId = ChatSessionId(1L) + val messages = listOf( + makeMessage(1L, 1L, MessageRole.USER, "안녕하세요"), + makeMessage(2L, 1L, MessageRole.ASSISTANT, "무엇을 도와드릴까요?"), + makeMessage(3L, 1L, MessageRole.SYSTEM, "시스템 메시지"), + ) + every { chatMessageRepository.findAllBySessionId(sessionId) } returns messages + + val sut = PersistentChatMemory(chatMessageRepository, maxMessages = 20) + + // when + val result = sut.get("1") + + // then + result.size shouldBe 3 + (result[0] is UserMessage) shouldBe true + result[0].text shouldBe "안녕하세요" + (result[1] is AssistantMessage) shouldBe true + result[1].text shouldBe "무엇을 도와드릴까요?" + (result[2] is SystemMessage) shouldBe true + result[2].text shouldBe "시스템 메시지" + } + + @Test + fun `get()이 maxMessages 개수만큼만 반환한다`() { + // given + val sessionId = ChatSessionId(1L) + val messages = (1L..25L).map { i -> + makeMessage(i, 1L, MessageRole.USER, "메시지 $i") + } + every { chatMessageRepository.findAllBySessionId(sessionId) } returns messages + + val sut = PersistentChatMemory(chatMessageRepository, maxMessages = 10) + + // when + val result = sut.get("1") + + // then + result.size shouldBe 10 + result.first().text shouldBe "메시지 16" + result.last().text shouldBe "메시지 25" + } + + @Test + fun `get()이 빈 히스토리를 정상 처리한다`() { + // given + val sessionId = ChatSessionId(1L) + every { chatMessageRepository.findAllBySessionId(sessionId) } returns emptyList() + + val sut = PersistentChatMemory(chatMessageRepository, maxMessages = 20) + + // when + val result = sut.get("1") + + // then + result shouldBe emptyList() + } + + @Test + fun `get()이 잘못된 conversationId를 처리한다`() { + // given + val sut = PersistentChatMemory(chatMessageRepository, maxMessages = 20) + + // when + val result = sut.get("not-a-number") + + // then + result shouldBe emptyList() + } + } + + @Nested + inner class `add() 테스트` { + + @Test + fun `add()는 아무 동작도 하지 않는다 (no-op)`() { + // given + val sut = PersistentChatMemory(chatMessageRepository, maxMessages = 20) + val springMessages = listOf(UserMessage("테스트")) + + // when + sut.add("1", springMessages) + + // then - repository 메서드가 호출되지 않아야 함 + verify(exactly = 0) { chatMessageRepository.save(any()) } + verify(exactly = 0) { chatMessageRepository.saveAll(any()) } + } + } + + @Nested + inner class `clear() 테스트` { + + @Test + fun `clear()가 deleteAllBySessionId를 호출한다`() { + // given + val sessionId = ChatSessionId(1L) + every { chatMessageRepository.deleteAllBySessionId(sessionId) } returns Unit + + val sut = PersistentChatMemory(chatMessageRepository, maxMessages = 20) + + // when + sut.clear("1") + + // then + verify(exactly = 1) { chatMessageRepository.deleteAllBySessionId(sessionId) } + } + + @Test + fun `clear()가 잘못된 conversationId를 처리한다`() { + // given + val sut = PersistentChatMemory(chatMessageRepository, maxMessages = 20) + + // when + sut.clear("not-a-number") + + // then - repository 메서드가 호출되지 않아야 함 + verify(exactly = 0) { chatMessageRepository.deleteAllBySessionId(any()) } + } + } +}