Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions buildSrc/src/main/kotlin/plugin/kover-features.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ koverReport {
"*LlmServiceImpl*",
"*MemoQueryServiceAdapter*",
"*MemoCommandServiceAdapter*",
"*MemoQueryClientAdapter*",
)
// DTO 및 데이터 클래스 (equals, hashCode, toString 등 자동 생성)
classes("*Dto", "*Dto\$*", "*Request", "*Request\$*", "*Response", "*Response\$*")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -80,6 +81,7 @@ class ContextWithTestContainerConfig {
content = "테스트 메모 내용입니다."
)
every { getAllMemosByAuthorId(any()) } returns emptyList()
every { searchByKeyword(any(), any(), any()) } returns emptyList()
}
}

Expand Down Expand Up @@ -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())
}
}

Expand Down Expand Up @@ -161,18 +164,25 @@ class ContextWithTestContainerConfig {
}
}

@Bean
@Primary
fun compoundIntentHandler(): CompoundIntentHandler {
return mockk(relaxed = true)
}

@Bean
@Primary
fun agentOrchestrator(
intentRouterAgent: IntentRouterAgent,
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
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,13 @@ class MemoQueryClientAdapter(
)
}
}

override fun searchByKeyword(authorId: Long, keyword: String, limit: Int): List<MemoInfo> {
return getAllMemosByAuthorId(authorId)
.filter { memo ->
memo.title.contains(keyword, ignoreCase = true) ||
memo.content.contains(keyword, ignoreCase = true)
}
.take(limit)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
/**
Expand All @@ -38,22 +39,27 @@ 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,
authorId = authorId
)
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Intent>,
sessionId: Long,
authorId: Long
): AgentResponse {
if (subIntents.isEmpty()) {
return AgentResponse.ChatResponse("요청을 처리할 수 없습니다.")
}

val results = mutableListOf<AgentResponse>()

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>): AgentResponse {
if (responses.size == 1) return responses.first()

val messageParts = mutableListOf<String>()

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"))
}
}
Original file line number Diff line number Diff line change
@@ -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 // 복합 의도 (여러 의도가 섞인 요청)
}
Loading