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
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.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
Expand Down Expand Up @@ -107,19 +108,32 @@ 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
}
}

@Bean
@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 "테스트 응답입니다."
}
}

Expand Down Expand Up @@ -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("테스트 응답입니다.")
Expand Down
Original file line number Diff line number Diff line change
@@ -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로 라우팅합니다.
Expand All @@ -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)
}
Expand All @@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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() ?: "응답을 생성할 수 없습니다."
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import org.springframework.stereotype.Component
/**
* Intent Router Agent - 경량 모델을 사용하여 사용자 메시지의 의도를 분류합니다.
* 토큰 최적화를 위해 최소한의 프롬프트와 단순한 응답만 사용합니다.
* 대화 히스토리를 참조하여 후속 메시지의 맥락을 파악합니다.
*/
@Component
class IntentRouterAgent(
Expand Down Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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() ?: "응답을 생성할 수 없습니다."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading