From 4e50eacc37abd9e0a856b128533a763a71673e25 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Sun, 20 Sep 2026 19:18:34 +0900 Subject: [PATCH 01/21] feat(chatbot): add LLM gateway and chat API --- build.gradle | 5 + .../domain/chatbot/config/ChatbotConfig.java | 22 ++++ .../chatbot/config/ChatbotProperties.java | 16 +++ .../chatbot/controller/ChatController.java | 26 +++++ .../chatbot/dto/request/ChatContextReq.java | 6 + .../chatbot/dto/request/ChatMessageReq.java | 18 +++ .../dto/request/CurrentLocationReq.java | 11 ++ .../chatbot/dto/response/ChatMessageRes.java | 6 + .../domain/chatbot/gateway/LlmGateway.java | 5 + .../chatbot/gateway/SpringAiLlmGateway.java | 52 +++++++++ .../domain/chatbot/service/ChatService.java | 30 +++++ .../teamcback/global/response/ResultCode.java | 6 +- src/main/resources/application.yml | 24 ++++ .../controller/ChatControllerTest.java | 109 ++++++++++++++++++ .../gateway/SpringAiLlmGatewayTest.java | 45 ++++++++ .../service/ChatServiceArchitectureTest.java | 20 ++++ src/test/resources/application-test.yml | 6 + 17 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotConfig.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/controller/ChatController.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/request/ChatContextReq.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/request/ChatMessageReq.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/request/CurrentLocationReq.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/response/ChatMessageRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceArchitectureTest.java diff --git a/build.gradle b/build.gradle index 7b7168c1..4f945975 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,7 @@ repositories { ext { set('springCloudVersion', "2024.0.0") + set('springAiVersion', "1.0.9") } dependencies { @@ -47,6 +48,9 @@ dependencies { // web implementation 'org.springframework.boot:spring-boot-starter-web' + // Spring AI + implementation 'org.springframework.ai:spring-ai-starter-model-vertex-ai-gemini' + // h2 runtimeOnly 'com.h2database:h2' @@ -131,6 +135,7 @@ dependencies { dependencyManagement { imports { mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + mavenBom "org.springframework.ai:spring-ai-bom:${springAiVersion}" } } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotConfig.java b/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotConfig.java new file mode 100644 index 00000000..6eba3509 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotConfig.java @@ -0,0 +1,22 @@ +package devkor.com.teamcback.domain.chatbot.config; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(ChatbotProperties.class) +public class ChatbotConfig { + @Bean(destroyMethod = "shutdown") + public ExecutorService chatbotLlmExecutor() { + AtomicInteger sequence = new AtomicInteger(); + return Executors.newCachedThreadPool(task -> { + Thread thread = new Thread(task, "chatbot-llm-" + sequence.incrementAndGet()); + thread.setDaemon(true); + return thread; + }); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java b/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java new file mode 100644 index 00000000..43158dbd --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java @@ -0,0 +1,16 @@ +package devkor.com.teamcback.domain.chatbot.config; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "chatbot") +public record ChatbotProperties(boolean enabled, Llm llm, Agent agent) { + public record Llm(String provider, String model, int maxOutputTokens, int timeoutSeconds) { + public Duration timeout() { + return Duration.ofSeconds(timeoutSeconds); + } + } + + public record Agent(int maxToolCallsPerMessage, int historyTurns, int sessionTtlMinutes) { + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/controller/ChatController.java b/src/main/java/devkor/com/teamcback/domain/chatbot/controller/ChatController.java new file mode 100644 index 00000000..4e762d3d --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/controller/ChatController.java @@ -0,0 +1,26 @@ +package devkor.com.teamcback.domain.chatbot.controller; + +import devkor.com.teamcback.domain.chatbot.dto.request.ChatMessageReq; +import devkor.com.teamcback.domain.chatbot.dto.response.ChatMessageRes; +import devkor.com.teamcback.domain.chatbot.service.ChatService; +import devkor.com.teamcback.global.response.CommonResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/chatbot") +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class ChatController { + private final ChatService chatService; + + @PostMapping("/messages") + public CommonResponse sendMessage(@Valid @RequestBody ChatMessageReq request) { + return CommonResponse.success(chatService.sendMessage(request)); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/request/ChatContextReq.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/request/ChatContextReq.java new file mode 100644 index 00000000..a4ab6453 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/request/ChatContextReq.java @@ -0,0 +1,6 @@ +package devkor.com.teamcback.domain.chatbot.dto.request; + +import jakarta.validation.Valid; + +public record ChatContextReq(@Valid CurrentLocationReq currentLocation) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/request/ChatMessageReq.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/request/ChatMessageReq.java new file mode 100644 index 00000000..6610ae0d --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/request/ChatMessageReq.java @@ -0,0 +1,18 @@ +package devkor.com.teamcback.domain.chatbot.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.util.UUID; + +public record ChatMessageReq( + UUID sessionId, + @NotBlank @Size(max = 1000) String message, + @Valid ChatContextReq context +) { + public ChatMessageReq { + if (message != null) { + message = message.trim(); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/request/CurrentLocationReq.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/request/CurrentLocationReq.java new file mode 100644 index 00000000..fa539685 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/request/CurrentLocationReq.java @@ -0,0 +1,11 @@ +package devkor.com.teamcback.domain.chatbot.dto.request; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; + +public record CurrentLocationReq( + @NotNull @DecimalMin("-90.0") @DecimalMax("90.0") Double latitude, + @NotNull @DecimalMin("-180.0") @DecimalMax("180.0") Double longitude +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/response/ChatMessageRes.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/response/ChatMessageRes.java new file mode 100644 index 00000000..5fdbe7f7 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/response/ChatMessageRes.java @@ -0,0 +1,6 @@ +package devkor.com.teamcback.domain.chatbot.dto.response; + +import java.util.UUID; + +public record ChatMessageRes(UUID sessionId, String reply) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java new file mode 100644 index 00000000..b61536a3 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java @@ -0,0 +1,5 @@ +package devkor.com.teamcback.domain.chatbot.gateway; + +public interface LlmGateway { + String generate(String systemPrompt, String userMessage); +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java new file mode 100644 index 00000000..0dc0b6ee --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java @@ -0,0 +1,52 @@ +package devkor.com.teamcback.domain.chatbot.gateway; + +import static devkor.com.teamcback.global.response.ResultCode.CHATBOT_TEMPORARILY_UNAVAILABLE; + +import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class SpringAiLlmGateway implements LlmGateway { + private final ChatClient chatClient; + private final ChatbotProperties properties; + private final ExecutorService chatbotLlmExecutor; + + public SpringAiLlmGateway(ChatClient.Builder chatClientBuilder, ChatbotProperties properties, + ExecutorService chatbotLlmExecutor) { + this.chatClient = chatClientBuilder.build(); + this.properties = properties; + this.chatbotLlmExecutor = chatbotLlmExecutor; + } + + @Override + public String generate(String systemPrompt, String userMessage) { + Future response = chatbotLlmExecutor.submit(() -> chatClient.prompt() + .system(systemPrompt) + .user(userMessage) + .call() + .content()); + try { + String content = response.get(properties.llm().timeout().toMillis(), TimeUnit.MILLISECONDS); + if (content == null || content.isBlank()) { + throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); + } + return content; + } catch (InterruptedException exception) { + response.cancel(true); + Thread.currentThread().interrupt(); + throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); + } catch (ExecutionException | TimeoutException exception) { + response.cancel(true); + throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java new file mode 100644 index 00000000..f7a378ac --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java @@ -0,0 +1,30 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import devkor.com.teamcback.domain.chatbot.dto.request.ChatMessageReq; +import devkor.com.teamcback.domain.chatbot.dto.response.ChatMessageRes; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class ChatService { + static final String SYSTEM_PROMPT = """ + 당신은 고려대학교 캠퍼스 생활 도우미입니다. + 실제 교내 정보는 도구가 제공되는 경우 반드시 도구 결과에 근거하고, 없는 사실을 만들지 마세요. + 정보가 없거나 지원하지 않는 요청은 명확히 알리세요. + 개인정보, 인증 정보, 내부 식별자는 노출하지 마세요. + 학사 규정, 졸업 요건, 장학금 및 RAG 기반 지식 질의는 V1 지원 범위가 아닙니다. + 한국어로 간결하고 실용적으로 답하세요. + """; + + private final LlmGateway llmGateway; + + public ChatMessageRes sendMessage(ChatMessageReq request) { + UUID sessionId = request.sessionId() == null ? UUID.randomUUID() : request.sessionId(); + return new ChatMessageRes(sessionId, llmGateway.generate(SYSTEM_PROMPT, request.message())); + } +} diff --git a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java index 2536d2e1..15cc3040 100644 --- a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java +++ b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java @@ -124,7 +124,11 @@ public enum ResultCode { // 사용 성향 조사 19000번대 ALREADY_ANSWERED_USAGE_SURVEY(HttpStatus.CONFLICT, 19000, "이미 응답한 조사 문항입니다."), - INVALID_USAGE_SURVEY_OPTION(HttpStatus.BAD_REQUEST, 19001, "유효하지 않은 조사 응답입니다."); + INVALID_USAGE_SURVEY_OPTION(HttpStatus.BAD_REQUEST, 19001, "유효하지 않은 조사 응답입니다."), + + // 챗봇 20000번대 + CHATBOT_TEMPORARILY_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, 20000, + "챗봇 서비스를 일시적으로 사용할 수 없습니다."); private final HttpStatus status; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2bbaf3c6..2bf10769 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,16 @@ spring: + ai: + model: + chat: vertexai + vertex: + ai: + gemini: + project-id: ${GOOGLE_CLOUD_PROJECT} + location: ${GOOGLE_CLOUD_LOCATION:us-central1} + chat: + options: + model: ${chatbot.llm.model} + max-output-tokens: ${chatbot.llm.max-output-tokens} cache: type: redis datasource: @@ -158,6 +170,18 @@ management: staff: emails: leeyejin113@gmail.com,pingdoll3110@naver.com,ku.kodaero@gmail.com +chatbot: + enabled: ${CHATBOT_ENABLED:true} + llm: + provider: ${CHATBOT_LLM_PROVIDER:google} + model: ${CHATBOT_LLM_MODEL:gemini-2.5-flash-lite} + max-output-tokens: ${CHATBOT_MAX_OUTPUT_TOKENS:500} + timeout-seconds: ${CHATBOT_TIMEOUT_SECONDS:8} + agent: + max-tool-calls-per-message: ${CHATBOT_MAX_TOOL_CALLS:6} + history-turns: ${CHATBOT_HISTORY_TURNS:5} + session-ttl-minutes: ${CHATBOT_SESSION_TTL_MINUTES:60} + push: expo: base-url: https://exp.host/--/api/v2/push diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java new file mode 100644 index 00000000..c258543c --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java @@ -0,0 +1,109 @@ +package devkor.com.teamcback.domain.chatbot.controller; + +import static devkor.com.teamcback.global.response.ResultCode.CHATBOT_TEMPORARILY_UNAVAILABLE; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.blankOrNullString; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import devkor.com.teamcback.domain.chatbot.service.ChatService; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.exception.handler.GlobalExceptionHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +@ExtendWith(MockitoExtension.class) +class ChatControllerTest { + @Mock + private LlmGateway llmGateway; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + ChatService chatService = new ChatService(llmGateway); + mockMvc = MockMvcBuilders.standaloneSetup(new ChatController(chatService)) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + void rejectsBlankMessageBeforeLlmCall() throws Exception { + mockMvc.perform(post("/api/chatbot/messages") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"message\":\" \"}")) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(llmGateway); + } + + @Test + void rejectsMessageLongerThanOneThousandCharactersBeforeLlmCall() throws Exception { + String request = "{\"message\":\"" + "a".repeat(1001) + "\"}"; + + mockMvc.perform(post("/api/chatbot/messages") + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(llmGateway); + } + + @Test + void rejectsInvalidCoordinatesBeforeLlmCall() throws Exception { + mockMvc.perform(post("/api/chatbot/messages") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "message": "길을 알려줘", + "context": { + "currentLocation": { + "latitude": 91.0, + "longitude": -181.0 + } + } + } + """)) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(llmGateway); + } + + @Test + void generatesSessionIdAndReturnsGatewayReply() throws Exception { + when(llmGateway.generate(anyString(), anyString())).thenReturn("안녕하세요."); + + mockMvc.perform(post("/api/chatbot/messages") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"message\":\" 안녕 \"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.statusCode").value(0)) + .andExpect(jsonPath("$.data.sessionId", not(blankOrNullString()))) + .andExpect(jsonPath("$.data.reply").value("안녕하세요.")); + } + + @Test + void hidesProviderFailureDetails() throws Exception { + when(llmGateway.generate(anyString(), anyString())) + .thenThrow(new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE)); + + mockMvc.perform(post("/api/chatbot/messages") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"message\":\"안녕\"}")) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.statusCode").value(20000)) + .andExpect(jsonPath("$.message").value("챗봇 서비스를 일시적으로 사용할 수 없습니다.")) + .andExpect(jsonPath("$.data").doesNotExist()); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java new file mode 100644 index 00000000..0a77e611 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java @@ -0,0 +1,45 @@ +package devkor.com.teamcback.domain.chatbot.gateway; + +import static devkor.com.teamcback.global.response.ResultCode.CHATBOT_TEMPORARILY_UNAVAILABLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.client.ChatClient; + +class SpringAiLlmGatewayTest { + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + + @AfterEach + void tearDown() { + executor.shutdownNow(); + } + + @Test + void mapsProviderFailureWithoutExposingProviderDetails() { + ChatClient chatClient = mock(ChatClient.class, RETURNS_DEEP_STUBS); + ChatClient.Builder builder = mock(ChatClient.Builder.class); + when(builder.build()).thenReturn(chatClient); + when(chatClient.prompt().system("system").user("hello").call().content()) + .thenThrow(new IllegalStateException("provider secret and internal details")); + ChatbotProperties properties = new ChatbotProperties(true, + new ChatbotProperties.Llm("google", "gemini-2.5-flash-lite", 500, 8), + new ChatbotProperties.Agent(6, 5, 60)); + SpringAiLlmGateway gateway = new SpringAiLlmGateway(builder, properties, executor); + + assertThatThrownBy(() -> gateway.generate("system", "hello")) + .isInstanceOfSatisfying(GlobalException.class, exception -> { + assertThat(exception.getResultCode()).isEqualTo(CHATBOT_TEMPORARILY_UNAVAILABLE); + assertThat(exception.getMessage()).isNull(); + assertThat(exception.getCause()).isNull(); + }); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceArchitectureTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceArchitectureTest.java new file mode 100644 index 00000000..271a5554 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceArchitectureTest.java @@ -0,0 +1,20 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import java.lang.reflect.Field; +import java.util.Arrays; +import org.junit.jupiter.api.Test; + +class ChatServiceArchitectureTest { + @Test + void dependsOnLlmGatewayOnlyForLlmAccess() { + Field[] fields = ChatService.class.getDeclaredFields(); + + assertThat(Arrays.stream(fields).map(Field::getType)) + .contains(LlmGateway.class) + .noneMatch(type -> type.getName().startsWith("org.springframework.ai") + || type.getName().startsWith("com.google")); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index ed69394e..8b69de3c 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -1,4 +1,7 @@ spring: + ai: + model: + chat: none cache: type: none datasource: @@ -99,3 +102,6 @@ management: staff: emails: test@test.com + +chatbot: + enabled: false From 798599d7893fa71525f32df400d46f719659b15c Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Sun, 20 Sep 2026 20:13:05 +0900 Subject: [PATCH 02/21] feat(chatbot): add campus search tools --- .../chatbot/config/ChatbotProperties.java | 8 +- .../chatbot/gateway/SpringAiLlmGateway.java | 6 +- .../chatbot/tool/CampusChatbotTools.java | 40 +++ .../chatbot/tool/CampusToolAdapter.java | 244 +++++++++++++++++ .../chatbot/tool/dto/CampusFacilityType.java | 44 +++ .../chatbot/tool/dto/CampusToolError.java | 13 + .../chatbot/tool/dto/CampusToolErrorCode.java | 9 + .../chatbot/tool/dto/FacilityToolItem.java | 19 ++ .../tool/dto/FindFacilitiesToolRequest.java | 9 + .../tool/dto/FindFacilitiesToolResult.java | 8 + .../dto/GetLocationDetailToolRequest.java | 4 + .../tool/dto/GetLocationDetailToolResult.java | 7 + .../tool/dto/LocationDetailToolData.java | 27 ++ .../chatbot/tool/dto/SearchCampusItem.java | 17 ++ .../tool/dto/SearchCampusToolRequest.java | 4 + .../tool/dto/SearchCampusToolResult.java | 12 + .../chatbot/tool/dto/ToolLocationType.java | 6 + src/main/resources/application.yml | 7 + .../gateway/SpringAiLlmGatewayTest.java | 9 +- .../chatbot/tool/CampusChatbotToolsTest.java | 17 ++ .../chatbot/tool/CampusToolAdapterTest.java | 255 ++++++++++++++++++ 21 files changed, 761 insertions(+), 4 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusFacilityType.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusToolError.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusToolErrorCode.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FacilityToolItem.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FindFacilitiesToolRequest.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FindFacilitiesToolResult.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetLocationDetailToolRequest.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetLocationDetailToolResult.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/LocationDetailToolData.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusItem.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusToolRequest.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusToolResult.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/ToolLocationType.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java b/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java index 43158dbd..c04f939b 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java @@ -4,7 +4,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "chatbot") -public record ChatbotProperties(boolean enabled, Llm llm, Agent agent) { +public record ChatbotProperties(boolean enabled, Llm llm, Agent agent, Tools tools) { public record Llm(String provider, String model, int maxOutputTokens, int timeoutSeconds) { public Duration timeout() { return Duration.ofSeconds(timeoutSeconds); @@ -13,4 +13,10 @@ public Duration timeout() { public record Agent(int maxToolCallsPerMessage, int historyTurns, int sessionTtlMinutes) { } + + public record Tools(Limits search, Limits facilities) { + } + + public record Limits(int defaultLimit, int maxLimit) { + } } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java index 0dc0b6ee..57c2f5d7 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java @@ -3,6 +3,7 @@ import static devkor.com.teamcback.global.response.ResultCode.CHATBOT_TEMPORARILY_UNAVAILABLE; import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import devkor.com.teamcback.domain.chatbot.tool.CampusChatbotTools; import devkor.com.teamcback.global.exception.exception.GlobalException; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; @@ -19,12 +20,14 @@ public class SpringAiLlmGateway implements LlmGateway { private final ChatClient chatClient; private final ChatbotProperties properties; private final ExecutorService chatbotLlmExecutor; + private final CampusChatbotTools campusChatbotTools; public SpringAiLlmGateway(ChatClient.Builder chatClientBuilder, ChatbotProperties properties, - ExecutorService chatbotLlmExecutor) { + ExecutorService chatbotLlmExecutor, CampusChatbotTools campusChatbotTools) { this.chatClient = chatClientBuilder.build(); this.properties = properties; this.chatbotLlmExecutor = chatbotLlmExecutor; + this.campusChatbotTools = campusChatbotTools; } @Override @@ -32,6 +35,7 @@ public String generate(String systemPrompt, String userMessage) { Future response = chatbotLlmExecutor.submit(() -> chatClient.prompt() .system(systemPrompt) .user(userMessage) + .tools(campusChatbotTools) .call() .content()); try { diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java new file mode 100644 index 00000000..f732ccd5 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java @@ -0,0 +1,40 @@ +package devkor.com.teamcback.domain.chatbot.tool; + +import devkor.com.teamcback.domain.chatbot.tool.dto.FindFacilitiesToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.FindFacilitiesToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolResult; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class CampusChatbotTools { + private final CampusToolAdapter adapter; + + public CampusChatbotTools(CampusToolAdapter adapter) { + this.adapter = adapter; + } + + @Tool(name = "searchCampus", description = "장소 ID를 모를 때 가장 먼저 사용해 고려대학교 건물 또는 장소 이름 후보를 찾습니다.") + public SearchCampusToolResult searchCampus( + @ToolParam(description = "검색어와 선택적 결과 제한") SearchCampusToolRequest request) { + return adapter.searchCampus(request); + } + + @Tool(name = "getLocationDetail", description = "searchCampus로 확인한 BUILDING 또는 PLACE ID의 상세와 운영 정보를 조회합니다.") + public GetLocationDetailToolResult getLocationDetail( + @ToolParam(description = "BUILDING 또는 PLACE 유형과 위치 ID") GetLocationDetailToolRequest request) { + return adapter.getLocationDetail(request); + } + + @Tool(name = "findFacilities", description = "시설 유형이나 건물/층 조건으로 고려대학교 시설을 찾습니다. 층은 건물 ID와 함께 사용해야 합니다.") + public FindFacilitiesToolResult findFacilities( + @ToolParam(description = "시설 유형, 건물 ID, 층, 선택적 결과 제한") FindFacilitiesToolRequest request) { + return adapter.findFacilities(request); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java new file mode 100644 index 00000000..8e837461 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java @@ -0,0 +1,244 @@ +package devkor.com.teamcback.domain.chatbot.tool; + +import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.AMBIGUOUS_LOCATION; +import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.INVALID_INPUT; +import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.NOT_FOUND; +import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.TEMPORARILY_UNAVAILABLE; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_BUILDING; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_PLACE; + +import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolError; +import devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode; +import devkor.com.teamcback.domain.chatbot.tool.dto.FacilityToolItem; +import devkor.com.teamcback.domain.chatbot.tool.dto.FindFacilitiesToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.FindFacilitiesToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.LocationDetailToolData; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusItem; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType; +import devkor.com.teamcback.domain.common.LocationType; +import devkor.com.teamcback.domain.search.dto.response.GlobalSearchRes; +import devkor.com.teamcback.domain.search.dto.response.SearchBuildingDetailRes; +import devkor.com.teamcback.domain.search.dto.response.SearchFacilityRes; +import devkor.com.teamcback.domain.search.dto.response.SearchPlaceDetailRes; +import devkor.com.teamcback.domain.search.dto.response.SearchPlaceRes; +import devkor.com.teamcback.domain.search.dto.response.SearchRoomDetailRes; +import devkor.com.teamcback.domain.search.service.SearchService; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class CampusToolAdapter { + private final SearchService searchService; + private final ChatbotProperties properties; + + public CampusToolAdapter(SearchService searchService, ChatbotProperties properties) { + this.searchService = searchService; + this.properties = properties; + } + + public SearchCampusToolResult searchCampus(SearchCampusToolRequest request) { + if (request == null || request.query() == null || request.query().trim().isEmpty()) { + return new SearchCampusToolResult(List.of(), false, error(INVALID_INPUT)); + } + Integer limit = resolveLimit(request.limit(), properties.tools().search()); + if (limit == null) { + return new SearchCampusToolResult(List.of(), false, error(INVALID_INPUT)); + } + + try { + List actualLocations = searchService.globalSearch(request.query().trim(), null).getList() + .stream() + .filter(item -> item.getId() != null) + .filter(item -> item.getLocationType() == LocationType.BUILDING + || item.getLocationType() == LocationType.PLACE) + .toList(); + List candidates = actualLocations.stream() + .limit(limit) + .map(this::toSearchItem) + .toList(); + if (candidates.isEmpty()) { + return new SearchCampusToolResult(List.of(), false, error(NOT_FOUND)); + } + boolean ambiguous = actualLocations.size() > 1; + return new SearchCampusToolResult(candidates, ambiguous, + ambiguous ? error(AMBIGUOUS_LOCATION) : null); + } catch (GlobalException exception) { + return new SearchCampusToolResult(List.of(), false, mapDomainError(exception)); + } catch (RuntimeException exception) { + return new SearchCampusToolResult(List.of(), false, error(TEMPORARILY_UNAVAILABLE)); + } + } + + public GetLocationDetailToolResult getLocationDetail(GetLocationDetailToolRequest request) { + if (request == null || request.locationType() == null + || request.locationId() == null || request.locationId() <= 0) { + return new GetLocationDetailToolResult(null, error(INVALID_INPUT)); + } + try { + LocationDetailToolData data = switch (request.locationType()) { + case BUILDING -> toBuildingDetail(searchService.searchBuildingDetail(null, request.locationId())); + case PLACE -> toPlaceDetail(searchService.searchPlaceDetail(null, request.locationId())); + }; + return new GetLocationDetailToolResult(data, null); + } catch (GlobalException exception) { + return new GetLocationDetailToolResult(null, mapDomainError(exception)); + } catch (RuntimeException exception) { + return new GetLocationDetailToolResult(null, error(TEMPORARILY_UNAVAILABLE)); + } + } + + public FindFacilitiesToolResult findFacilities(FindFacilitiesToolRequest request) { + if (!validFacilityRequest(request)) { + return new FindFacilitiesToolResult(List.of(), error(INVALID_INPUT)); + } + Integer limit = resolveLimit(request.limit(), properties.tools().facilities()); + if (limit == null) { + return new FindFacilitiesToolResult(List.of(), error(INVALID_INPUT)); + } + + try { + List facilities; + if (request.facilityType() != null && request.buildingId() == null) { + facilities = searchService.searchFacilitiesWithType(request.facilityType().toPlaceType()) + .getFacilities().stream().map(this::toFacilityItem).toList(); + } else if (request.facilityType() != null) { + facilities = flattenBuildingFacilities(request); + } else { + facilities = searchService.searchPlaceByBuildingFloor(request.buildingId(), request.floor()) + .getRoomList().stream() + .map(item -> toFloorItem(item, request.buildingId(), request.floor())) + .toList(); + } + facilities = facilities.stream().limit(limit).toList(); + if (facilities.isEmpty()) { + return new FindFacilitiesToolResult(List.of(), error(NOT_FOUND)); + } + return new FindFacilitiesToolResult(facilities, null); + } catch (GlobalException exception) { + return new FindFacilitiesToolResult(List.of(), mapDomainError(exception)); + } catch (RuntimeException exception) { + return new FindFacilitiesToolResult(List.of(), error(TEMPORARILY_UNAVAILABLE)); + } + } + + private boolean validFacilityRequest(FindFacilitiesToolRequest request) { + if (request == null) { + return false; + } + if (request.buildingId() != null && request.buildingId() <= 0) { + return false; + } + if (request.floor() != null && request.buildingId() == null) { + return false; + } + if (request.facilityType() == null && request.buildingId() == null) { + return false; + } + return request.facilityType() != null || request.floor() != null; + } + + private List flattenBuildingFacilities(FindFacilitiesToolRequest request) { + Map> byFloor = searchService.searchBuildingFacilityByType( + request.buildingId(), request.facilityType().toPlaceType()).getFacilities(); + return byFloor.entrySet().stream() + .filter(entry -> request.floor() == null + || Double.compare(entry.getKey(), request.floor().doubleValue()) == 0) + .sorted(Map.Entry.comparingByKey(Comparator.naturalOrder())) + .flatMap(entry -> entry.getValue().stream()) + .map(this::toBuildingFacilityItem) + .toList(); + } + + private SearchCampusItem toSearchItem(GlobalSearchRes item) { + ToolLocationType type = item.getLocationType() == LocationType.BUILDING + ? ToolLocationType.BUILDING : ToolLocationType.PLACE; + Long buildingId = type == ToolLocationType.BUILDING ? item.getId() : item.getBuildingId(); + String buildingName = type == ToolLocationType.BUILDING ? item.getName() : null; + return new SearchCampusItem(item.getId(), type, item.getName(), buildingId, buildingName, + item.getFloor(), item.getPlaceType(), normalizeDetail(item.getDetail())); + } + + private LocationDetailToolData toBuildingDetail(SearchBuildingDetailRes detail) { + return new LocationDetailToolData(detail.getBuildingId(), ToolLocationType.BUILDING, + detail.getName(), detail.getBuildingId(), detail.getName(), null, detail.getAddress(), + normalizeDetail(detail.getDetails()), null, detail.isOperating(), detail.getNextBuildingTime(), + detail.getWeekdayOperatingTime(), detail.getSaturdayOperatingTime(), + detail.getSundayOperatingTime(), null, null, null, null); + } + + private LocationDetailToolData toPlaceDetail(SearchPlaceDetailRes detail) { + return new LocationDetailToolData(detail.getPlaceId(), ToolLocationType.PLACE, detail.getName(), + detail.getBuildingId(), null, detail.getFloor(), null, normalizeDetail(detail.getDetail()), + detail.getPlaceType(), detail.isOperating(), detail.getNextPlaceTime(), + detail.getWeekdayOperatingTime(), detail.getSaturdayOperatingTime(), + detail.getSundayOperatingTime(), detail.isAvailability(), detail.isPlugAvailability(), + null, parseMeaningfulRating(detail.getStarAverage())); + } + + private FacilityToolItem toFacilityItem(SearchPlaceRes item) { + return new FacilityToolItem(item.getId(), item.getName(), item.getPlaceType(), item.getBuildingId(), + item.getBuildingName(), toInteger(item.getFloor()), normalizeDetail(item.getDetail()), + item.isOperating(), null, item.getPlugAvailability()); + } + + private FacilityToolItem toBuildingFacilityItem(SearchFacilityRes item) { + return new FacilityToolItem(item.getId(), item.getName(), item.getPlaceType(), item.getBuildingId(), + null, item.getFloor(), normalizeDetail(item.getDetail()), item.isOperating(), + item.getAvailability(), null); + } + + private FacilityToolItem toFloorItem(SearchRoomDetailRes item, Long buildingId, Integer floor) { + return new FacilityToolItem(item.getId(), item.getName(), item.getPlaceType(), buildingId, null, floor, + normalizeDetail(item.getDetail()), item.isOperating(), item.isAvailability(), + item.isPlugAvailability()); + } + + private Integer resolveLimit(Integer requested, ChatbotProperties.Limits limits) { + if (requested != null && requested <= 0) { + return null; + } + int value = requested == null ? limits.defaultLimit() : requested; + return Math.min(value, limits.maxLimit()); + } + + private CampusToolError mapDomainError(GlobalException exception) { + if (exception.getResultCode() == NOT_FOUND_BUILDING || exception.getResultCode() == NOT_FOUND_PLACE) { + return error(NOT_FOUND); + } + return error(TEMPORARILY_UNAVAILABLE); + } + + private CampusToolError error(CampusToolErrorCode code) { + return CampusToolError.of(code); + } + + private String normalizeDetail(String detail) { + return detail == null || detail.isBlank() || ".".equals(detail) ? null : detail; + } + + private Integer toInteger(Double floor) { + return floor == null ? null : floor.intValue(); + } + + private Double parseMeaningfulRating(String rating) { + if (rating == null) { + return null; + } + try { + double value = Double.parseDouble(rating); + return Double.isFinite(value) ? value : null; + } catch (NumberFormatException exception) { + return null; + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusFacilityType.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusFacilityType.java new file mode 100644 index 00000000..6544939e --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusFacilityType.java @@ -0,0 +1,44 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +import devkor.com.teamcback.domain.place.entity.PlaceType; + +public enum CampusFacilityType { + CLASSROOM, + TOILET, + MEN_TOILET, + WOMEN_TOILET, + HANDICAPPED_TOILET, + MEN_HANDICAPPED_TOILET, + WOMEN_HANDICAPPED_TOILET, + VENDING_MACHINE, + WATER_PURIFIER, + PRINTER, + LOUNGE, + CAFE, + SMOKING_BOOTH, + CONVENIENCE_STORE, + CAFETERIA, + READING_ROOM, + STUDY_ROOM, + SLEEPING_ROOM, + SHOWER_ROOM, + LOCKER, + BANK, + TRASH_CAN, + GYM, + BICYCLE_RACK, + BENCH, + SHUTTLE_BUS, + BOOK_RETURN_MACHINE, + TUMBLER_WASHER, + ONESTOP_AUTO_MACHINE, + HEALTH_OFFICE, + DISABLED_PARKING, + BARRIER_FREE_ENTRANCE, + REUSABLE_CUP_RETURN, + PHARMACY; + + public PlaceType toPlaceType() { + return PlaceType.valueOf(name()); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusToolError.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusToolError.java new file mode 100644 index 00000000..d4ffe7ba --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusToolError.java @@ -0,0 +1,13 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public record CampusToolError(CampusToolErrorCode code, String message) { + public static CampusToolError of(CampusToolErrorCode code) { + return new CampusToolError(code, switch (code) { + case NOT_FOUND -> "조건에 맞는 캠퍼스 정보를 찾지 못했습니다."; + case AMBIGUOUS_LOCATION -> "여러 위치 후보가 있어 사용자의 확인이 필요합니다."; + case INVALID_INPUT -> "도구 입력값이 올바르지 않습니다."; + case UNSUPPORTED -> "지원하지 않는 위치 또는 시설 유형입니다."; + case TEMPORARILY_UNAVAILABLE -> "캠퍼스 정보를 일시적으로 조회할 수 없습니다."; + }); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusToolErrorCode.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusToolErrorCode.java new file mode 100644 index 00000000..c4e442ce --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusToolErrorCode.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public enum CampusToolErrorCode { + NOT_FOUND, + AMBIGUOUS_LOCATION, + INVALID_INPUT, + UNSUPPORTED, + TEMPORARILY_UNAVAILABLE +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FacilityToolItem.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FacilityToolItem.java new file mode 100644 index 00000000..83b64f46 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FacilityToolItem.java @@ -0,0 +1,19 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import devkor.com.teamcback.domain.place.entity.PlaceType; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record FacilityToolItem( + Long placeId, + String name, + PlaceType placeType, + Long buildingId, + String buildingName, + Integer floor, + String detail, + Boolean openNow, + Boolean available, + Boolean plugAvailable +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FindFacilitiesToolRequest.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FindFacilitiesToolRequest.java new file mode 100644 index 00000000..f5627643 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FindFacilitiesToolRequest.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public record FindFacilitiesToolRequest( + CampusFacilityType facilityType, + Long buildingId, + Integer floor, + Integer limit +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FindFacilitiesToolResult.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FindFacilitiesToolResult.java new file mode 100644 index 00000000..70ef4829 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FindFacilitiesToolResult.java @@ -0,0 +1,8 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record FindFacilitiesToolResult(List facilities, CampusToolError error) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetLocationDetailToolRequest.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetLocationDetailToolRequest.java new file mode 100644 index 00000000..1d5f8ceb --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetLocationDetailToolRequest.java @@ -0,0 +1,4 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public record GetLocationDetailToolRequest(ToolLocationType locationType, Long locationId) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetLocationDetailToolResult.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetLocationDetailToolResult.java new file mode 100644 index 00000000..2e10f9a9 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetLocationDetailToolResult.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record GetLocationDetailToolResult(LocationDetailToolData location, CampusToolError error) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/LocationDetailToolData.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/LocationDetailToolData.java new file mode 100644 index 00000000..c7fa4d8d --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/LocationDetailToolData.java @@ -0,0 +1,27 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import devkor.com.teamcback.domain.place.entity.PlaceType; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record LocationDetailToolData( + Long locationId, + ToolLocationType locationType, + String name, + Long buildingId, + String buildingName, + Integer floor, + String address, + String detail, + PlaceType placeType, + Boolean openNow, + String nextStatusChangeTime, + String weekdayHours, + String saturdayHours, + String sundayHours, + Boolean available, + Boolean plugAvailable, + Boolean studentCardRequired, + Double rating +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusItem.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusItem.java new file mode 100644 index 00000000..5fa00d82 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusItem.java @@ -0,0 +1,17 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import devkor.com.teamcback.domain.place.entity.PlaceType; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record SearchCampusItem( + Long locationId, + ToolLocationType locationType, + String name, + Long buildingId, + String buildingName, + Double floor, + PlaceType placeType, + String detail +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusToolRequest.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusToolRequest.java new file mode 100644 index 00000000..540df6f1 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusToolRequest.java @@ -0,0 +1,4 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public record SearchCampusToolRequest(String query, Integer limit) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusToolResult.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusToolResult.java new file mode 100644 index 00000000..3bb01807 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusToolResult.java @@ -0,0 +1,12 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record SearchCampusToolResult( + List candidates, + boolean ambiguous, + CampusToolError error +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/ToolLocationType.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/ToolLocationType.java new file mode 100644 index 00000000..91614da5 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/ToolLocationType.java @@ -0,0 +1,6 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public enum ToolLocationType { + BUILDING, + PLACE +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2bf10769..75da94eb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -181,6 +181,13 @@ chatbot: max-tool-calls-per-message: ${CHATBOT_MAX_TOOL_CALLS:6} history-turns: ${CHATBOT_HISTORY_TURNS:5} session-ttl-minutes: ${CHATBOT_SESSION_TTL_MINUTES:60} + tools: + search: + default-limit: ${CHATBOT_SEARCH_DEFAULT_LIMIT:5} + max-limit: ${CHATBOT_SEARCH_MAX_LIMIT:10} + facilities: + default-limit: ${CHATBOT_FACILITIES_DEFAULT_LIMIT:10} + max-limit: ${CHATBOT_FACILITIES_MAX_LIMIT:20} push: expo: diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java index 0a77e611..07787759 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java @@ -8,6 +8,7 @@ import static org.mockito.Mockito.when; import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import devkor.com.teamcback.domain.chatbot.tool.CampusChatbotTools; import devkor.com.teamcback.global.exception.exception.GlobalException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -32,8 +33,12 @@ void mapsProviderFailureWithoutExposingProviderDetails() { .thenThrow(new IllegalStateException("provider secret and internal details")); ChatbotProperties properties = new ChatbotProperties(true, new ChatbotProperties.Llm("google", "gemini-2.5-flash-lite", 500, 8), - new ChatbotProperties.Agent(6, 5, 60)); - SpringAiLlmGateway gateway = new SpringAiLlmGateway(builder, properties, executor); + new ChatbotProperties.Agent(6, 5, 60), + new ChatbotProperties.Tools( + new ChatbotProperties.Limits(5, 10), + new ChatbotProperties.Limits(10, 20))); + CampusChatbotTools tools = mock(CampusChatbotTools.class); + SpringAiLlmGateway gateway = new SpringAiLlmGateway(builder, properties, executor, tools); assertThatThrownBy(() -> gateway.generate("system", "hello")) .isInstanceOfSatisfying(GlobalException.class, exception -> { diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsTest.java new file mode 100644 index 00000000..928548b7 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsTest.java @@ -0,0 +1,17 @@ +package devkor.com.teamcback.domain.chatbot.tool; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import org.junit.jupiter.api.Test; +import org.springframework.ai.tool.annotation.Tool; + +class CampusChatbotToolsTest { + @Test + void registersExactlyTheThreePhaseTwoTools() { + assertThat(Arrays.stream(CampusChatbotTools.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(Tool.class)) + .map(method -> method.getAnnotation(Tool.class).name())) + .containsExactlyInAnyOrder("searchCampus", "getLocationDetail", "findFacilities"); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java new file mode 100644 index 00000000..ab0f0ff6 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java @@ -0,0 +1,255 @@ +package devkor.com.teamcback.domain.chatbot.tool; + +import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusFacilityType.TOILET; +import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.AMBIGUOUS_LOCATION; +import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.INVALID_INPUT; +import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.NOT_FOUND; +import static devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType.BUILDING; +import static devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType.PLACE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import devkor.com.teamcback.domain.chatbot.tool.dto.FacilityToolItem; +import devkor.com.teamcback.domain.chatbot.tool.dto.FindFacilitiesToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; +import devkor.com.teamcback.domain.common.LocationType; +import devkor.com.teamcback.domain.place.entity.PlaceType; +import devkor.com.teamcback.domain.search.dto.response.GlobalSearchListRes; +import devkor.com.teamcback.domain.search.dto.response.GlobalSearchRes; +import devkor.com.teamcback.domain.search.dto.response.SearchBuildingDetailRes; +import devkor.com.teamcback.domain.search.dto.response.SearchBuildingFacilityListRes; +import devkor.com.teamcback.domain.search.dto.response.SearchFacilityListRes; +import devkor.com.teamcback.domain.search.dto.response.SearchFacilityRes; +import devkor.com.teamcback.domain.search.dto.response.SearchFloorInfoRes; +import devkor.com.teamcback.domain.search.dto.response.SearchPlaceDetailRes; +import devkor.com.teamcback.domain.search.dto.response.SearchPlaceRes; +import devkor.com.teamcback.domain.search.dto.response.SearchRoomDetailRes; +import devkor.com.teamcback.domain.search.service.SearchService; +import java.lang.reflect.RecordComponent; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CampusToolAdapterTest { + @Mock + private SearchService searchService; + + private CampusToolAdapter adapter; + + @BeforeEach + void setUp() { + ChatbotProperties properties = new ChatbotProperties(true, + new ChatbotProperties.Llm("google", "gemini-2.5-flash-lite", 500, 8), + new ChatbotProperties.Agent(6, 5, 60), + new ChatbotProperties.Tools( + new ChatbotProperties.Limits(5, 10), + new ChatbotProperties.Limits(10, 20))); + adapter = new CampusToolAdapter(searchService, properties); + } + + @Test + void centralLibraryNicknameSearchReturnsBuildingCandidateWithoutPersonalization() { + GlobalSearchRes building = searchResult(11L, "중앙도서관", LocationType.BUILDING, null); + when(searchService.globalSearch("중도", null)).thenReturn(new GlobalSearchListRes(List.of(building))); + + var result = adapter.searchCampus(new SearchCampusToolRequest(" 중도 ", null)); + + assertThat(result.error()).isNull(); + assertThat(result.ambiguous()).isFalse(); + assertThat(result.candidates()).singleElement().satisfies(item -> { + assertThat(item.locationId()).isEqualTo(11L); + assertThat(item.locationType()).isEqualTo(BUILDING); + assertThat(item.name()).isEqualTo("중앙도서관"); + }); + verify(searchService).globalSearch("중도", null); + } + + @Test + void multipleSearchCandidatesRemainAmbiguousInsteadOfBeingSelected() { + GlobalSearchRes first = searchResult(11L, "중앙도서관", LocationType.BUILDING, null); + GlobalSearchRes second = searchResult(12L, "중앙도서관 신관", LocationType.BUILDING, null); + when(searchService.globalSearch("중앙도서관", null)) + .thenReturn(new GlobalSearchListRes(List.of(first, second))); + + var result = adapter.searchCampus(new SearchCampusToolRequest("중앙도서관", 1)); + + assertThat(result.ambiguous()).isTrue(); + assertThat(result.error().code()).isEqualTo(AMBIGUOUS_LOCATION); + assertThat(result.candidates()).extracting("locationId").containsExactly(11L); + } + + @Test + void searchFiltersVirtualFacilityAndEnforcesMaximumLimit() { + GlobalSearchRes virtualFacility = searchResult(null, "화장실", LocationType.FACILITY, PlaceType.TOILET); + List places = IntStream.rangeClosed(1, 12) + .mapToObj(id -> searchResult((long) id, "장소" + id, LocationType.PLACE, PlaceType.CAFE)) + .toList(); + List mixed = new java.util.ArrayList<>(); + mixed.add(virtualFacility); + mixed.addAll(places); + when(searchService.globalSearch("시설", null)).thenReturn(new GlobalSearchListRes(mixed)); + + var result = adapter.searchCampus(new SearchCampusToolRequest("시설", 100)); + + assertThat(result.candidates()).hasSize(10) + .allMatch(item -> item.locationId() != null && item.locationType() == PLACE); + } + + @Test + void blankSearchAndNoResultsReturnStableErrors() { + assertThat(adapter.searchCampus(new SearchCampusToolRequest(" ", null)).error().code()) + .isEqualTo(INVALID_INPUT); + verifyNoInteractions(searchService); + + when(searchService.globalSearch("없는 곳", null)).thenReturn(new GlobalSearchListRes(List.of())); + assertThat(adapter.searchCampus(new SearchCampusToolRequest("없는 곳", null)).error().code()) + .isEqualTo(NOT_FOUND); + } + + @Test + void centralLibraryOpenQuestionUsesBuildingDetailPath() { + SearchBuildingDetailRes detail = mock(SearchBuildingDetailRes.class); + when(detail.getBuildingId()).thenReturn(11L); + when(detail.getName()).thenReturn("고려대학교 중앙도서관"); + when(detail.isOperating()).thenReturn(true); + when(detail.getNextBuildingTime()).thenReturn("22:00"); + when(detail.getWeekdayOperatingTime()).thenReturn("09:00-22:00"); + when(searchService.searchBuildingDetail(null, 11L)).thenReturn(detail); + + var result = adapter.getLocationDetail(new GetLocationDetailToolRequest(BUILDING, 11L)); + + assertThat(result.error()).isNull(); + assertThat(result.location().openNow()).isTrue(); + assertThat(result.location().nextStatusChangeTime()).isEqualTo("22:00"); + verify(searchService).searchBuildingDetail(null, 11L); + verify(searchService, never()).searchPlaceDetail(null, 11L); + } + + @Test + void placeDetailMapsOperatingAndPlugFields() { + SearchPlaceDetailRes detail = mock(SearchPlaceDetailRes.class); + when(detail.getPlaceId()).thenReturn(31L); + when(detail.getBuildingId()).thenReturn(3L); + when(detail.getName()).thenReturn("하나스퀘어 카페"); + when(detail.getPlaceType()).thenReturn(PlaceType.CAFE); + when(detail.isOperating()).thenReturn(false); + when(detail.getNextPlaceTime()).thenReturn("09:00"); + when(detail.isAvailability()).thenReturn(true); + when(detail.isPlugAvailability()).thenReturn(true); + when(detail.getStarAverage()).thenReturn("4.25"); + when(searchService.searchPlaceDetail(null, 31L)).thenReturn(detail); + + var result = adapter.getLocationDetail(new GetLocationDetailToolRequest(PLACE, 31L)); + + assertThat(result.location().openNow()).isFalse(); + assertThat(result.location().available()).isTrue(); + assertThat(result.location().plugAvailable()).isTrue(); + assertThat(result.location().rating()).isEqualTo(4.25); + verify(searchService).searchPlaceDetail(null, 31L); + } + + @Test + void toolDetailOutputHasNoPrivateOrInternalFields() { + assertThat(recordFieldNames(devkor.com.teamcback.domain.chatbot.tool.dto.LocationDetailToolData.class)) + .doesNotContain("imageUrl", "longitude", "latitude", "xCoord", "yCoord", "maskIndex", + "bookmarked", "userId", "categoryColor", "nodeId"); + assertThat(recordFieldNames(devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusItem.class)) + .doesNotContain("imageUrl", "longitude", "latitude", "bookmarked", "userId", "categoryColor"); + } + + @Test + void studentCenterToiletUsesCampusWideTypeQuery() { + SearchPlaceRes toilet = mock(SearchPlaceRes.class); + when(toilet.getId()).thenReturn(21L); + when(toilet.getName()).thenReturn("학생회관 1층 화장실"); + when(toilet.getPlaceType()).thenReturn(PlaceType.TOILET); + when(toilet.getBuildingId()).thenReturn(5L); + when(toilet.getBuildingName()).thenReturn("학생회관"); + when(searchService.searchFacilitiesWithType(PlaceType.TOILET)) + .thenReturn(new SearchFacilityListRes(List.of(toilet))); + + var result = adapter.findFacilities(new FindFacilitiesToolRequest(TOILET, null, null, null)); + + assertThat(result.error()).isNull(); + assertThat(result.facilities()).singleElement().extracting(FacilityToolItem::buildingName) + .isEqualTo("학생회관"); + verify(searchService).searchFacilitiesWithType(PlaceType.TOILET); + } + + @Test + void buildingTypeQueryUsesBuildingFacilityService() { + SearchFacilityRes toilet = mock(SearchFacilityRes.class); + when(toilet.getId()).thenReturn(22L); + when(toilet.getPlaceType()).thenReturn(PlaceType.TOILET); + when(toilet.getBuildingId()).thenReturn(5L); + SearchBuildingFacilityListRes response = mock(SearchBuildingFacilityListRes.class); + when(response.getFacilities()).thenReturn(Map.of(1.0, List.of(toilet))); + when(searchService.searchBuildingFacilityByType(5L, PlaceType.TOILET)).thenReturn(response); + + var result = adapter.findFacilities(new FindFacilitiesToolRequest(TOILET, 5L, null, null)); + + assertThat(result.facilities()).singleElement().extracting(FacilityToolItem::placeId).isEqualTo(22L); + verify(searchService).searchBuildingFacilityByType(5L, PlaceType.TOILET); + } + + @Test + void buildingFloorQueryExcludesNavigationNodes() { + SearchRoomDetailRes room = mock(SearchRoomDetailRes.class); + when(room.getId()).thenReturn(41L); + when(room.getName()).thenReturn("101호"); + when(room.getPlaceType()).thenReturn(PlaceType.CLASSROOM); + SearchFloorInfoRes response = mock(SearchFloorInfoRes.class); + when(response.getRoomList()).thenReturn(List.of(room)); + when(searchService.searchPlaceByBuildingFloor(5L, 1)).thenReturn(response); + + var result = adapter.findFacilities(new FindFacilitiesToolRequest(null, 5L, 1, null)); + + assertThat(result.facilities()).singleElement().satisfies(item -> { + assertThat(item.placeId()).isEqualTo(41L); + assertThat(item.floor()).isEqualTo(1); + }); + verify(searchService).searchPlaceByBuildingFloor(5L, 1); + } + + @Test + void floorWithoutBuildingIsRejectedAndFacilityMaximumLimitIsEnforced() { + var invalid = adapter.findFacilities(new FindFacilitiesToolRequest(null, null, 1, null)); + assertThat(invalid.error().code()).isEqualTo(INVALID_INPUT); + verifyNoInteractions(searchService); + + List facilities = IntStream.range(0, 25).mapToObj(index -> mock(SearchPlaceRes.class)).toList(); + when(searchService.searchFacilitiesWithType(PlaceType.TOILET)) + .thenReturn(new SearchFacilityListRes(facilities)); + var limited = adapter.findFacilities(new FindFacilitiesToolRequest(TOILET, null, null, 100)); + assertThat(limited.facilities()).hasSize(20); + } + + private GlobalSearchRes searchResult(Long id, String name, LocationType type, PlaceType placeType) { + GlobalSearchRes result = mock(GlobalSearchRes.class); + lenient().when(result.getId()).thenReturn(id); + lenient().when(result.getName()).thenReturn(name); + lenient().when(result.getLocationType()).thenReturn(type); + lenient().when(result.getPlaceType()).thenReturn(placeType); + return result; + } + + private List recordFieldNames(Class type) { + return IntStream.range(0, type.getRecordComponents().length) + .mapToObj(index -> type.getRecordComponents()[index]) + .map(RecordComponent::getName) + .toList(); + } +} From 531493433e14a453bb9fdf89bdd9c678c45fd1c6 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Wed, 23 Sep 2026 01:03:12 +0900 Subject: [PATCH 03/21] feat(chatbot): add campus route tool --- .../domain/chatbot/service/ChatService.java | 17 +- .../chatbot/tool/CampusChatbotTools.java | 8 + .../chatbot/tool/CampusToolAdapter.java | 109 ++++++++++++- .../chatbot/tool/dto/FindRouteToolData.java | 6 + .../tool/dto/FindRouteToolRequest.java | 6 + .../chatbot/tool/dto/FindRouteToolResult.java | 4 + .../chatbot/tool/dto/RouteCondition.java | 5 + .../chatbot/tool/dto/RouteEndpoint.java | 4 + .../chatbot/tool/dto/RouteEndpointType.java | 5 + .../chatbot/tool/dto/RouteSectionType.java | 5 + .../domain/chatbot/tool/dto/RouteStep.java | 4 + .../ChatServiceCurrentLocationTest.java | 42 +++++ .../chatbot/tool/CampusChatbotToolsTest.java | 4 +- .../tool/CampusRouteToolAdapterTest.java | 149 ++++++++++++++++++ .../chatbot/tool/CampusToolAdapterTest.java | 6 +- 15 files changed, 369 insertions(+), 5 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FindRouteToolData.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FindRouteToolRequest.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FindRouteToolResult.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RouteCondition.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RouteEndpoint.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RouteEndpointType.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RouteSectionType.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RouteStep.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceCurrentLocationTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRouteToolAdapterTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java index f7a378ac..811a3146 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java @@ -1,6 +1,7 @@ package devkor.com.teamcback.domain.chatbot.service; import devkor.com.teamcback.domain.chatbot.dto.request.ChatMessageReq; +import devkor.com.teamcback.domain.chatbot.dto.request.CurrentLocationReq; import devkor.com.teamcback.domain.chatbot.dto.response.ChatMessageRes; import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; import java.util.UUID; @@ -19,12 +20,26 @@ public class ChatService { 개인정보, 인증 정보, 내부 식별자는 노출하지 마세요. 학사 규정, 졸업 요건, 장학금 및 RAG 기반 지식 질의는 V1 지원 범위가 아닙니다. 한국어로 간결하고 실용적으로 답하세요. + Use campus tools for dynamic campus facts. If a route endpoint ID is unknown, call searchCampus first. + Never choose an ambiguous location candidate. Request currentLocation is valid only for this request. + Never repeat raw latitude or longitude in the final answer. + BARRIERFREE only means stair nodes are excluded; do not claim complete wheelchair accessibility. """; private final LlmGateway llmGateway; public ChatMessageRes sendMessage(ChatMessageReq request) { UUID sessionId = request.sessionId() == null ? UUID.randomUUID() : request.sessionId(); - return new ChatMessageRes(sessionId, llmGateway.generate(SYSTEM_PROMPT, request.message())); + return new ChatMessageRes(sessionId, llmGateway.generate(SYSTEM_PROMPT, messageWithRequestContext(request))); + } + + private String messageWithRequestContext(ChatMessageReq request) { + if (request.context() == null || request.context().currentLocation() == null) { + return request.message(); + } + CurrentLocationReq location = request.context().currentLocation(); + return request.message() + "\n\n[REQUEST_CONTEXT: currentLocation is available only for this request; " + + "use start/end type COORD when needed; latitude=" + location.latitude() + + ", longitude=" + location.longitude() + "; never reveal these raw coordinates]"; } } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java index f732ccd5..7b82a0ec 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java @@ -2,6 +2,8 @@ import devkor.com.teamcback.domain.chatbot.tool.dto.FindFacilitiesToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.FindFacilitiesToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; @@ -37,4 +39,10 @@ public FindFacilitiesToolResult findFacilities( @ToolParam(description = "시설 유형, 건물 ID, 층, 선택적 결과 제한") FindFacilitiesToolRequest request) { return adapter.findFacilities(request); } + + @Tool(name = "findRoute", description = "Find a campus route between BUILDING/PLACE IDs or request-scoped COORD coordinates. Use searchCampus first when an ID is unknown and never select an ambiguous candidate. BARRIERFREE only excludes stair nodes and is not a complete accessibility guarantee.") + public FindRouteToolResult findRoute( + @ToolParam(description = "Start, end, and optional BARRIERFREE/SHUTTLE/STUDENTCARD/OPERATING conditions") FindRouteToolRequest request) { + return adapter.findRoute(request); + } } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java index 8e837461..00b8c0da 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java @@ -4,8 +4,14 @@ import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.INVALID_INPUT; import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.NOT_FOUND; import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.TEMPORARILY_UNAVAILABLE; +import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.UNSUPPORTED; +import static devkor.com.teamcback.global.response.ResultCode.COORDINATES_TOO_FAR; +import static devkor.com.teamcback.global.response.ResultCode.COORDINATES_TOO_NEAR; import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_BUILDING; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_NODE; import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_PLACE; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_ROUTE; +import static devkor.com.teamcback.global.response.ResultCode.NOT_PROVIDED_ROUTE; import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; import devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolError; @@ -13,6 +19,9 @@ import devkor.com.teamcback.domain.chatbot.tool.dto.FacilityToolItem; import devkor.com.teamcback.domain.chatbot.tool.dto.FindFacilitiesToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.FindFacilitiesToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolData; +import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.LocationDetailToolData; @@ -20,7 +29,15 @@ import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteCondition; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteEndpoint; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteSectionType; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteStep; import devkor.com.teamcback.domain.common.LocationType; +import devkor.com.teamcback.domain.routes.dto.response.GetRouteRes; +import devkor.com.teamcback.domain.routes.dto.response.PartialRouteRes; +import devkor.com.teamcback.domain.routes.entity.Conditions; +import devkor.com.teamcback.domain.routes.service.RouteService; import devkor.com.teamcback.domain.search.dto.response.GlobalSearchRes; import devkor.com.teamcback.domain.search.dto.response.SearchBuildingDetailRes; import devkor.com.teamcback.domain.search.dto.response.SearchFacilityRes; @@ -28,6 +45,7 @@ import devkor.com.teamcback.domain.search.dto.response.SearchPlaceRes; import devkor.com.teamcback.domain.search.dto.response.SearchRoomDetailRes; import devkor.com.teamcback.domain.search.service.SearchService; +import devkor.com.teamcback.global.exception.exception.AdminException; import devkor.com.teamcback.global.exception.exception.GlobalException; import java.util.Comparator; import java.util.List; @@ -39,10 +57,12 @@ @ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") public class CampusToolAdapter { private final SearchService searchService; + private final RouteService routeService; private final ChatbotProperties properties; - public CampusToolAdapter(SearchService searchService, ChatbotProperties properties) { + public CampusToolAdapter(SearchService searchService, RouteService routeService, ChatbotProperties properties) { this.searchService = searchService; + this.routeService = routeService; this.properties = properties; } @@ -131,6 +151,93 @@ public FindFacilitiesToolResult findFacilities(FindFacilitiesToolRequest request } } + public FindRouteToolResult findRoute(FindRouteToolRequest request) { + if (!validRouteRequest(request)) { + return new FindRouteToolResult(null, error(INVALID_INPUT)); + } + + try { + RouteEndpoint start = request.start(); + RouteEndpoint end = request.end(); + List conditions = request.conditions() == null ? List.of() + : request.conditions().stream().map(this::toDomainCondition).toList(); + List routes = routeService.findRoute( + toDomainType(start), start.locationId(), start.latitude(), start.longitude(), + toDomainType(end), end.locationId(), end.latitude(), end.longitude(), conditions); + if (routes == null || routes.isEmpty()) { + return new FindRouteToolResult(null, error(NOT_FOUND)); + } + return new FindRouteToolResult(toRouteData(routes.get(0)), null); + } catch (AdminException exception) { + return new FindRouteToolResult(null, error(TEMPORARILY_UNAVAILABLE)); + } catch (GlobalException exception) { + return new FindRouteToolResult(null, mapRouteError(exception)); + } catch (RuntimeException exception) { + return new FindRouteToolResult(null, error(TEMPORARILY_UNAVAILABLE)); + } + } + + private boolean validRouteRequest(FindRouteToolRequest request) { + if (request == null || !validEndpoint(request.start()) || !validEndpoint(request.end())) { + return false; + } + return request.conditions() == null + || request.conditions().stream().allMatch(java.util.Objects::nonNull); + } + + private boolean validEndpoint(RouteEndpoint endpoint) { + if (endpoint == null || endpoint.type() == null) { + return false; + } + return switch (endpoint.type()) { + case BUILDING, PLACE -> endpoint.locationId() != null && endpoint.locationId() > 0 + && endpoint.latitude() == null && endpoint.longitude() == null; + case COORD -> endpoint.locationId() == null && validLatitude(endpoint.latitude()) + && validLongitude(endpoint.longitude()); + }; + } + + private boolean validLatitude(Double value) { + return value != null && Double.isFinite(value) && value >= -90.0 && value <= 90.0; + } + + private boolean validLongitude(Double value) { + return value != null && Double.isFinite(value) && value >= -180.0 && value <= 180.0; + } + + private devkor.com.teamcback.domain.routes.entity.LocationType toDomainType(RouteEndpoint endpoint) { + return devkor.com.teamcback.domain.routes.entity.LocationType.valueOf(endpoint.type().name()); + } + + private Conditions toDomainCondition(RouteCondition condition) { + return Conditions.valueOf(condition.name()); + } + + private FindRouteToolData toRouteData(GetRouteRes route) { + List steps = route.getPath() == null ? List.of() + : route.getPath().stream().map(this::toRouteStep).toList(); + return new FindRouteToolData(route.getDuration(), steps); + } + + private RouteStep toRouteStep(PartialRouteRes step) { + boolean indoor = step.inOut; + return new RouteStep(indoor ? RouteSectionType.INDOOR : RouteSectionType.OUTDOOR, + indoor ? step.buildingId : null, indoor ? step.floor : null, normalizeDetail(step.info)); + } + + private CampusToolError mapRouteError(GlobalException exception) { + if (exception.getResultCode() == NOT_FOUND_ROUTE || exception.getResultCode() == NOT_FOUND_NODE + || exception.getResultCode() == NOT_FOUND_BUILDING || exception.getResultCode() == NOT_FOUND_PLACE + || exception.getResultCode() == COORDINATES_TOO_FAR) { + return error(NOT_FOUND); + } + if (exception.getResultCode() == NOT_PROVIDED_ROUTE + || exception.getResultCode() == COORDINATES_TOO_NEAR) { + return error(UNSUPPORTED); + } + return error(TEMPORARILY_UNAVAILABLE); + } + private boolean validFacilityRequest(FindFacilitiesToolRequest request) { if (request == null) { return false; diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FindRouteToolData.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FindRouteToolData.java new file mode 100644 index 00000000..2bd4c6fc --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FindRouteToolData.java @@ -0,0 +1,6 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +import java.util.List; + +public record FindRouteToolData(Long estimatedDurationSeconds, List steps) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FindRouteToolRequest.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FindRouteToolRequest.java new file mode 100644 index 00000000..9723e269 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FindRouteToolRequest.java @@ -0,0 +1,6 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +import java.util.List; + +public record FindRouteToolRequest(RouteEndpoint start, RouteEndpoint end, List conditions) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FindRouteToolResult.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FindRouteToolResult.java new file mode 100644 index 00000000..106266a5 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/FindRouteToolResult.java @@ -0,0 +1,4 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public record FindRouteToolResult(FindRouteToolData route, CampusToolError error) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RouteCondition.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RouteCondition.java new file mode 100644 index 00000000..88c10295 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RouteCondition.java @@ -0,0 +1,5 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public enum RouteCondition { + BARRIERFREE, SHUTTLE, STUDENTCARD, OPERATING +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RouteEndpoint.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RouteEndpoint.java new file mode 100644 index 00000000..9b8a354d --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RouteEndpoint.java @@ -0,0 +1,4 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public record RouteEndpoint(RouteEndpointType type, Long locationId, Double latitude, Double longitude) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RouteEndpointType.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RouteEndpointType.java new file mode 100644 index 00000000..307ce7dc --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RouteEndpointType.java @@ -0,0 +1,5 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public enum RouteEndpointType { + BUILDING, PLACE, COORD +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RouteSectionType.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RouteSectionType.java new file mode 100644 index 00000000..d44bcee1 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RouteSectionType.java @@ -0,0 +1,5 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public enum RouteSectionType { + INDOOR, OUTDOOR +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RouteStep.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RouteStep.java new file mode 100644 index 00000000..2d207f33 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RouteStep.java @@ -0,0 +1,4 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public record RouteStep(RouteSectionType sectionType, Long buildingId, Double floor, String instruction) { +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceCurrentLocationTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceCurrentLocationTest.java new file mode 100644 index 00000000..f4cd8805 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceCurrentLocationTest.java @@ -0,0 +1,42 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.chatbot.dto.request.ChatContextReq; +import devkor.com.teamcback.domain.chatbot.dto.request.ChatMessageReq; +import devkor.com.teamcback.domain.chatbot.dto.request.CurrentLocationReq; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ChatServiceCurrentLocationTest { + @Mock LlmGateway llmGateway; + + @Test + void passesCurrentLocationOnlyInCurrentProviderRequestWithoutAddingMemoryState() { + when(llmGateway.generate(anyString(), anyString())).thenReturn("경로 안내"); + ChatService service = new ChatService(llmGateway); + UUID sessionId = UUID.randomUUID(); + + service.sendMessage(new ChatMessageReq(sessionId, "현재 위치에서 중도까지 가줘", + new ChatContextReq(new CurrentLocationReq(37.5861, 127.0290)))); + service.sendMessage(new ChatMessageReq(sessionId, "다시 알려줘", null)); + + ArgumentCaptor messages = ArgumentCaptor.forClass(String.class); + verify(llmGateway, org.mockito.Mockito.times(2)).generate(anyString(), messages.capture()); + assertThat(messages.getAllValues().get(0)) + .contains("현재 위치에서 중도까지 가줘", "latitude=37.5861", "longitude=127.029"); + assertThat(messages.getAllValues().get(1)).isEqualTo("다시 알려줘") + .doesNotContain("37.5861", "127.029", "currentLocation"); + assertThat(ChatService.class.getDeclaredFields()).extracting(java.lang.reflect.Field::getName) + .doesNotContain("currentLocation", "latitude", "longitude", "memory"); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsTest.java index 928548b7..fbd0f8db 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsTest.java @@ -8,10 +8,10 @@ class CampusChatbotToolsTest { @Test - void registersExactlyTheThreePhaseTwoTools() { + void registersExactlyThePhaseThreeTools() { assertThat(Arrays.stream(CampusChatbotTools.class.getDeclaredMethods()) .filter(method -> method.isAnnotationPresent(Tool.class)) .map(method -> method.getAnnotation(Tool.class).name())) - .containsExactlyInAnyOrder("searchCampus", "getLocationDetail", "findFacilities"); + .containsExactlyInAnyOrder("searchCampus", "getLocationDetail", "findFacilities", "findRoute"); } } diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRouteToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRouteToolAdapterTest.java new file mode 100644 index 00000000..6de1cd66 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRouteToolAdapterTest.java @@ -0,0 +1,149 @@ +package devkor.com.teamcback.domain.chatbot.tool; + +import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.INVALID_INPUT; +import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.TEMPORARILY_UNAVAILABLE; +import static devkor.com.teamcback.domain.chatbot.tool.dto.RouteCondition.BARRIERFREE; +import static devkor.com.teamcback.domain.chatbot.tool.dto.RouteEndpointType.BUILDING; +import static devkor.com.teamcback.domain.chatbot.tool.dto.RouteEndpointType.COORD; +import static devkor.com.teamcback.domain.chatbot.tool.dto.RouteEndpointType.PLACE; +import static devkor.com.teamcback.domain.routes.entity.Conditions.OPERATING; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteCondition; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteEndpoint; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteEndpointType; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteSectionType; +import devkor.com.teamcback.domain.routes.dto.response.GetRouteRes; +import devkor.com.teamcback.domain.routes.dto.response.PartialRouteRes; +import devkor.com.teamcback.domain.routes.entity.Conditions; +import devkor.com.teamcback.domain.routes.entity.LocationType; +import devkor.com.teamcback.domain.routes.service.RouteService; +import devkor.com.teamcback.domain.search.service.SearchService; +import devkor.com.teamcback.global.exception.exception.AdminException; +import devkor.com.teamcback.global.response.ResultCode; +import java.lang.reflect.RecordComponent; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CampusRouteToolAdapterTest { + @Mock SearchService searchService; + @Mock RouteService routeService; + private CampusToolAdapter adapter; + + @BeforeEach + void setUp() { + ChatbotProperties properties = new ChatbotProperties(true, + new ChatbotProperties.Llm("google", "gemini-2.5-flash-lite", 500, 8), + new ChatbotProperties.Agent(6, 5, 60), + new ChatbotProperties.Tools(new ChatbotProperties.Limits(5, 10), + new ChatbotProperties.Limits(10, 20))); + adapter = new CampusToolAdapter(searchService, routeService, properties); + } + + @Test + void mapsBuildingRouteAndRemovesRawCoordinatesAndNodeIds() { + PartialRouteRes outdoor = new PartialRouteRes(List.of(List.of(37.5, 127.0, 999.0))); + outdoor.setInfo("중앙도서관으로 이동하세요"); + when(routeService.findRoute(LocationType.BUILDING, 5L, null, null, + LocationType.BUILDING, 11L, null, null, List.of())) + .thenReturn(List.of(new GetRouteRes(420L, List.of(outdoor)))); + + var result = adapter.findRoute(request(endpoint(BUILDING, 5L), endpoint(BUILDING, 11L), List.of())); + + assertThat(result.error()).isNull(); + assertThat(result.route().estimatedDurationSeconds()).isEqualTo(420L); + assertThat(result.route().steps()).singleElement().satisfies(step -> { + assertThat(step.sectionType()).isEqualTo(RouteSectionType.OUTDOOR); + assertThat(step.buildingId()).isNull(); + assertThat(step.floor()).isNull(); + assertThat(step.instruction()).isEqualTo("중앙도서관으로 이동하세요"); + }); + assertThat(Arrays.stream(result.route().getClass().getRecordComponents()).map(RecordComponent::getName)) + .doesNotContain("route", "coordinates", "nodeId", "path"); + assertThat(Arrays.stream(result.route().steps().get(0).getClass().getRecordComponents()) + .map(RecordComponent::getName)).doesNotContain("route", "coordinates", "nodeId"); + } + + @Test + void mapsPlaceToBuildingEndpointTypes() { + when(routeService.findRoute(LocationType.PLACE, 31L, null, null, + LocationType.BUILDING, 11L, null, null, List.of(OPERATING))) + .thenReturn(List.of(new GetRouteRes(100L, List.of()))); + + var result = adapter.findRoute(request(endpoint(PLACE, 31L), endpoint(BUILDING, 11L), + List.of(RouteCondition.OPERATING))); + + assertThat(result.error()).isNull(); + verify(routeService).findRoute(LocationType.PLACE, 31L, null, null, + LocationType.BUILDING, 11L, null, null, List.of(OPERATING)); + } + + @Test + void mapsCurrentCoordinateAndBarrierFreeConditionExactly() { + RouteEndpoint start = new RouteEndpoint(COORD, null, 37.5861, 127.0290); + when(routeService.findRoute(LocationType.COORD, null, 37.5861, 127.0290, + LocationType.BUILDING, 11L, null, null, List.of(Conditions.BARRIERFREE))) + .thenReturn(List.of(new GetRouteRes(300L, List.of()))); + + var result = adapter.findRoute(request(start, endpoint(BUILDING, 11L), List.of(BARRIERFREE))); + + assertThat(result.error()).isNull(); + verify(routeService).findRoute(LocationType.COORD, null, 37.5861, 127.0290, + LocationType.BUILDING, 11L, null, null, List.of(Conditions.BARRIERFREE)); + } + + @Test + void rejectsInvalidCoordinateAndMixedEndpointFieldsBeforeDomainCall() { + var missingLongitude = new RouteEndpoint(COORD, null, 37.5, null); + var mixedBuilding = new RouteEndpoint(BUILDING, 5L, 37.5, 127.0); + + assertThat(adapter.findRoute(request(missingLongitude, endpoint(BUILDING, 11L), List.of())) + .error().code()).isEqualTo(INVALID_INPUT); + assertThat(adapter.findRoute(request(mixedBuilding, endpoint(BUILDING, 11L), List.of())) + .error().code()).isEqualTo(INVALID_INPUT); + verify(routeService, never()).findRoute(any(), any(), any(), any(), any(), any(), any(), any(), anyList()); + } + + @Test + void toolEnumsDoNotExposeNodeOrInnerRoute() { + assertThat(RouteEndpointType.values()).extracting(Enum::name) + .containsExactlyInAnyOrder("BUILDING", "PLACE", "COORD"); + assertThat(RouteCondition.values()).extracting(Enum::name) + .containsExactlyInAnyOrder("BARRIERFREE", "SHUTTLE", "STUDENTCARD", "OPERATING"); + } + + @Test + void internalRouteFailureDoesNotLeakAdminDetails() { + when(routeService.findRoute(eq(LocationType.BUILDING), eq(5L), isNull(), isNull(), + eq(LocationType.BUILDING), eq(11L), isNull(), isNull(), anyList())) + .thenThrow(new AdminException(ResultCode.INCORRECT_NODE_DATA, "node 999 database secret")); + + var result = adapter.findRoute(request(endpoint(BUILDING, 5L), endpoint(BUILDING, 11L), List.of())); + + assertThat(result.error().code()).isEqualTo(TEMPORARILY_UNAVAILABLE); + assertThat(result.error().message()).doesNotContain("999", "database", "secret"); + } + + private FindRouteToolRequest request(RouteEndpoint start, RouteEndpoint end, List conditions) { + return new FindRouteToolRequest(start, end, conditions); + } + + private RouteEndpoint endpoint(RouteEndpointType type, Long id) { + return new RouteEndpoint(type, id, null, null); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java index ab0f0ff6..6b60c88e 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java @@ -32,6 +32,7 @@ import devkor.com.teamcback.domain.search.dto.response.SearchPlaceRes; import devkor.com.teamcback.domain.search.dto.response.SearchRoomDetailRes; import devkor.com.teamcback.domain.search.service.SearchService; +import devkor.com.teamcback.domain.routes.service.RouteService; import java.lang.reflect.RecordComponent; import java.util.List; import java.util.Map; @@ -46,6 +47,8 @@ class CampusToolAdapterTest { @Mock private SearchService searchService; + @Mock + private RouteService routeService; private CampusToolAdapter adapter; @@ -57,7 +60,7 @@ void setUp() { new ChatbotProperties.Tools( new ChatbotProperties.Limits(5, 10), new ChatbotProperties.Limits(10, 20))); - adapter = new CampusToolAdapter(searchService, properties); + adapter = new CampusToolAdapter(searchService, routeService, properties); } @Test @@ -89,6 +92,7 @@ void multipleSearchCandidatesRemainAmbiguousInsteadOfBeingSelected() { assertThat(result.ambiguous()).isTrue(); assertThat(result.error().code()).isEqualTo(AMBIGUOUS_LOCATION); assertThat(result.candidates()).extracting("locationId").containsExactly(11L); + verifyNoInteractions(routeService); } @Test From 03b332a3ae446e9cc9649a9f9dcfe544c270e85c Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Wed, 23 Sep 2026 01:12:42 +0900 Subject: [PATCH 04/21] feat(chatbot): add campus life tools --- .../chatbot/config/ChatbotProperties.java | 2 +- .../chatbot/tool/CampusChatbotTools.java | 22 ++ .../chatbot/tool/CampusToolAdapter.java | 160 +++++++++++++- .../tool/dto/CafeteriaMealToolItem.java | 4 + .../chatbot/tool/dto/CafeteriaMenuDay.java | 7 + .../tool/dto/CampusStatusToolResult.java | 5 + .../chatbot/tool/dto/CampusToolError.java | 1 + .../chatbot/tool/dto/CampusToolErrorCode.java | 1 + .../tool/dto/GetCafeteriaMenuToolData.java | 6 + .../tool/dto/GetCafeteriaMenuToolRequest.java | 6 + .../tool/dto/GetCafeteriaMenuToolResult.java | 4 + .../tool/dto/GetRoomCoursesToolData.java | 7 + .../tool/dto/GetRoomCoursesToolRequest.java | 6 + .../tool/dto/GetRoomCoursesToolResult.java | 4 + .../chatbot/tool/dto/RoomCourseToolItem.java | 7 + src/main/resources/application.yml | 1 + .../gateway/SpringAiLlmGatewayTest.java | 2 +- .../chatbot/tool/CampusChatbotToolsTest.java | 8 +- .../tool/CampusLifeToolAdapterTest.java | 205 ++++++++++++++++++ .../tool/CampusRouteToolAdapterTest.java | 4 +- .../chatbot/tool/CampusToolAdapterTest.java | 4 +- 21 files changed, 458 insertions(+), 8 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CafeteriaMealToolItem.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CafeteriaMenuDay.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusStatusToolResult.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetCafeteriaMenuToolData.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetCafeteriaMenuToolRequest.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetCafeteriaMenuToolResult.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetRoomCoursesToolData.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetRoomCoursesToolRequest.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetRoomCoursesToolResult.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RoomCourseToolItem.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusLifeToolAdapterTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java b/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java index c04f939b..7cad8a81 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java @@ -14,7 +14,7 @@ public Duration timeout() { public record Agent(int maxToolCallsPerMessage, int historyTurns, int sessionTtlMinutes) { } - public record Tools(Limits search, Limits facilities) { + public record Tools(Limits search, Limits facilities, int menuMaxDays) { } public record Limits(int defaultLimit, int maxLimit) { diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java index 7b82a0ec..093242b6 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java @@ -4,8 +4,13 @@ import devkor.com.teamcback.domain.chatbot.tool.dto.FindFacilitiesToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.CampusStatusToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetCafeteriaMenuToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetCafeteriaMenuToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetRoomCoursesToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetRoomCoursesToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolResult; import org.springframework.ai.tool.annotation.Tool; @@ -45,4 +50,21 @@ public FindRouteToolResult findRoute( @ToolParam(description = "Start, end, and optional BARRIERFREE/SHUTTLE/STUDENTCARD/OPERATING conditions") FindRouteToolRequest request) { return adapter.findRoute(request); } + + @Tool(name = "getCafeteriaMenu", description = "교내 식당의 지정 날짜 또는 최대 7일 메뉴를 조회합니다. 일반 카페 상품 메뉴 조회에는 사용하지 않습니다.") + public GetCafeteriaMenuToolResult getCafeteriaMenu( + @ToolParam(description = "식당 PLACE ID와 조회 시작일, 선택적 종료일") GetCafeteriaMenuToolRequest request) { + return adapter.getCafeteriaMenu(request); + } + + @Tool(name = "getRoomCourses", description = "특정 강의실의 현재 학기 수업 일정을 조회합니다. 개인 시간표나 사용자 수강정보에는 사용하지 않습니다.") + public GetRoomCoursesToolResult getRoomCourses( + @ToolParam(description = "강의실 PLACE ID와 선택적 요일") GetRoomCoursesToolRequest request) { + return adapter.getRoomCourses(request); + } + + @Tool(name = "getCampusStatus", description = "현재 학기, 방학 여부, 고연전 기간 여부를 조회합니다. 건물 운영시간이나 과거·미래 일정 조회에는 사용하지 않습니다.") + public CampusStatusToolResult getCampusStatus() { + return adapter.getCampusStatus(); + } } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java index 00b8c0da..4ce283bf 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java @@ -3,6 +3,7 @@ import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.AMBIGUOUS_LOCATION; import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.INVALID_INPUT; import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.NOT_FOUND; +import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.NO_DATA; import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.TEMPORARILY_UNAVAILABLE; import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.UNSUPPORTED; import static devkor.com.teamcback.global.response.ResultCode.COORDINATES_TOO_FAR; @@ -16,6 +17,9 @@ import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; import devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolError; import devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode; +import devkor.com.teamcback.domain.chatbot.tool.dto.CampusStatusToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.CafeteriaMealToolItem; +import devkor.com.teamcback.domain.chatbot.tool.dto.CafeteriaMenuDay; import devkor.com.teamcback.domain.chatbot.tool.dto.FacilityToolItem; import devkor.com.teamcback.domain.chatbot.tool.dto.FindFacilitiesToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.FindFacilitiesToolResult; @@ -24,6 +28,12 @@ import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetCafeteriaMenuToolData; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetCafeteriaMenuToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetCafeteriaMenuToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetRoomCoursesToolData; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetRoomCoursesToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetRoomCoursesToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.LocationDetailToolData; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusItem; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; @@ -33,7 +43,14 @@ import devkor.com.teamcback.domain.chatbot.tool.dto.RouteEndpoint; import devkor.com.teamcback.domain.chatbot.tool.dto.RouteSectionType; import devkor.com.teamcback.domain.chatbot.tool.dto.RouteStep; +import devkor.com.teamcback.domain.chatbot.tool.dto.RoomCourseToolItem; import devkor.com.teamcback.domain.common.LocationType; +import devkor.com.teamcback.domain.common.entity.Weekday; +import devkor.com.teamcback.domain.course.dto.response.GetCourseListRes; +import devkor.com.teamcback.domain.course.dto.response.GetCourseRes; +import devkor.com.teamcback.domain.course.service.CourseService; +import devkor.com.teamcback.domain.place.dto.response.GetCafeteriaMenuListRes; +import devkor.com.teamcback.domain.place.service.CafeteriaMenuService; import devkor.com.teamcback.domain.routes.dto.response.GetRouteRes; import devkor.com.teamcback.domain.routes.dto.response.PartialRouteRes; import devkor.com.teamcback.domain.routes.entity.Conditions; @@ -45,11 +62,17 @@ import devkor.com.teamcback.domain.search.dto.response.SearchPlaceRes; import devkor.com.teamcback.domain.search.dto.response.SearchRoomDetailRes; import devkor.com.teamcback.domain.search.service.SearchService; +import devkor.com.teamcback.domain.schoolcalendar.service.SchoolCalendarService; import devkor.com.teamcback.global.exception.exception.AdminException; import devkor.com.teamcback.global.exception.exception.GlobalException; import java.util.Comparator; +import java.time.DateTimeException; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -58,11 +81,19 @@ public class CampusToolAdapter { private final SearchService searchService; private final RouteService routeService; + private final CafeteriaMenuService cafeteriaMenuService; + private final CourseService courseService; + private final SchoolCalendarService schoolCalendarService; private final ChatbotProperties properties; - public CampusToolAdapter(SearchService searchService, RouteService routeService, ChatbotProperties properties) { + public CampusToolAdapter(SearchService searchService, RouteService routeService, + CafeteriaMenuService cafeteriaMenuService, CourseService courseService, + SchoolCalendarService schoolCalendarService, ChatbotProperties properties) { this.searchService = searchService; this.routeService = routeService; + this.cafeteriaMenuService = cafeteriaMenuService; + this.courseService = courseService; + this.schoolCalendarService = schoolCalendarService; this.properties = properties; } @@ -238,6 +269,133 @@ private CampusToolError mapRouteError(GlobalException exception) { return error(TEMPORARILY_UNAVAILABLE); } + public GetCafeteriaMenuToolResult getCafeteriaMenu(GetCafeteriaMenuToolRequest request) { + if (request == null || request.placeId() == null || request.placeId() <= 0 + || request.startDate() == null) { + return new GetCafeteriaMenuToolResult(null, error(INVALID_INPUT)); + } + LocalDate endDate = request.endDate() == null ? request.startDate() : request.endDate(); + long inclusiveDays = ChronoUnit.DAYS.between(request.startDate(), endDate) + 1; + if (inclusiveDays <= 0 || inclusiveDays > properties.tools().menuMaxDays()) { + return new GetCafeteriaMenuToolResult(null, error(INVALID_INPUT)); + } + + try { + GetCafeteriaMenuListRes response = cafeteriaMenuService.getCafeteriaMenu( + request.placeId(), request.startDate(), endDate.plusDays(1)); + List days = response.getMenus().entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> new CafeteriaMenuDay(entry.getKey(), toMeals(entry.getValue()))) + .toList(); + if (days.stream().allMatch(day -> day.meals().isEmpty())) { + return new GetCafeteriaMenuToolResult(null, error(NO_DATA)); + } + return new GetCafeteriaMenuToolResult( + new GetCafeteriaMenuToolData(response.getPlaceId(), response.getPlaceName(), days), null); + } catch (DateTimeException exception) { + return new GetCafeteriaMenuToolResult(null, error(INVALID_INPUT)); + } catch (GlobalException exception) { + return new GetCafeteriaMenuToolResult(null, mapDomainError(exception)); + } catch (RuntimeException exception) { + return new GetCafeteriaMenuToolResult(null, error(TEMPORARILY_UNAVAILABLE)); + } + } + + public GetRoomCoursesToolResult getRoomCourses(GetRoomCoursesToolRequest request) { + if (request == null || request.placeId() == null || request.placeId() <= 0) { + return new GetRoomCoursesToolResult(null, error(INVALID_INPUT)); + } + try { + GetCourseListRes response = courseService.getCourseList(request.placeId()); + List allRows = response.getCourses().values().stream().flatMap(List::stream).toList(); + List selectedRows = response.getCourses().entrySet().stream() + .filter(entry -> request.weekday() == null || entry.getKey() == request.weekday()) + .sorted(Map.Entry.comparingByKey()) + .flatMap(entry -> entry.getValue().stream()) + .toList(); + if (selectedRows.isEmpty()) { + return new GetRoomCoursesToolResult(null, error(NO_DATA)); + } + GetCourseRes metadata = allRows.isEmpty() ? selectedRows.get(0) : allRows.get(0); + return new GetRoomCoursesToolResult(new GetRoomCoursesToolData( + response.getPlaceName(), metadata.getYear(), metadata.getTerm(), mergeCoursePeriods(selectedRows)), null); + } catch (GlobalException exception) { + return new GetRoomCoursesToolResult(null, mapDomainError(exception)); + } catch (RuntimeException exception) { + return new GetRoomCoursesToolResult(null, error(TEMPORARILY_UNAVAILABLE)); + } + } + + public CampusStatusToolResult getCampusStatus() { + try { + return new CampusStatusToolResult( + schoolCalendarService.getTerm().getTerm(), + schoolCalendarService.isVacation().isActive(), + schoolCalendarService.isKoyeon().isActive(), + null); + } catch (RuntimeException exception) { + return new CampusStatusToolResult(null, false, false, error(TEMPORARILY_UNAVAILABLE)); + } + } + + private List toMeals(Map menuByType) { + if (menuByType == null || menuByType.isEmpty()) { + return List.of(); + } + return menuByType.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> new CafeteriaMealToolItem(entry.getKey(), entry.getValue())) + .toList(); + } + + private List mergeCoursePeriods(List rows) { + List merged = new ArrayList<>(); + for (GetCourseRes row : rows) { + Weekday weekday; + try { + weekday = Weekday.valueOf(row.getWeekday()); + } catch (IllegalArgumentException | NullPointerException exception) { + throw new IllegalStateException("Invalid course weekday"); + } + MergedCourse current = MergedCourse.from(row, weekday); + if (!merged.isEmpty() && merged.get(merged.size() - 1).canMerge(current)) { + MergedCourse previous = merged.get(merged.size() - 1); + merged.set(merged.size() - 1, previous.extendTo(current.endPeriod())); + } else { + merged.add(current); + } + } + return merged.stream().map(MergedCourse::toToolItem).toList(); + } + + private record MergedCourse(Long courseId, String subject, String professor, String courseCode, + String section, Weekday weekday, int startPeriod, int endPeriod) { + static MergedCourse from(GetCourseRes row, Weekday weekday) { + return new MergedCourse(row.getCourseId(), row.getSubject(), row.getProfessor(), row.getCode(), + row.getSection(), weekday, row.getClassTime(), row.getClassTime()); + } + + boolean canMerge(MergedCourse next) { + return Objects.equals(courseId, next.courseId) + && Objects.equals(subject, next.subject) + && Objects.equals(professor, next.professor) + && Objects.equals(courseCode, next.courseCode) + && Objects.equals(section, next.section) + && weekday == next.weekday + && next.startPeriod == endPeriod + 1; + } + + MergedCourse extendTo(int period) { + return new MergedCourse(courseId, subject, professor, courseCode, section, + weekday, startPeriod, period); + } + + RoomCourseToolItem toToolItem() { + return new RoomCourseToolItem(subject, professor, courseCode, section, + weekday, startPeriod, endPeriod); + } + } + private boolean validFacilityRequest(FindFacilitiesToolRequest request) { if (request == null) { return false; diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CafeteriaMealToolItem.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CafeteriaMealToolItem.java new file mode 100644 index 00000000..aca68c25 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CafeteriaMealToolItem.java @@ -0,0 +1,4 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public record CafeteriaMealToolItem(String mealType, String menu) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CafeteriaMenuDay.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CafeteriaMenuDay.java new file mode 100644 index 00000000..01768f54 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CafeteriaMenuDay.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +import java.time.LocalDate; +import java.util.List; + +public record CafeteriaMenuDay(LocalDate date, List meals) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusStatusToolResult.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusStatusToolResult.java new file mode 100644 index 00000000..c13ac24a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusStatusToolResult.java @@ -0,0 +1,5 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public record CampusStatusToolResult(String term, boolean vacation, boolean koyeonPeriod, + CampusToolError error) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusToolError.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusToolError.java index d4ffe7ba..d364a01f 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusToolError.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusToolError.java @@ -7,6 +7,7 @@ public static CampusToolError of(CampusToolErrorCode code) { case AMBIGUOUS_LOCATION -> "여러 위치 후보가 있어 사용자의 확인이 필요합니다."; case INVALID_INPUT -> "도구 입력값이 올바르지 않습니다."; case UNSUPPORTED -> "지원하지 않는 위치 또는 시설 유형입니다."; + case NO_DATA -> "요청한 조건에 해당하는 데이터가 없습니다."; case TEMPORARILY_UNAVAILABLE -> "캠퍼스 정보를 일시적으로 조회할 수 없습니다."; }); } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusToolErrorCode.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusToolErrorCode.java index c4e442ce..bd84f719 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusToolErrorCode.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusToolErrorCode.java @@ -5,5 +5,6 @@ public enum CampusToolErrorCode { AMBIGUOUS_LOCATION, INVALID_INPUT, UNSUPPORTED, + NO_DATA, TEMPORARILY_UNAVAILABLE } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetCafeteriaMenuToolData.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetCafeteriaMenuToolData.java new file mode 100644 index 00000000..4abb9d9d --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetCafeteriaMenuToolData.java @@ -0,0 +1,6 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +import java.util.List; + +public record GetCafeteriaMenuToolData(Long placeId, String placeName, List days) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetCafeteriaMenuToolRequest.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetCafeteriaMenuToolRequest.java new file mode 100644 index 00000000..348a2135 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetCafeteriaMenuToolRequest.java @@ -0,0 +1,6 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +import java.time.LocalDate; + +public record GetCafeteriaMenuToolRequest(Long placeId, LocalDate startDate, LocalDate endDate) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetCafeteriaMenuToolResult.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetCafeteriaMenuToolResult.java new file mode 100644 index 00000000..307508e4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetCafeteriaMenuToolResult.java @@ -0,0 +1,4 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public record GetCafeteriaMenuToolResult(GetCafeteriaMenuToolData cafeteria, CampusToolError error) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetRoomCoursesToolData.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetRoomCoursesToolData.java new file mode 100644 index 00000000..8410aa9e --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetRoomCoursesToolData.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +import java.util.List; + +public record GetRoomCoursesToolData(String roomName, Integer year, String term, + List courses) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetRoomCoursesToolRequest.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetRoomCoursesToolRequest.java new file mode 100644 index 00000000..ad91d2a8 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetRoomCoursesToolRequest.java @@ -0,0 +1,6 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +import devkor.com.teamcback.domain.common.entity.Weekday; + +public record GetRoomCoursesToolRequest(Long placeId, Weekday weekday) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetRoomCoursesToolResult.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetRoomCoursesToolResult.java new file mode 100644 index 00000000..ae6193da --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetRoomCoursesToolResult.java @@ -0,0 +1,4 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public record GetRoomCoursesToolResult(GetRoomCoursesToolData room, CampusToolError error) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RoomCourseToolItem.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RoomCourseToolItem.java new file mode 100644 index 00000000..84a38122 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/RoomCourseToolItem.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +import devkor.com.teamcback.domain.common.entity.Weekday; + +public record RoomCourseToolItem(String subject, String professor, String courseCode, String section, + Weekday weekday, int startPeriod, int endPeriod) { +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 75da94eb..8960c1dc 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -188,6 +188,7 @@ chatbot: facilities: default-limit: ${CHATBOT_FACILITIES_DEFAULT_LIMIT:10} max-limit: ${CHATBOT_FACILITIES_MAX_LIMIT:20} + menu-max-days: ${CHATBOT_MENU_MAX_DAYS:7} push: expo: diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java index 07787759..c5bb3acc 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java @@ -36,7 +36,7 @@ void mapsProviderFailureWithoutExposingProviderDetails() { new ChatbotProperties.Agent(6, 5, 60), new ChatbotProperties.Tools( new ChatbotProperties.Limits(5, 10), - new ChatbotProperties.Limits(10, 20))); + new ChatbotProperties.Limits(10, 20), 7)); CampusChatbotTools tools = mock(CampusChatbotTools.class); SpringAiLlmGateway gateway = new SpringAiLlmGateway(builder, properties, executor, tools); diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsTest.java index fbd0f8db..db6d7dc5 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsTest.java @@ -12,6 +12,12 @@ void registersExactlyThePhaseThreeTools() { assertThat(Arrays.stream(CampusChatbotTools.class.getDeclaredMethods()) .filter(method -> method.isAnnotationPresent(Tool.class)) .map(method -> method.getAnnotation(Tool.class).name())) - .containsExactlyInAnyOrder("searchCampus", "getLocationDetail", "findFacilities", "findRoute"); + .containsExactlyInAnyOrder("searchCampus", "getLocationDetail", "findFacilities", "findRoute", + "getCafeteriaMenu", "getRoomCourses", "getCampusStatus"); + } + + @Test + void campusStatusHasNoToolInput() throws NoSuchMethodException { + assertThat(CampusChatbotTools.class.getDeclaredMethod("getCampusStatus").getParameterCount()).isZero(); } } diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusLifeToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusLifeToolAdapterTest.java new file mode 100644 index 00000000..670975d7 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusLifeToolAdapterTest.java @@ -0,0 +1,205 @@ +package devkor.com.teamcback.domain.chatbot.tool; + +import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.INVALID_INPUT; +import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.NO_DATA; +import static devkor.com.teamcback.domain.common.entity.Weekday.MON; +import static devkor.com.teamcback.domain.common.entity.Weekday.TUE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import devkor.com.teamcback.domain.chatbot.tool.dto.CafeteriaMenuDay; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetCafeteriaMenuToolData; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetCafeteriaMenuToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetRoomCoursesToolData; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetRoomCoursesToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.RoomCourseToolItem; +import devkor.com.teamcback.domain.course.dto.response.GetCourseListRes; +import devkor.com.teamcback.domain.course.dto.response.GetCourseRes; +import devkor.com.teamcback.domain.course.service.CourseService; +import devkor.com.teamcback.domain.place.dto.response.GetCafeteriaMenuListRes; +import devkor.com.teamcback.domain.place.service.CafeteriaMenuService; +import devkor.com.teamcback.domain.routes.service.RouteService; +import devkor.com.teamcback.domain.schoolcalendar.dto.response.GetSchoolCalendarRes; +import devkor.com.teamcback.domain.schoolcalendar.dto.response.GetSchoolCalendarTermRes; +import devkor.com.teamcback.domain.schoolcalendar.service.SchoolCalendarService; +import devkor.com.teamcback.domain.search.service.SearchService; +import java.lang.reflect.RecordComponent; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CampusLifeToolAdapterTest { + @Mock SearchService searchService; + @Mock RouteService routeService; + @Mock CafeteriaMenuService cafeteriaMenuService; + @Mock CourseService courseService; + @Mock SchoolCalendarService schoolCalendarService; + private CampusToolAdapter adapter; + + @BeforeEach + void setUp() { + ChatbotProperties properties = new ChatbotProperties(true, + new ChatbotProperties.Llm("google", "gemini-2.5-flash-lite", 500, 8), + new ChatbotProperties.Agent(6, 5, 60), + new ChatbotProperties.Tools(new ChatbotProperties.Limits(5, 10), + new ChatbotProperties.Limits(10, 20), 7)); + adapter = new CampusToolAdapter(searchService, routeService, cafeteriaMenuService, + courseService, schoolCalendarService, properties); + } + + @Test + void omittedMenuEndDateQueriesOneInclusiveDayAndReturnsCompactOutput() { + LocalDate date = LocalDate.of(2026, 9, 23); + Map> menus = Map.of(date, Map.of("LUNCH", "비빔밥")); + when(cafeteriaMenuService.getCafeteriaMenu(10L, date, date.plusDays(1))) + .thenReturn(new GetCafeteriaMenuListRes(10L, "학생회관 식당", "address", "hours", "phone", menus)); + + var result = adapter.getCafeteriaMenu(new GetCafeteriaMenuToolRequest(10L, date, null)); + + assertThat(result.error()).isNull(); + assertThat(result.cafeteria().placeId()).isEqualTo(10L); + assertThat(result.cafeteria().placeName()).isEqualTo("학생회관 식당"); + assertThat(result.cafeteria().days()).singleElement().satisfies(day -> { + assertThat(day.date()).isEqualTo(date); + assertThat(day.meals()).singleElement().satisfies(meal -> { + assertThat(meal.mealType()).isEqualTo("LUNCH"); + assertThat(meal.menu()).isEqualTo("비빔밥"); + }); + }); + assertThat(recordFields(GetCafeteriaMenuToolData.class)) + .containsExactlyInAnyOrder("placeId", "placeName", "days") + .doesNotContain("address", "contact", "imageUrl", "operatingTime"); + assertThat(recordFields(CafeteriaMenuDay.class)).doesNotContain("id", "placeId"); + } + + @Test + void menuRangeIsInclusiveAndDaysAreSorted() { + LocalDate start = LocalDate.of(2026, 9, 21); + LocalDate end = start.plusDays(2); + Map> menus = new HashMap<>(); + menus.put(end, Map.of("DINNER", "국수")); + menus.put(start, Map.of("LUNCH", "백반")); + when(cafeteriaMenuService.getCafeteriaMenu(10L, start, end.plusDays(1))) + .thenReturn(new GetCafeteriaMenuListRes(10L, "식당", "", "", "", menus)); + + var result = adapter.getCafeteriaMenu(new GetCafeteriaMenuToolRequest(10L, start, end)); + + assertThat(result.cafeteria().days()).extracting(CafeteriaMenuDay::date) + .containsExactly(start, end); + } + + @Test + void invalidOrOverSevenDayMenuRangeIsRejectedBeforeServiceCall() { + LocalDate start = LocalDate.of(2026, 9, 1); + + assertThat(adapter.getCafeteriaMenu(new GetCafeteriaMenuToolRequest(10L, start, start.minusDays(1))) + .error().code()).isEqualTo(INVALID_INPUT); + assertThat(adapter.getCafeteriaMenu(new GetCafeteriaMenuToolRequest(10L, start, start.plusDays(7))) + .error().code()).isEqualTo(INVALID_INPUT); + verifyNoInteractions(cafeteriaMenuService); + } + + @Test + void emptyMenuMapsToNoData() { + LocalDate date = LocalDate.of(2026, 9, 23); + when(cafeteriaMenuService.getCafeteriaMenu(10L, date, date.plusDays(1))) + .thenReturn(new GetCafeteriaMenuListRes(10L, "식당", "", "", "", Map.of(date, Map.of()))); + + var result = adapter.getCafeteriaMenu(new GetCafeteriaMenuToolRequest(10L, date, date)); + + assertThat(result.cafeteria()).isNull(); + assertThat(result.error().code()).isEqualTo(NO_DATA); + } + + @Test + void roomCoursesFilterWeekdayAndMergeOnlyConsecutiveSameCourseRows() { + GetCourseRes math2 = course(1L, "수학", "김교수", "MATH101", "01", "MON", 2); + GetCourseRes math3 = course(1L, "수학", "김교수", "MATH101", "01", "MON", 3); + GetCourseRes math5 = course(1L, "수학", "김교수", "MATH101", "01", "MON", 5); + GetCourseRes other3 = course(2L, "수학", "이교수", "MATH102", "02", "MON", 3); + GetCourseRes tuesday = course(3L, "영어", "박교수", "ENG101", "01", "TUE", 1); + when(courseService.getCourseList(20L)).thenReturn(new GetCourseListRes(20L, "과도관 101호", + Map.of(MON, List.of(math2, math3, other3, math5), TUE, List.of(tuesday)))); + + var result = adapter.getRoomCourses(new GetRoomCoursesToolRequest(20L, MON)); + + assertThat(result.error()).isNull(); + assertThat(result.room().roomName()).isEqualTo("과도관 101호"); + assertThat(result.room().year()).isEqualTo(2026); + assertThat(result.room().term()).isEqualTo("FALL"); + assertThat(result.room().courses()).hasSize(3); + assertThat(result.room().courses().get(0)) + .extracting(RoomCourseToolItem::courseCode, RoomCourseToolItem::startPeriod, + RoomCourseToolItem::endPeriod) + .containsExactly("MATH101", 2, 3); + assertThat(result.room().courses()).extracting(RoomCourseToolItem::weekday).containsOnly(MON); + assertThat(recordFields(GetRoomCoursesToolData.class)) + .doesNotContain("courseId", "studentId", "userId", "department", "unit", "type"); + } + + @Test + void noCoursesForRequestedWeekdayMapsToNoData() { + GetCourseRes monday = course(1L, "수학", "김교수", "MATH101", "01", "MON", 2); + when(courseService.getCourseList(20L)).thenReturn(new GetCourseListRes(20L, "과도관 101호", + Map.of(MON, List.of(monday)))); + + var result = adapter.getRoomCourses(new GetRoomCoursesToolRequest(20L, TUE)); + + assertThat(result.room()).isNull(); + assertThat(result.error().code()).isEqualTo(NO_DATA); + } + + @Test + void campusStatusCombinesCurrentDomainValues() { + GetSchoolCalendarTermRes term = mock(GetSchoolCalendarTermRes.class); + GetSchoolCalendarRes vacation = mock(GetSchoolCalendarRes.class); + GetSchoolCalendarRes koyeon = mock(GetSchoolCalendarRes.class); + when(term.getTerm()).thenReturn("FALL"); + when(vacation.isActive()).thenReturn(false); + when(koyeon.isActive()).thenReturn(true); + when(schoolCalendarService.getTerm()).thenReturn(term); + when(schoolCalendarService.isVacation()).thenReturn(vacation); + when(schoolCalendarService.isKoyeon()).thenReturn(koyeon); + + var result = adapter.getCampusStatus(); + + assertThat(result.term()).isEqualTo("FALL"); + assertThat(result.vacation()).isFalse(); + assertThat(result.koyeonPeriod()).isTrue(); + assertThat(result.error()).isNull(); + verify(schoolCalendarService).getTerm(); + verify(schoolCalendarService).isVacation(); + verify(schoolCalendarService).isKoyeon(); + } + + private GetCourseRes course(Long id, String subject, String professor, String code, + String section, String weekday, int period) { + GetCourseRes row = mock(GetCourseRes.class); + lenient().when(row.getCourseId()).thenReturn(id); + lenient().when(row.getYear()).thenReturn(2026); + lenient().when(row.getTerm()).thenReturn("FALL"); + lenient().when(row.getSubject()).thenReturn(subject); + lenient().when(row.getProfessor()).thenReturn(professor); + lenient().when(row.getCode()).thenReturn(code); + lenient().when(row.getSection()).thenReturn(section); + lenient().when(row.getWeekday()).thenReturn(weekday); + lenient().when(row.getClassTime()).thenReturn(period); + return row; + } + + private List recordFields(Class type) { + return java.util.Arrays.stream(type.getRecordComponents()).map(RecordComponent::getName).toList(); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRouteToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRouteToolAdapterTest.java index 6de1cd66..582a7918 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRouteToolAdapterTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRouteToolAdapterTest.java @@ -51,8 +51,8 @@ void setUp() { new ChatbotProperties.Llm("google", "gemini-2.5-flash-lite", 500, 8), new ChatbotProperties.Agent(6, 5, 60), new ChatbotProperties.Tools(new ChatbotProperties.Limits(5, 10), - new ChatbotProperties.Limits(10, 20))); - adapter = new CampusToolAdapter(searchService, routeService, properties); + new ChatbotProperties.Limits(10, 20), 7)); + adapter = new CampusToolAdapter(searchService, routeService, null, null, null, properties); } @Test diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java index 6b60c88e..034656ff 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java @@ -59,8 +59,8 @@ void setUp() { new ChatbotProperties.Agent(6, 5, 60), new ChatbotProperties.Tools( new ChatbotProperties.Limits(5, 10), - new ChatbotProperties.Limits(10, 20))); - adapter = new CampusToolAdapter(searchService, routeService, properties); + new ChatbotProperties.Limits(10, 20), 7)); + adapter = new CampusToolAdapter(searchService, routeService, null, null, null, properties); } @Test From 5de8b835345fbede605c2736f890a02dbbc0f0eb Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Thu, 24 Sep 2026 15:45:45 +0900 Subject: [PATCH 05/21] feat(chatbot): add crowd and review tools --- .../chatbot/config/ChatbotProperties.java | 2 +- .../chatbot/tool/CampusChatbotTools.java | 16 ++ .../chatbot/tool/CampusToolAdapter.java | 132 +++++++++++- .../domain/chatbot/tool/dto/CrowdLevel.java | 5 + .../chatbot/tool/dto/CrowdStatusToolData.java | 9 + .../tool/dto/GetCrowdStatusToolRequest.java | 4 + .../tool/dto/GetCrowdStatusToolResult.java | 4 + .../tool/dto/GetPlaceReviewsToolRequest.java | 4 + .../tool/dto/GetPlaceReviewsToolResult.java | 4 + .../tool/dto/PlaceReviewsToolData.java | 8 + .../chatbot/tool/dto/ReviewSummary.java | 4 + .../chatbot/tool/dto/ReviewTagSummary.java | 4 + .../chatbot/tool/dto/TypicalCrowdPattern.java | 4 + src/main/resources/application.yml | 3 + .../gateway/SpringAiLlmGatewayTest.java | 3 +- .../chatbot/tool/CampusChatbotToolsTest.java | 3 +- .../tool/CampusLifeToolAdapterTest.java | 5 +- .../CampusRealtimeReviewToolAdapterTest.java | 200 ++++++++++++++++++ .../tool/CampusRouteToolAdapterTest.java | 5 +- .../chatbot/tool/CampusToolAdapterTest.java | 5 +- 20 files changed, 414 insertions(+), 10 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CrowdLevel.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CrowdStatusToolData.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetCrowdStatusToolRequest.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetCrowdStatusToolResult.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetPlaceReviewsToolRequest.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetPlaceReviewsToolResult.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/PlaceReviewsToolData.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/ReviewSummary.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/ReviewTagSummary.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/TypicalCrowdPattern.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRealtimeReviewToolAdapterTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java b/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java index 7cad8a81..fded0114 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java @@ -14,7 +14,7 @@ public Duration timeout() { public record Agent(int maxToolCallsPerMessage, int historyTurns, int sessionTtlMinutes) { } - public record Tools(Limits search, Limits facilities, int menuMaxDays) { + public record Tools(Limits search, Limits facilities, int menuMaxDays, Limits reviews) { } public record Limits(int defaultLimit, int maxLimit) { diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java index 093242b6..dc7b726b 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java @@ -5,12 +5,16 @@ import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.CampusStatusToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetCrowdStatusToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetCrowdStatusToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.GetCafeteriaMenuToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.GetCafeteriaMenuToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.GetRoomCoursesToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.GetRoomCoursesToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetPlaceReviewsToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetPlaceReviewsToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolResult; import org.springframework.ai.tool.annotation.Tool; @@ -67,4 +71,16 @@ public GetRoomCoursesToolResult getRoomCourses( public CampusStatusToolResult getCampusStatus() { return adapter.getCampusStatus(); } + + @Tool(name = "getCrowdStatus", description = "BLE 센서가 설치된 장소의 추정 혼잡도를 조회합니다. 센서가 없거나 데이터가 오래되면 추정하지 않습니다.") + public GetCrowdStatusToolResult getCrowdStatus( + @ToolParam(description = "PLACE ID와 선택적 최근 한 달 시간대별 패턴 포함 여부") GetCrowdStatusToolRequest request) { + return adapter.getCrowdStatus(request); + } + + @Tool(name = "getPlaceReviews", description = "리뷰가 지원되는 장소의 평점, 대표 태그, 최근 텍스트 리뷰를 조회합니다. 작성자 정보나 리뷰 이미지는 제공하지 않습니다.") + public GetPlaceReviewsToolResult getPlaceReviews( + @ToolParam(description = "리뷰 지원 PLACE ID와 선택적 리뷰 개수") GetPlaceReviewsToolRequest request) { + return adapter.getPlaceReviews(request); + } } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java index 4ce283bf..7a5037bb 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java @@ -9,15 +9,22 @@ import static devkor.com.teamcback.global.response.ResultCode.COORDINATES_TOO_FAR; import static devkor.com.teamcback.global.response.ResultCode.COORDINATES_TOO_NEAR; import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_BUILDING; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_DEVICE; import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_NODE; import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_PLACE; import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_ROUTE; import static devkor.com.teamcback.global.response.ResultCode.NOT_PROVIDED_ROUTE; +import static devkor.com.teamcback.global.response.ResultCode.NOT_SUPPORTED_PLACE_TYPE; +import static devkor.com.teamcback.global.response.ResultCode.NO_DATA_FOR_DEVICE; import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; import devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolError; import devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode; import devkor.com.teamcback.domain.chatbot.tool.dto.CampusStatusToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.CrowdLevel; +import devkor.com.teamcback.domain.chatbot.tool.dto.CrowdStatusToolData; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetCrowdStatusToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetCrowdStatusToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.CafeteriaMealToolItem; import devkor.com.teamcback.domain.chatbot.tool.dto.CafeteriaMenuDay; import devkor.com.teamcback.domain.chatbot.tool.dto.FacilityToolItem; @@ -34,6 +41,8 @@ import devkor.com.teamcback.domain.chatbot.tool.dto.GetRoomCoursesToolData; import devkor.com.teamcback.domain.chatbot.tool.dto.GetRoomCoursesToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.GetRoomCoursesToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetPlaceReviewsToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetPlaceReviewsToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.LocationDetailToolData; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusItem; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; @@ -44,6 +53,14 @@ import devkor.com.teamcback.domain.chatbot.tool.dto.RouteSectionType; import devkor.com.teamcback.domain.chatbot.tool.dto.RouteStep; import devkor.com.teamcback.domain.chatbot.tool.dto.RoomCourseToolItem; +import devkor.com.teamcback.domain.chatbot.tool.dto.PlaceReviewsToolData; +import devkor.com.teamcback.domain.chatbot.tool.dto.ReviewSummary; +import devkor.com.teamcback.domain.chatbot.tool.dto.ReviewTagSummary; +import devkor.com.teamcback.domain.chatbot.tool.dto.TypicalCrowdPattern; +import devkor.com.teamcback.domain.ble.dto.response.BLETimePatternRes; +import devkor.com.teamcback.domain.ble.dto.response.GetBLERes; +import devkor.com.teamcback.domain.ble.entity.BLEstatus; +import devkor.com.teamcback.domain.ble.service.BLEService; import devkor.com.teamcback.domain.common.LocationType; import devkor.com.teamcback.domain.common.entity.Weekday; import devkor.com.teamcback.domain.course.dto.response.GetCourseListRes; @@ -55,6 +72,9 @@ import devkor.com.teamcback.domain.routes.dto.response.PartialRouteRes; import devkor.com.teamcback.domain.routes.entity.Conditions; import devkor.com.teamcback.domain.routes.service.RouteService; +import devkor.com.teamcback.domain.review.dto.response.GetReviewPlaceDetailRes; +import devkor.com.teamcback.domain.review.dto.response.SearchPlaceReviewRes; +import devkor.com.teamcback.domain.review.service.ReviewService; import devkor.com.teamcback.domain.search.dto.response.GlobalSearchRes; import devkor.com.teamcback.domain.search.dto.response.SearchBuildingDetailRes; import devkor.com.teamcback.domain.search.dto.response.SearchFacilityRes; @@ -84,16 +104,21 @@ public class CampusToolAdapter { private final CafeteriaMenuService cafeteriaMenuService; private final CourseService courseService; private final SchoolCalendarService schoolCalendarService; + private final BLEService bleService; + private final ReviewService reviewService; private final ChatbotProperties properties; public CampusToolAdapter(SearchService searchService, RouteService routeService, CafeteriaMenuService cafeteriaMenuService, CourseService courseService, - SchoolCalendarService schoolCalendarService, ChatbotProperties properties) { + SchoolCalendarService schoolCalendarService, BLEService bleService, + ReviewService reviewService, ChatbotProperties properties) { this.searchService = searchService; this.routeService = routeService; this.cafeteriaMenuService = cafeteriaMenuService; this.courseService = courseService; this.schoolCalendarService = schoolCalendarService; + this.bleService = bleService; + this.reviewService = reviewService; this.properties = properties; } @@ -396,6 +421,111 @@ RoomCourseToolItem toToolItem() { } } + public GetCrowdStatusToolResult getCrowdStatus(GetCrowdStatusToolRequest request) { + if (request == null || request.placeId() == null || request.placeId() <= 0) { + return new GetCrowdStatusToolResult(null, error(INVALID_INPUT)); + } + try { + GetBLERes response = bleService.getBLE(request.placeId()); + boolean stale = response.getLastStatus() == BLEstatus.FAILURE.getCode(); + List pattern = Boolean.TRUE.equals(request.includeTypicalPattern()) + ? toTypicalPattern(bleService.getBLETimePattern(request.placeId())) : null; + return new GetCrowdStatusToolResult(new CrowdStatusToolData( + response.getPlaceId(), response.getLastCount(), response.getCapacity(), + toCrowdLevel(response.getLastStatus()), response.getLastTime(), stale, pattern), null); + } catch (GlobalException exception) { + return new GetCrowdStatusToolResult(null, mapCrowdError(exception)); + } catch (RuntimeException exception) { + return new GetCrowdStatusToolResult(null, error(TEMPORARILY_UNAVAILABLE)); + } + } + + public GetPlaceReviewsToolResult getPlaceReviews(GetPlaceReviewsToolRequest request) { + if (request == null || request.placeId() == null || request.placeId() <= 0) { + return new GetPlaceReviewsToolResult(null, error(INVALID_INPUT)); + } + Integer limit = resolveLimit(request.limit(), properties.tools().reviews()); + if (limit == null) { + return new GetPlaceReviewsToolResult(null, error(INVALID_INPUT)); + } + try { + GetReviewPlaceDetailRes response = reviewService.getReviewPlaceDetail(request.placeId()); + List sourceReviews = response.getReviewList(); + if (sourceReviews == null || sourceReviews.isEmpty()) { + return new GetPlaceReviewsToolResult(null, error(NO_DATA)); + } + List reviews = sourceReviews.stream().limit(limit) + .map(item -> new ReviewSummary(item.getComment(), item.isRevisit(), item.getCreatedAt())) + .toList(); + List tags = response.getTagList() == null ? List.of() + : response.getTagList().stream() + .map(item -> new ReviewTagSummary(item.getTag(), item.getNum())).toList(); + return new GetPlaceReviewsToolResult(new PlaceReviewsToolData( + response.getPlaceId(), response.getName(), parseRating(response.getStarAverage()), tags, + reviews, sourceReviews.size() > limit), null); + } catch (GlobalException exception) { + return new GetPlaceReviewsToolResult(null, mapReviewError(exception)); + } catch (RuntimeException exception) { + return new GetPlaceReviewsToolResult(null, error(TEMPORARILY_UNAVAILABLE)); + } + } + + private CrowdLevel toCrowdLevel(int status) { + return switch (status) { + case 0 -> CrowdLevel.VACANT; + case 1 -> CrowdLevel.AVAILABLE; + case 2 -> CrowdLevel.CROWDED; + default -> CrowdLevel.UNKNOWN; + }; + } + + private List toTypicalPattern(BLETimePatternRes pattern) { + if (pattern == null || pattern.getHours() == null || pattern.getDayOfWeeks() == null + || pattern.getAverages() == null) { + return List.of(); + } + List result = new ArrayList<>(); + int dayCount = Math.min(pattern.getDayOfWeeks().length, pattern.getAverages().length); + for (int dayIndex = 0; dayIndex < dayCount; dayIndex++) { + int[] averages = pattern.getAverages()[dayIndex]; + if (averages == null) { + continue; + } + int hourCount = Math.min(pattern.getHours().length, averages.length); + for (int hourIndex = 0; hourIndex < hourCount; hourIndex++) { + result.add(new TypicalCrowdPattern(pattern.getDayOfWeeks()[dayIndex], + pattern.getHours()[hourIndex], averages[hourIndex])); + } + } + return result; + } + + private CampusToolError mapCrowdError(GlobalException exception) { + if (exception.getResultCode() == NOT_FOUND_DEVICE || exception.getResultCode() == NO_DATA_FOR_DEVICE) { + return error(NO_DATA); + } + return mapDomainError(exception); + } + + private CampusToolError mapReviewError(GlobalException exception) { + if (exception.getResultCode() == NOT_SUPPORTED_PLACE_TYPE) { + return error(UNSUPPORTED); + } + return mapDomainError(exception); + } + + private Double parseRating(String rating) { + if (rating == null) { + return null; + } + try { + double value = Double.parseDouble(rating); + return Double.isFinite(value) ? value : null; + } catch (NumberFormatException exception) { + return null; + } + } + private boolean validFacilityRequest(FindFacilitiesToolRequest request) { if (request == null) { return false; diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CrowdLevel.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CrowdLevel.java new file mode 100644 index 00000000..c67ff54a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CrowdLevel.java @@ -0,0 +1,5 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public enum CrowdLevel { + VACANT, AVAILABLE, CROWDED, UNKNOWN +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CrowdStatusToolData.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CrowdStatusToolData.java new file mode 100644 index 00000000..b48860a7 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CrowdStatusToolData.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record CrowdStatusToolData(Long placeId, Integer estimatedPeople, Integer capacity, + CrowdLevel level, LocalDateTime measuredAt, boolean stale, + List typicalPattern) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetCrowdStatusToolRequest.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetCrowdStatusToolRequest.java new file mode 100644 index 00000000..1ead767b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetCrowdStatusToolRequest.java @@ -0,0 +1,4 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public record GetCrowdStatusToolRequest(Long placeId, Boolean includeTypicalPattern) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetCrowdStatusToolResult.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetCrowdStatusToolResult.java new file mode 100644 index 00000000..7808ad63 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetCrowdStatusToolResult.java @@ -0,0 +1,4 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public record GetCrowdStatusToolResult(CrowdStatusToolData crowd, CampusToolError error) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetPlaceReviewsToolRequest.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetPlaceReviewsToolRequest.java new file mode 100644 index 00000000..dc1b7cc2 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetPlaceReviewsToolRequest.java @@ -0,0 +1,4 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public record GetPlaceReviewsToolRequest(Long placeId, Integer limit) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetPlaceReviewsToolResult.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetPlaceReviewsToolResult.java new file mode 100644 index 00000000..fcef8bb9 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/GetPlaceReviewsToolResult.java @@ -0,0 +1,4 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public record GetPlaceReviewsToolResult(PlaceReviewsToolData reviews, CampusToolError error) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/PlaceReviewsToolData.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/PlaceReviewsToolData.java new file mode 100644 index 00000000..2ec34ae3 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/PlaceReviewsToolData.java @@ -0,0 +1,8 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +import java.util.List; + +public record PlaceReviewsToolData(Long placeId, String placeName, Double averageRating, + List topTags, List reviews, + boolean truncated) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/ReviewSummary.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/ReviewSummary.java new file mode 100644 index 00000000..83a8f541 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/ReviewSummary.java @@ -0,0 +1,4 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public record ReviewSummary(String comment, boolean revisit, String createdAt) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/ReviewTagSummary.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/ReviewTagSummary.java new file mode 100644 index 00000000..e25fe0d7 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/ReviewTagSummary.java @@ -0,0 +1,4 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public record ReviewTagSummary(String tag, int count) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/TypicalCrowdPattern.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/TypicalCrowdPattern.java new file mode 100644 index 00000000..2bc94053 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/TypicalCrowdPattern.java @@ -0,0 +1,4 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public record TypicalCrowdPattern(String weekday, int hour, int estimatedPeople) { +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8960c1dc..e51236e8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -189,6 +189,9 @@ chatbot: default-limit: ${CHATBOT_FACILITIES_DEFAULT_LIMIT:10} max-limit: ${CHATBOT_FACILITIES_MAX_LIMIT:20} menu-max-days: ${CHATBOT_MENU_MAX_DAYS:7} + reviews: + default-limit: ${CHATBOT_REVIEWS_DEFAULT_LIMIT:5} + max-limit: ${CHATBOT_REVIEWS_MAX_LIMIT:10} push: expo: diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java index c5bb3acc..736275ae 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java @@ -36,7 +36,8 @@ void mapsProviderFailureWithoutExposingProviderDetails() { new ChatbotProperties.Agent(6, 5, 60), new ChatbotProperties.Tools( new ChatbotProperties.Limits(5, 10), - new ChatbotProperties.Limits(10, 20), 7)); + new ChatbotProperties.Limits(10, 20), 7, + new ChatbotProperties.Limits(5, 10))); CampusChatbotTools tools = mock(CampusChatbotTools.class); SpringAiLlmGateway gateway = new SpringAiLlmGateway(builder, properties, executor, tools); diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsTest.java index db6d7dc5..97daa5ed 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsTest.java @@ -13,7 +13,8 @@ void registersExactlyThePhaseThreeTools() { .filter(method -> method.isAnnotationPresent(Tool.class)) .map(method -> method.getAnnotation(Tool.class).name())) .containsExactlyInAnyOrder("searchCampus", "getLocationDetail", "findFacilities", "findRoute", - "getCafeteriaMenu", "getRoomCourses", "getCampusStatus"); + "getCafeteriaMenu", "getRoomCourses", "getCampusStatus", "getCrowdStatus", + "getPlaceReviews"); } @Test diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusLifeToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusLifeToolAdapterTest.java index 670975d7..2673a225 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusLifeToolAdapterTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusLifeToolAdapterTest.java @@ -54,9 +54,10 @@ void setUp() { new ChatbotProperties.Llm("google", "gemini-2.5-flash-lite", 500, 8), new ChatbotProperties.Agent(6, 5, 60), new ChatbotProperties.Tools(new ChatbotProperties.Limits(5, 10), - new ChatbotProperties.Limits(10, 20), 7)); + new ChatbotProperties.Limits(10, 20), 7, + new ChatbotProperties.Limits(5, 10))); adapter = new CampusToolAdapter(searchService, routeService, cafeteriaMenuService, - courseService, schoolCalendarService, properties); + courseService, schoolCalendarService, null, null, properties); } @Test diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRealtimeReviewToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRealtimeReviewToolAdapterTest.java new file mode 100644 index 00000000..301ef17c --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRealtimeReviewToolAdapterTest.java @@ -0,0 +1,200 @@ +package devkor.com.teamcback.domain.chatbot.tool; + +import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.NO_DATA; +import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.UNSUPPORTED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.ble.dto.response.BLETimePatternRes; +import devkor.com.teamcback.domain.ble.dto.response.GetBLERes; +import devkor.com.teamcback.domain.ble.service.BLEService; +import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import devkor.com.teamcback.domain.chatbot.tool.dto.CrowdLevel; +import devkor.com.teamcback.domain.chatbot.tool.dto.CrowdStatusToolData; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetCrowdStatusToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetPlaceReviewsToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.PlaceReviewsToolData; +import devkor.com.teamcback.domain.course.service.CourseService; +import devkor.com.teamcback.domain.place.service.CafeteriaMenuService; +import devkor.com.teamcback.domain.review.dto.response.GetReviewPlaceDetailRes; +import devkor.com.teamcback.domain.review.dto.response.SearchPlaceReviewRes; +import devkor.com.teamcback.domain.review.service.ReviewService; +import devkor.com.teamcback.domain.routes.service.RouteService; +import devkor.com.teamcback.domain.schoolcalendar.service.SchoolCalendarService; +import devkor.com.teamcback.domain.search.dto.response.SearchPlaceReviewTagRes; +import devkor.com.teamcback.domain.search.service.SearchService; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; +import java.lang.reflect.RecordComponent; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CampusRealtimeReviewToolAdapterTest { + @Mock SearchService searchService; + @Mock RouteService routeService; + @Mock CafeteriaMenuService cafeteriaMenuService; + @Mock CourseService courseService; + @Mock SchoolCalendarService schoolCalendarService; + @Mock BLEService bleService; + @Mock ReviewService reviewService; + private CampusToolAdapter adapter; + + @BeforeEach + void setUp() { + ChatbotProperties properties = new ChatbotProperties(true, + new ChatbotProperties.Llm("google", "gemini-2.5-flash-lite", 500, 8), + new ChatbotProperties.Agent(6, 5, 60), + new ChatbotProperties.Tools(new ChatbotProperties.Limits(5, 10), + new ChatbotProperties.Limits(10, 20), 7, + new ChatbotProperties.Limits(5, 10))); + adapter = new CampusToolAdapter(searchService, routeService, cafeteriaMenuService, courseService, + schoolCalendarService, bleService, reviewService, properties); + } + + @Test + void mapsSensorEstimateAndOmitsPatternByDefaultWithoutDeviceMetadata() { + GetBLERes ble = ble(1, 42, 100, LocalDateTime.of(2026, 9, 23, 12, 0)); + when(bleService.getBLE(10L)).thenReturn(ble); + + var result = adapter.getCrowdStatus(new GetCrowdStatusToolRequest(10L, null)); + + assertThat(result.error()).isNull(); + assertThat(result.crowd()).satisfies(crowd -> { + assertThat(crowd.estimatedPeople()).isEqualTo(42); + assertThat(crowd.capacity()).isEqualTo(100); + assertThat(crowd.level()).isEqualTo(CrowdLevel.AVAILABLE); + assertThat(crowd.stale()).isFalse(); + assertThat(crowd.typicalPattern()).isNull(); + }); + verifyNoInteractions(reviewService); + assertThat(recordFields(CrowdStatusToolData.class)) + .doesNotContain("id", "deviceId", "deviceName", "lastStatus", "ratio", "defaultCount"); + } + + @Test + void staleFailureStatusIsUnknownButRetainsMeasuredEstimateAndTimestamp() { + GetBLERes stale = ble(3, 42, 100, LocalDateTime.of(2026, 9, 23, 10, 0)); + when(bleService.getBLE(10L)).thenReturn(stale); + + var result = adapter.getCrowdStatus(new GetCrowdStatusToolRequest(10L, false)); + + assertThat(result.error()).isNull(); + assertThat(result.crowd().level()).isEqualTo(CrowdLevel.UNKNOWN); + assertThat(result.crowd().stale()).isTrue(); + assertThat(result.crowd().estimatedPeople()).isEqualTo(42); + } + + @Test + void noSensorOrSensorDataIsNoData() { + when(bleService.getBLE(10L)).thenThrow(new GlobalException(ResultCode.NOT_FOUND_DEVICE)); + assertThat(adapter.getCrowdStatus(new GetCrowdStatusToolRequest(10L, false)).error().code()) + .isEqualTo(NO_DATA); + + when(bleService.getBLE(11L)).thenThrow(new GlobalException(ResultCode.NO_DATA_FOR_DEVICE)); + assertThat(adapter.getCrowdStatus(new GetCrowdStatusToolRequest(11L, false)).error().code()) + .isEqualTo(NO_DATA); + } + + @Test + void typicalPatternIsIncludedOnlyWhenRequested() { + GetBLERes current = ble(0, 12, 100, LocalDateTime.now()); + when(bleService.getBLE(10L)).thenReturn(current); + BLETimePatternRes pattern = mock(BLETimePatternRes.class); + when(pattern.getDayOfWeeks()).thenReturn(new String[]{"mon"}); + when(pattern.getHours()).thenReturn(new int[]{7, 10}); + when(pattern.getAverages()).thenReturn(new int[][]{{10, 20}}); + when(bleService.getBLETimePattern(10L)).thenReturn(pattern); + + var result = adapter.getCrowdStatus(new GetCrowdStatusToolRequest(10L, true)); + + assertThat(result.crowd().typicalPattern()).extracting(item -> item.hour()) + .containsExactly(7, 10); + verify(bleService).getBLETimePattern(10L); + } + + @Test + void limitsAndMinimizesReviewsWithoutAuthorOrImageFields() { + List source = List.of( + review("첫 리뷰", true, "26.09.23(화)"), review("둘째 리뷰", false, "26.09.22(월)"), + review("셋째 리뷰", false, "26.09.21(일)"), review("넷째 리뷰", false, "26.09.20(토)"), + review("다섯째 리뷰", false, "26.09.19(금)"), review("여섯째 리뷰", false, "26.09.18(목)")); + GetReviewPlaceDetailRes response = reviewResponse(source, "4.25"); + when(reviewService.getReviewPlaceDetail(20L)).thenReturn(response); + + var result = adapter.getPlaceReviews(new GetPlaceReviewsToolRequest(20L, null)); + + assertThat(result.error()).isNull(); + assertThat(result.reviews().averageRating()).isEqualTo(4.25); + assertThat(result.reviews().topTags()).extracting(tag -> tag.tag()).containsExactly("맛있어요"); + assertThat(result.reviews().reviews()).hasSize(5); + assertThat(result.reviews().truncated()).isTrue(); + assertThat(recordFields(PlaceReviewsToolData.class)).doesNotContain("userId", "username", "nickname", + "profileImageUrl", "characterImage", "reviewImage", "reviewId", "fileUuid"); + assertThat(recordFields(devkor.com.teamcback.domain.chatbot.tool.dto.ReviewSummary.class)) + .containsExactlyInAnyOrder("comment", "revisit", "createdAt") + .doesNotContain("userId", "username", "profileImageUrl", "imageUrl"); + } + + @Test + void maxReviewLimitAndUnsupportedOrEmptyReviewsHaveStableErrors() { + List source = java.util.stream.IntStream.range(0, 12) + .mapToObj(index -> review("리뷰" + index, false, "26.09.23(화)")).toList(); + GetReviewPlaceDetailRes response = reviewResponse(source, "NaN"); + when(reviewService.getReviewPlaceDetail(20L)).thenReturn(response); + var limited = adapter.getPlaceReviews(new GetPlaceReviewsToolRequest(20L, 99)); + assertThat(limited.reviews().reviews()).hasSize(10); + assertThat(limited.reviews().averageRating()).isNull(); + + GetReviewPlaceDetailRes emptyResponse = reviewResponse(List.of(), "NaN"); + when(reviewService.getReviewPlaceDetail(21L)).thenReturn(emptyResponse); + assertThat(adapter.getPlaceReviews(new GetPlaceReviewsToolRequest(21L, null)).error().code()) + .isEqualTo(NO_DATA); + + when(reviewService.getReviewPlaceDetail(22L)) + .thenThrow(new GlobalException(ResultCode.NOT_SUPPORTED_PLACE_TYPE)); + assertThat(adapter.getPlaceReviews(new GetPlaceReviewsToolRequest(22L, null)).error().code()) + .isEqualTo(UNSUPPORTED); + } + + private GetBLERes ble(int status, int people, int capacity, LocalDateTime measuredAt) { + GetBLERes response = mock(GetBLERes.class); + lenient().when(response.getPlaceId()).thenReturn(10L); + lenient().when(response.getLastStatus()).thenReturn(status); + lenient().when(response.getLastCount()).thenReturn(people); + lenient().when(response.getCapacity()).thenReturn(capacity); + lenient().when(response.getLastTime()).thenReturn(measuredAt); + return response; + } + + private GetReviewPlaceDetailRes reviewResponse(List reviews, String average) { + GetReviewPlaceDetailRes response = mock(GetReviewPlaceDetailRes.class); + lenient().when(response.getPlaceId()).thenReturn(20L); + lenient().when(response.getName()).thenReturn("하나스퀘어 카페"); + lenient().when(response.getStarAverage()).thenReturn(average); + lenient().when(response.getTagList()).thenReturn(List.of(new SearchPlaceReviewTagRes(1L, "맛있어요", 3))); + lenient().when(response.getReviewList()).thenReturn(reviews); + return response; + } + + private SearchPlaceReviewRes review(String comment, boolean revisit, String createdAt) { + SearchPlaceReviewRes response = mock(SearchPlaceReviewRes.class); + lenient().when(response.getComment()).thenReturn(comment); + lenient().when(response.isRevisit()).thenReturn(revisit); + lenient().when(response.getCreatedAt()).thenReturn(createdAt); + return response; + } + + private List recordFields(Class type) { + return java.util.Arrays.stream(type.getRecordComponents()).map(RecordComponent::getName).toList(); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRouteToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRouteToolAdapterTest.java index 582a7918..94eb0074 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRouteToolAdapterTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRouteToolAdapterTest.java @@ -51,8 +51,9 @@ void setUp() { new ChatbotProperties.Llm("google", "gemini-2.5-flash-lite", 500, 8), new ChatbotProperties.Agent(6, 5, 60), new ChatbotProperties.Tools(new ChatbotProperties.Limits(5, 10), - new ChatbotProperties.Limits(10, 20), 7)); - adapter = new CampusToolAdapter(searchService, routeService, null, null, null, properties); + new ChatbotProperties.Limits(10, 20), 7, + new ChatbotProperties.Limits(5, 10))); + adapter = new CampusToolAdapter(searchService, routeService, null, null, null, null, null, properties); } @Test diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java index 034656ff..fa9ada7b 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java @@ -59,8 +59,9 @@ void setUp() { new ChatbotProperties.Agent(6, 5, 60), new ChatbotProperties.Tools( new ChatbotProperties.Limits(5, 10), - new ChatbotProperties.Limits(10, 20), 7)); - adapter = new CampusToolAdapter(searchService, routeService, null, null, null, properties); + new ChatbotProperties.Limits(10, 20), 7, + new ChatbotProperties.Limits(5, 10))); + adapter = new CampusToolAdapter(searchService, routeService, null, null, null, null, null, properties); } @Test From 75df913162ca65420e03e50f5889505f33b6a1f3 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Thu, 24 Sep 2026 16:36:49 +0900 Subject: [PATCH 06/21] feat(chatbot): add session memory and usage limits --- .../chatbot/config/ChatbotProperties.java | 6 +- .../chatbot/controller/ChatController.java | 11 ++- .../chatbot/gateway/SpringAiLlmGateway.java | 79 ++++++++++++--- .../domain/chatbot/service/ChatCaller.java | 13 +++ .../chatbot/service/ChatRateLimiter.java | 58 +++++++++++ .../domain/chatbot/service/ChatService.java | 35 +++++-- .../service/ChatSessionMemoryService.java | 73 ++++++++++++++ .../service/ChatbotToolCallLimiter.java | 69 +++++++++++++ .../ToolCallLimitExceededException.java | 4 + .../chatbot/tool/CampusChatbotTools.java | 14 ++- .../teamcback/global/response/ResultCode.java | 7 +- src/main/resources/application.yml | 5 + .../controller/ChatControllerTest.java | 68 ++++++------- .../gateway/SpringAiLlmGatewayTest.java | 6 +- .../ChatServiceCurrentLocationTest.java | 17 ++-- .../service/ChatbotOperationsTest.java | 99 +++++++++++++++++++ .../tool/CampusChatbotToolsToolLimitTest.java | 34 +++++++ .../tool/CampusLifeToolAdapterTest.java | 3 +- .../CampusRealtimeReviewToolAdapterTest.java | 3 +- .../tool/CampusRouteToolAdapterTest.java | 3 +- .../chatbot/tool/CampusToolAdapterTest.java | 3 +- 21 files changed, 537 insertions(+), 73 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatCaller.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRateLimiter.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatSessionMemoryService.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatbotToolCallLimiter.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/ToolCallLimitExceededException.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatbotOperationsTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsToolLimitTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java b/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java index fded0114..8fd597fd 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java @@ -4,7 +4,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "chatbot") -public record ChatbotProperties(boolean enabled, Llm llm, Agent agent, Tools tools) { +public record ChatbotProperties(boolean enabled, Llm llm, Agent agent, Tools tools, RateLimit rateLimit) { public record Llm(String provider, String model, int maxOutputTokens, int timeoutSeconds) { public Duration timeout() { return Duration.ofSeconds(timeoutSeconds); @@ -19,4 +19,8 @@ public record Tools(Limits search, Limits facilities, int menuMaxDays, Limits re public record Limits(int defaultLimit, int maxLimit) { } + + public record RateLimit(int authenticatedDailyLimit, int anonymousDailyLimit, int burstPerMinute, + String dailyResetZone) { + } } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/controller/ChatController.java b/src/main/java/devkor/com/teamcback/domain/chatbot/controller/ChatController.java index 4e762d3d..f5574f5d 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/controller/ChatController.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/controller/ChatController.java @@ -3,7 +3,10 @@ import devkor.com.teamcback.domain.chatbot.dto.request.ChatMessageReq; import devkor.com.teamcback.domain.chatbot.dto.response.ChatMessageRes; import devkor.com.teamcback.domain.chatbot.service.ChatService; +import devkor.com.teamcback.domain.chatbot.service.ChatCaller; import devkor.com.teamcback.global.response.CommonResponse; +import devkor.com.teamcback.global.security.UserDetailsImpl; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -11,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.core.annotation.AuthenticationPrincipal; @RestController @RequiredArgsConstructor @@ -20,7 +24,10 @@ public class ChatController { private final ChatService chatService; @PostMapping("/messages") - public CommonResponse sendMessage(@Valid @RequestBody ChatMessageReq request) { - return CommonResponse.success(chatService.sendMessage(request)); + public CommonResponse sendMessage(@Valid @RequestBody ChatMessageReq request, + @AuthenticationPrincipal UserDetailsImpl userDetails, + HttpServletRequest httpRequest) { + return CommonResponse.success(chatService.sendMessage(request, + ChatCaller.from(userDetails, httpRequest.getRemoteAddr()))); } } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java index 57c2f5d7..858226ff 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java @@ -3,6 +3,8 @@ import static devkor.com.teamcback.global.response.ResultCode.CHATBOT_TEMPORARILY_UNAVAILABLE; import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter; +import devkor.com.teamcback.domain.chatbot.service.ToolCallLimitExceededException; import devkor.com.teamcback.domain.chatbot.tool.CampusChatbotTools; import devkor.com.teamcback.global.exception.exception.GlobalException; import java.util.concurrent.ExecutionException; @@ -13,44 +15,97 @@ import org.springframework.ai.chat.client.ChatClient; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; +import lombok.extern.slf4j.Slf4j; @Component +@Slf4j @ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") public class SpringAiLlmGateway implements LlmGateway { + private static final String TOOL_LIMIT_FALLBACK = "요청을 처리하는 데 필요한 확인이 너무 많습니다. " + + "장소나 조건을 조금 더 구체적으로 알려 주세요."; private final ChatClient chatClient; private final ChatbotProperties properties; private final ExecutorService chatbotLlmExecutor; private final CampusChatbotTools campusChatbotTools; + private final ChatbotToolCallLimiter toolCallLimiter; public SpringAiLlmGateway(ChatClient.Builder chatClientBuilder, ChatbotProperties properties, - ExecutorService chatbotLlmExecutor, CampusChatbotTools campusChatbotTools) { + ExecutorService chatbotLlmExecutor, CampusChatbotTools campusChatbotTools, + ChatbotToolCallLimiter toolCallLimiter) { this.chatClient = chatClientBuilder.build(); this.properties = properties; this.chatbotLlmExecutor = chatbotLlmExecutor; this.campusChatbotTools = campusChatbotTools; + this.toolCallLimiter = toolCallLimiter; } @Override public String generate(String systemPrompt, String userMessage) { - Future response = chatbotLlmExecutor.submit(() -> chatClient.prompt() - .system(systemPrompt) - .user(userMessage) - .tools(campusChatbotTools) - .call() - .content()); + Future response = chatbotLlmExecutor.submit(() -> invoke(systemPrompt, userMessage)); try { - String content = response.get(properties.llm().timeout().toMillis(), TimeUnit.MILLISECONDS); - if (content == null || content.isBlank()) { - throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); - } - return content; + GatewayResult result = response.get(properties.llm().timeout().toMillis(), TimeUnit.MILLISECONDS); + log.info("chatbot_llm outcome={} provider={} model={} latencyMs={} toolCalls={} inputTokens=unavailable outputTokens=unavailable", + result.outcome(), properties.llm().provider(), properties.llm().model(), result.latencyMillis(), + result.toolCalls()); + return result.content(); } catch (InterruptedException exception) { response.cancel(true); Thread.currentThread().interrupt(); + log.warn("chatbot_llm outcome=TEMPORARILY_UNAVAILABLE provider={} model={}", + properties.llm().provider(), properties.llm().model()); throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); } catch (ExecutionException | TimeoutException exception) { response.cancel(true); + if (hasCause(exception, ToolCallLimitExceededException.class)) { + log.info("chatbot_llm outcome=TOOL_LIMIT provider={} model={}", + properties.llm().provider(), properties.llm().model()); + return TOOL_LIMIT_FALLBACK; + } + log.warn("chatbot_llm outcome=TEMPORARILY_UNAVAILABLE provider={} model={}", + properties.llm().provider(), properties.llm().model()); throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); } } + + private GatewayResult invoke(String systemPrompt, String userMessage) { + long startedAt = System.nanoTime(); + try (ChatbotToolCallLimiter.Scope scope = toolCallLimiter.open()) { + try { + String content = chatClient.prompt() + .system(systemPrompt) + .user(userMessage) + .tools(campusChatbotTools) + .call() + .content(); + if (content == null || content.isBlank()) { + throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); + } + return new GatewayResult(content, "SUCCESS", elapsedMillis(startedAt), scope.callCount()); + } catch (RuntimeException exception) { + if (hasCause(exception, ToolCallLimitExceededException.class)) { + return new GatewayResult(TOOL_LIMIT_FALLBACK, "TOOL_LIMIT", elapsedMillis(startedAt), + scope.callCount()); + } + throw exception; + } + } + } + + private boolean hasCause(Throwable exception, Class type) { + Throwable current = exception; + while (current != null) { + if (type.isInstance(current)) { + return true; + } + current = current.getCause(); + } + return false; + } + + private long elapsedMillis(long startedAt) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt); + } + + private record GatewayResult(String content, String outcome, long latencyMillis, int toolCalls) { + } } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatCaller.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatCaller.java new file mode 100644 index 00000000..29a32f7e --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatCaller.java @@ -0,0 +1,13 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import devkor.com.teamcback.global.security.UserDetailsImpl; + +public record ChatCaller(String key, boolean authenticated) { + public static ChatCaller from(UserDetailsImpl userDetails, String remoteAddress) { + if (userDetails != null && userDetails.getUser() != null && userDetails.getUser().getUserId() != null) { + return new ChatCaller("user:" + userDetails.getUser().getUserId(), true); + } + String safeAddress = remoteAddress == null || remoteAddress.isBlank() ? "unknown" : remoteAddress; + return new ChatCaller("ip:" + safeAddress, false); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRateLimiter.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRateLimiter.java new file mode 100644 index 00000000..8171d828 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRateLimiter.java @@ -0,0 +1,58 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static devkor.com.teamcback.global.response.ResultCode.CHATBOT_RATE_LIMITED; +import static devkor.com.teamcback.global.response.ResultCode.CHATBOT_TEMPORARILY_UNAVAILABLE; + +import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class ChatRateLimiter { + private static final DateTimeFormatter DAY = DateTimeFormatter.BASIC_ISO_DATE; + private static final DateTimeFormatter MINUTE = DateTimeFormatter.ofPattern("yyyyMMddHHmm"); + + private final StringRedisTemplate redisTemplate; + private final ChatbotProperties properties; + + public void check(ChatCaller caller) { + try { + ZonedDateTime now = ZonedDateTime.now(java.time.ZoneId.of(properties.rateLimit().dailyResetZone())); + long burst = increment("chatbot:rate:minute:" + caller.key() + ":" + now.format(MINUTE), + Duration.between(now, now.plusMinutes(1).withSecond(0).withNano(0))); + if (burst > properties.rateLimit().burstPerMinute()) { + throw new GlobalException(CHATBOT_RATE_LIMITED); + } + long daily = increment("chatbot:rate:daily:" + caller.key() + ":" + now.format(DAY), + Duration.between(now, now.toLocalDate().plusDays(1).atStartOfDay(now.getZone()))); + int dailyLimit = caller.authenticated() ? properties.rateLimit().authenticatedDailyLimit() + : properties.rateLimit().anonymousDailyLimit(); + if (daily > dailyLimit) { + throw new GlobalException(CHATBOT_RATE_LIMITED); + } + } catch (GlobalException exception) { + throw exception; + } catch (Exception exception) { + throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); + } + } + + private long increment(String key, Duration ttl) { + Long count = redisTemplate.opsForValue().increment(key); + if (count == null) { + throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); + } + if (count == 1L) { + redisTemplate.expire(key, ttl); + } + return count; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java index 811a3146..e8c70df6 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java @@ -5,12 +5,10 @@ import devkor.com.teamcback.domain.chatbot.dto.response.ChatMessageRes; import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; import java.util.UUID; -import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; @Service -@RequiredArgsConstructor @ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") public class ChatService { static final String SYSTEM_PROMPT = """ @@ -27,19 +25,42 @@ public class ChatService { """; private final LlmGateway llmGateway; + private final ChatSessionMemoryService memoryService; + private final ChatRateLimiter rateLimiter; - public ChatMessageRes sendMessage(ChatMessageReq request) { + public ChatService(LlmGateway llmGateway, ChatSessionMemoryService memoryService, ChatRateLimiter rateLimiter) { + this.llmGateway = llmGateway; + this.memoryService = memoryService; + this.rateLimiter = rateLimiter; + } + + public ChatMessageRes sendMessage(ChatMessageReq request, ChatCaller caller) { UUID sessionId = request.sessionId() == null ? UUID.randomUUID() : request.sessionId(); - return new ChatMessageRes(sessionId, llmGateway.generate(SYSTEM_PROMPT, messageWithRequestContext(request))); + rateLimiter.check(caller); + var history = memoryService.load(sessionId, caller); + String reply = llmGateway.generate(SYSTEM_PROMPT, messageWithHistoryAndRequestContext(request, history)); + memoryService.save(sessionId, caller, request.message(), reply); + return new ChatMessageRes(sessionId, reply); } - private String messageWithRequestContext(ChatMessageReq request) { + private String messageWithHistoryAndRequestContext(ChatMessageReq request, + java.util.List history) { + String message = history.isEmpty() ? request.message() : historyPrefix(history) + request.message(); if (request.context() == null || request.context().currentLocation() == null) { - return request.message(); + return message; } CurrentLocationReq location = request.context().currentLocation(); - return request.message() + "\n\n[REQUEST_CONTEXT: currentLocation is available only for this request; " + return message + "\n\n[REQUEST_CONTEXT: currentLocation is available only for this request; " + "use start/end type COORD when needed; latitude=" + location.latitude() + ", longitude=" + location.longitude() + "; never reveal these raw coordinates]"; } + + private String historyPrefix(java.util.List history) { + StringBuilder prompt = new StringBuilder("[RECENT_CONVERSATION]\n"); + for (ChatSessionMemoryService.ChatTurn turn : history) { + prompt.append("User: ").append(turn.userMessage()).append("\nAssistant: ") + .append(turn.assistantReply()).append("\n"); + } + return prompt.append("[/RECENT_CONVERSATION]\n").toString(); + } } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatSessionMemoryService.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatSessionMemoryService.java new file mode 100644 index 00000000..0abc4ad5 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatSessionMemoryService.java @@ -0,0 +1,73 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static devkor.com.teamcback.global.response.ResultCode.CHATBOT_SESSION_FORBIDDEN; +import static devkor.com.teamcback.global.response.ResultCode.CHATBOT_TEMPORARILY_UNAVAILABLE; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class ChatSessionMemoryService { + private static final String KEY_PREFIX = "chatbot:session:"; + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + private final ChatbotProperties properties; + + public List load(UUID sessionId, ChatCaller caller) { + try { + String value = redisTemplate.opsForValue().get(key(sessionId)); + if (value == null) { + return List.of(); + } + StoredSession session = objectMapper.readValue(value, StoredSession.class); + if (!session.owner().equals(caller.key())) { + throw new GlobalException(CHATBOT_SESSION_FORBIDDEN); + } + redisTemplate.expire(key(sessionId), properties.agent().sessionTtlMinutes(), TimeUnit.MINUTES); + return List.copyOf(session.turns()); + } catch (GlobalException exception) { + throw exception; + } catch (Exception exception) { + throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); + } + } + + public void save(UUID sessionId, ChatCaller caller, String userMessage, String assistantReply) { + List turns = new ArrayList<>(load(sessionId, caller)); + turns.add(new ChatTurn(userMessage, assistantReply)); + int historyTurns = properties.agent().historyTurns(); + if (turns.size() > historyTurns) { + turns = new ArrayList<>(turns.subList(turns.size() - historyTurns, turns.size())); + } + try { + String value = objectMapper.writeValueAsString(new StoredSession(caller.key(), turns)); + redisTemplate.opsForValue().set(key(sessionId), value, + properties.agent().sessionTtlMinutes(), TimeUnit.MINUTES); + } catch (JsonProcessingException exception) { + throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); + } + } + + private String key(UUID sessionId) { + return KEY_PREFIX + sessionId; + } + + record StoredSession(String owner, List turns) { + } + + public record ChatTurn(String userMessage, String assistantReply) { + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatbotToolCallLimiter.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatbotToolCallLimiter.java new file mode 100644 index 00000000..3748fb18 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatbotToolCallLimiter.java @@ -0,0 +1,69 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class ChatbotToolCallLimiter { + private final ChatbotProperties properties; + private final ThreadLocal current = new ThreadLocal<>(); + + public ChatbotToolCallLimiter(ChatbotProperties properties) { + this.properties = properties; + } + + public Scope open() { + ScopeState previous = current.get(); + ScopeState scope = new ScopeState(properties.agent().maxToolCallsPerMessage()); + current.set(scope); + return new Scope(scope, previous); + } + + public void beforeToolCall() { + ScopeState scope = current.get(); + if (scope != null) { + scope.increment(); + } + } + + public final class Scope implements AutoCloseable { + private final ScopeState scope; + private final ScopeState previous; + + private Scope(ScopeState scope, ScopeState previous) { + this.scope = scope; + this.previous = previous; + } + + public int callCount() { + return scope.callCount; + } + + @Override + public void close() { + if (previous == null) { + current.remove(); + } else { + current.set(previous); + } + } + } + + private static final class ScopeState { + private final int maximum; + private int callCount; + + private ScopeState(int maximum) { + this.maximum = maximum; + } + + private void increment() { + if (callCount >= maximum) { + throw new ToolCallLimitExceededException(); + } + callCount++; + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ToolCallLimitExceededException.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ToolCallLimitExceededException.java new file mode 100644 index 00000000..dd2f9c3f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ToolCallLimitExceededException.java @@ -0,0 +1,4 @@ +package devkor.com.teamcback.domain.chatbot.service; + +public class ToolCallLimitExceededException extends RuntimeException { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java index dc7b726b..a3ae3ee4 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java @@ -17,6 +17,7 @@ import devkor.com.teamcback.domain.chatbot.tool.dto.GetPlaceReviewsToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolResult; +import devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -26,61 +27,72 @@ @ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") public class CampusChatbotTools { private final CampusToolAdapter adapter; + private final ChatbotToolCallLimiter toolCallLimiter; - public CampusChatbotTools(CampusToolAdapter adapter) { + public CampusChatbotTools(CampusToolAdapter adapter, ChatbotToolCallLimiter toolCallLimiter) { this.adapter = adapter; + this.toolCallLimiter = toolCallLimiter; } @Tool(name = "searchCampus", description = "장소 ID를 모를 때 가장 먼저 사용해 고려대학교 건물 또는 장소 이름 후보를 찾습니다.") public SearchCampusToolResult searchCampus( @ToolParam(description = "검색어와 선택적 결과 제한") SearchCampusToolRequest request) { + toolCallLimiter.beforeToolCall(); return adapter.searchCampus(request); } @Tool(name = "getLocationDetail", description = "searchCampus로 확인한 BUILDING 또는 PLACE ID의 상세와 운영 정보를 조회합니다.") public GetLocationDetailToolResult getLocationDetail( @ToolParam(description = "BUILDING 또는 PLACE 유형과 위치 ID") GetLocationDetailToolRequest request) { + toolCallLimiter.beforeToolCall(); return adapter.getLocationDetail(request); } @Tool(name = "findFacilities", description = "시설 유형이나 건물/층 조건으로 고려대학교 시설을 찾습니다. 층은 건물 ID와 함께 사용해야 합니다.") public FindFacilitiesToolResult findFacilities( @ToolParam(description = "시설 유형, 건물 ID, 층, 선택적 결과 제한") FindFacilitiesToolRequest request) { + toolCallLimiter.beforeToolCall(); return adapter.findFacilities(request); } @Tool(name = "findRoute", description = "Find a campus route between BUILDING/PLACE IDs or request-scoped COORD coordinates. Use searchCampus first when an ID is unknown and never select an ambiguous candidate. BARRIERFREE only excludes stair nodes and is not a complete accessibility guarantee.") public FindRouteToolResult findRoute( @ToolParam(description = "Start, end, and optional BARRIERFREE/SHUTTLE/STUDENTCARD/OPERATING conditions") FindRouteToolRequest request) { + toolCallLimiter.beforeToolCall(); return adapter.findRoute(request); } @Tool(name = "getCafeteriaMenu", description = "교내 식당의 지정 날짜 또는 최대 7일 메뉴를 조회합니다. 일반 카페 상품 메뉴 조회에는 사용하지 않습니다.") public GetCafeteriaMenuToolResult getCafeteriaMenu( @ToolParam(description = "식당 PLACE ID와 조회 시작일, 선택적 종료일") GetCafeteriaMenuToolRequest request) { + toolCallLimiter.beforeToolCall(); return adapter.getCafeteriaMenu(request); } @Tool(name = "getRoomCourses", description = "특정 강의실의 현재 학기 수업 일정을 조회합니다. 개인 시간표나 사용자 수강정보에는 사용하지 않습니다.") public GetRoomCoursesToolResult getRoomCourses( @ToolParam(description = "강의실 PLACE ID와 선택적 요일") GetRoomCoursesToolRequest request) { + toolCallLimiter.beforeToolCall(); return adapter.getRoomCourses(request); } @Tool(name = "getCampusStatus", description = "현재 학기, 방학 여부, 고연전 기간 여부를 조회합니다. 건물 운영시간이나 과거·미래 일정 조회에는 사용하지 않습니다.") public CampusStatusToolResult getCampusStatus() { + toolCallLimiter.beforeToolCall(); return adapter.getCampusStatus(); } @Tool(name = "getCrowdStatus", description = "BLE 센서가 설치된 장소의 추정 혼잡도를 조회합니다. 센서가 없거나 데이터가 오래되면 추정하지 않습니다.") public GetCrowdStatusToolResult getCrowdStatus( @ToolParam(description = "PLACE ID와 선택적 최근 한 달 시간대별 패턴 포함 여부") GetCrowdStatusToolRequest request) { + toolCallLimiter.beforeToolCall(); return adapter.getCrowdStatus(request); } @Tool(name = "getPlaceReviews", description = "리뷰가 지원되는 장소의 평점, 대표 태그, 최근 텍스트 리뷰를 조회합니다. 작성자 정보나 리뷰 이미지는 제공하지 않습니다.") public GetPlaceReviewsToolResult getPlaceReviews( @ToolParam(description = "리뷰 지원 PLACE ID와 선택적 리뷰 개수") GetPlaceReviewsToolRequest request) { + toolCallLimiter.beforeToolCall(); return adapter.getPlaceReviews(request); } } diff --git a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java index 15cc3040..aab691d5 100644 --- a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java +++ b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java @@ -128,8 +128,11 @@ public enum ResultCode { // 챗봇 20000번대 CHATBOT_TEMPORARILY_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, 20000, - "챗봇 서비스를 일시적으로 사용할 수 없습니다."); - + "챗봇 서비스를 일시적으로 사용할 수 없습니다."), + CHATBOT_RATE_LIMITED(HttpStatus.TOO_MANY_REQUESTS, 20001, + "Chatbot request limit exceeded. Please try again shortly."), + CHATBOT_SESSION_FORBIDDEN(HttpStatus.FORBIDDEN, 20002, + "This chatbot session is not available to the current requester."); private final HttpStatus status; private final int code; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e51236e8..96b15dae 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -192,6 +192,11 @@ chatbot: reviews: default-limit: ${CHATBOT_REVIEWS_DEFAULT_LIMIT:5} max-limit: ${CHATBOT_REVIEWS_MAX_LIMIT:10} + rate-limit: + authenticated-daily-limit: ${CHATBOT_AUTHENTICATED_DAILY_LIMIT:30} + anonymous-daily-limit: ${CHATBOT_ANONYMOUS_DAILY_LIMIT:10} + burst-per-minute: ${CHATBOT_BURST_PER_MINUTE:5} + daily-reset-zone: ${CHATBOT_DAILY_RESET_ZONE:Asia/Seoul} push: expo: diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java index c258543c..7c7ecc3d 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java @@ -1,19 +1,26 @@ package devkor.com.teamcback.domain.chatbot.controller; +import static devkor.com.teamcback.global.response.ResultCode.CHATBOT_RATE_LIMITED; import static devkor.com.teamcback.global.response.ResultCode.CHATBOT_TEMPORARILY_UNAVAILABLE; -import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.blankOrNullString; +import static org.hamcrest.Matchers.not; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import devkor.com.teamcback.domain.chatbot.service.ChatRateLimiter; import devkor.com.teamcback.domain.chatbot.service.ChatService; +import devkor.com.teamcback.domain.chatbot.service.ChatSessionMemoryService; import devkor.com.teamcback.global.exception.exception.GlobalException; import devkor.com.teamcback.global.exception.handler.GlobalExceptionHandler; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -25,14 +32,15 @@ @ExtendWith(MockitoExtension.class) class ChatControllerTest { - @Mock - private LlmGateway llmGateway; - + @Mock private LlmGateway llmGateway; + @Mock private ChatSessionMemoryService memoryService; + @Mock private ChatRateLimiter rateLimiter; private MockMvc mockMvc; @BeforeEach void setUp() { - ChatService chatService = new ChatService(llmGateway); + ChatService chatService = new ChatService(llmGateway, memoryService, rateLimiter); + lenient().when(memoryService.load(any(), any())).thenReturn(List.of()); mockMvc = MockMvcBuilders.standaloneSetup(new ChatController(chatService)) .setControllerAdvice(new GlobalExceptionHandler()) .build(); @@ -40,70 +48,58 @@ void setUp() { @Test void rejectsBlankMessageBeforeLlmCall() throws Exception { - mockMvc.perform(post("/api/chatbot/messages") - .contentType(MediaType.APPLICATION_JSON) + mockMvc.perform(post("/api/chatbot/messages").contentType(MediaType.APPLICATION_JSON) .content("{\"message\":\" \"}")) .andExpect(status().isBadRequest()); - verifyNoInteractions(llmGateway); } @Test void rejectsMessageLongerThanOneThousandCharactersBeforeLlmCall() throws Exception { - String request = "{\"message\":\"" + "a".repeat(1001) + "\"}"; - - mockMvc.perform(post("/api/chatbot/messages") - .contentType(MediaType.APPLICATION_JSON) - .content(request)) + mockMvc.perform(post("/api/chatbot/messages").contentType(MediaType.APPLICATION_JSON) + .content("{\"message\":\"" + "a".repeat(1001) + "\"}")) .andExpect(status().isBadRequest()); - verifyNoInteractions(llmGateway); } @Test void rejectsInvalidCoordinatesBeforeLlmCall() throws Exception { - mockMvc.perform(post("/api/chatbot/messages") - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "message": "길을 알려줘", - "context": { - "currentLocation": { - "latitude": 91.0, - "longitude": -181.0 - } - } - } - """)) + mockMvc.perform(post("/api/chatbot/messages").contentType(MediaType.APPLICATION_JSON).content(""" + {"message":"길을 알려줘","context":{"currentLocation":{"latitude":91.0,"longitude":-181.0}}} + """)) .andExpect(status().isBadRequest()); - verifyNoInteractions(llmGateway); } @Test void generatesSessionIdAndReturnsGatewayReply() throws Exception { when(llmGateway.generate(anyString(), anyString())).thenReturn("안녕하세요."); - - mockMvc.perform(post("/api/chatbot/messages") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"message\":\" 안녕 \"}")) + mockMvc.perform(post("/api/chatbot/messages").contentType(MediaType.APPLICATION_JSON) + .content("{\"message\":\" 안녕 \"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.statusCode").value(0)) .andExpect(jsonPath("$.data.sessionId", not(blankOrNullString()))) .andExpect(jsonPath("$.data.reply").value("안녕하세요.")); } + @Test + void returns429BeforeProviderCallWhenRateLimited() throws Exception { + doThrow(new GlobalException(CHATBOT_RATE_LIMITED)).when(rateLimiter).check(any()); + mockMvc.perform(post("/api/chatbot/messages").contentType(MediaType.APPLICATION_JSON) + .content("{\"message\":\"안녕\"}")) + .andExpect(status().isTooManyRequests()) + .andExpect(jsonPath("$.statusCode").value(20001)); + verifyNoInteractions(llmGateway); + } + @Test void hidesProviderFailureDetails() throws Exception { when(llmGateway.generate(anyString(), anyString())) .thenThrow(new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE)); - - mockMvc.perform(post("/api/chatbot/messages") - .contentType(MediaType.APPLICATION_JSON) + mockMvc.perform(post("/api/chatbot/messages").contentType(MediaType.APPLICATION_JSON) .content("{\"message\":\"안녕\"}")) .andExpect(status().isServiceUnavailable()) .andExpect(jsonPath("$.statusCode").value(20000)) - .andExpect(jsonPath("$.message").value("챗봇 서비스를 일시적으로 사용할 수 없습니다.")) .andExpect(jsonPath("$.data").doesNotExist()); } } diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java index 736275ae..a4ccb46b 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java @@ -37,9 +37,11 @@ void mapsProviderFailureWithoutExposingProviderDetails() { new ChatbotProperties.Tools( new ChatbotProperties.Limits(5, 10), new ChatbotProperties.Limits(10, 20), 7, - new ChatbotProperties.Limits(5, 10))); + new ChatbotProperties.Limits(5, 10)), + new ChatbotProperties.RateLimit(30, 10, 5, "Asia/Seoul")); CampusChatbotTools tools = mock(CampusChatbotTools.class); - SpringAiLlmGateway gateway = new SpringAiLlmGateway(builder, properties, executor, tools); + SpringAiLlmGateway gateway = new SpringAiLlmGateway(builder, properties, executor, tools, + new devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter(properties)); assertThatThrownBy(() -> gateway.generate("system", "hello")) .isInstanceOfSatisfying(GlobalException.class, exception -> { diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceCurrentLocationTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceCurrentLocationTest.java index f4cd8805..3f4d1373 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceCurrentLocationTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceCurrentLocationTest.java @@ -9,6 +9,7 @@ import devkor.com.teamcback.domain.chatbot.dto.request.ChatMessageReq; import devkor.com.teamcback.domain.chatbot.dto.request.CurrentLocationReq; import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import java.util.List; import java.util.UUID; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -19,16 +20,20 @@ @ExtendWith(MockitoExtension.class) class ChatServiceCurrentLocationTest { @Mock LlmGateway llmGateway; + @Mock ChatSessionMemoryService memoryService; + @Mock ChatRateLimiter rateLimiter; @Test - void passesCurrentLocationOnlyInCurrentProviderRequestWithoutAddingMemoryState() { + void passesCurrentLocationOnlyInCurrentProviderRequestAndNeverSavesIt() { when(llmGateway.generate(anyString(), anyString())).thenReturn("경로 안내"); - ChatService service = new ChatService(llmGateway); + ChatService service = new ChatService(llmGateway, memoryService, rateLimiter); UUID sessionId = UUID.randomUUID(); + ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); + when(memoryService.load(sessionId, caller)).thenReturn(List.of()); service.sendMessage(new ChatMessageReq(sessionId, "현재 위치에서 중도까지 가줘", - new ChatContextReq(new CurrentLocationReq(37.5861, 127.0290)))); - service.sendMessage(new ChatMessageReq(sessionId, "다시 알려줘", null)); + new ChatContextReq(new CurrentLocationReq(37.5861, 127.0290))), caller); + service.sendMessage(new ChatMessageReq(sessionId, "다시 알려줘", null), caller); ArgumentCaptor messages = ArgumentCaptor.forClass(String.class); verify(llmGateway, org.mockito.Mockito.times(2)).generate(anyString(), messages.capture()); @@ -36,7 +41,7 @@ void passesCurrentLocationOnlyInCurrentProviderRequestWithoutAddingMemoryState() .contains("현재 위치에서 중도까지 가줘", "latitude=37.5861", "longitude=127.029"); assertThat(messages.getAllValues().get(1)).isEqualTo("다시 알려줘") .doesNotContain("37.5861", "127.029", "currentLocation"); - assertThat(ChatService.class.getDeclaredFields()).extracting(java.lang.reflect.Field::getName) - .doesNotContain("currentLocation", "latitude", "longitude", "memory"); + verify(memoryService).save(sessionId, caller, "현재 위치에서 중도까지 가줘", "경로 안내"); + verify(memoryService).save(sessionId, caller, "다시 알려줘", "경로 안내"); } } diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatbotOperationsTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatbotOperationsTest.java new file mode 100644 index 00000000..8520dd93 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatbotOperationsTest.java @@ -0,0 +1,99 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static devkor.com.teamcback.global.response.ResultCode.CHATBOT_RATE_LIMITED; +import static devkor.com.teamcback.global.response.ResultCode.CHATBOT_SESSION_FORBIDDEN; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +class ChatbotOperationsTest { + private final ChatbotProperties properties = new ChatbotProperties(true, + new ChatbotProperties.Llm("google", "model", 500, 8), + new ChatbotProperties.Agent(6, 5, 60), + new ChatbotProperties.Tools(new ChatbotProperties.Limits(5, 10), + new ChatbotProperties.Limits(10, 20), 7, new ChatbotProperties.Limits(5, 10)), + new ChatbotProperties.RateLimit(30, 10, 5, "Asia/Seoul")); + + @Test + void sessionMemoryKeepsFiveTurnsRefreshesTtlAndExcludesCoordinates() throws Exception { + StringRedisTemplate redis = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") ValueOperations values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + AtomicReference stored = new AtomicReference<>(); + when(values.get(anyString())).thenAnswer(invocation -> stored.get()); + doAnswer(invocation -> { + stored.set(invocation.getArgument(1)); + return null; + }).when(values).set(anyString(), anyString(), any(Long.class), any(java.util.concurrent.TimeUnit.class)); + + ObjectMapper objectMapper = new ObjectMapper(); + ChatSessionMemoryService memory = new ChatSessionMemoryService(redis, objectMapper, properties); + java.util.UUID sessionId = java.util.UUID.randomUUID(); + ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); + for (int index = 1; index <= 6; index++) { + memory.save(sessionId, caller, "message-" + index, "reply-" + index); + } + + JsonNode document = objectMapper.readTree(stored.get()); + assertThat(document.at("/turns")).hasSize(5); + assertThat(document.toString()).contains("message-2", "reply-6") + .doesNotContain("37.5861", "127.0290", "currentLocation"); + assertThat(memory.load(sessionId, caller)).hasSize(5); + org.mockito.Mockito.verify(redis, org.mockito.Mockito.atLeastOnce()) + .expire(anyString(), org.mockito.ArgumentMatchers.eq(60L), + org.mockito.ArgumentMatchers.eq(java.util.concurrent.TimeUnit.MINUTES)); + } + + @Test + void sessionMemoryRejectsDifferentOwner() throws Exception { + StringRedisTemplate redis = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") ValueOperations values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + ObjectMapper objectMapper = new ObjectMapper(); + when(values.get(anyString())).thenReturn(objectMapper.writeValueAsString( + new ChatSessionMemoryService.StoredSession("user:1", java.util.List.of()))); + ChatSessionMemoryService memory = new ChatSessionMemoryService(redis, objectMapper, properties); + + assertThatThrownBy(() -> memory.load(java.util.UUID.randomUUID(), ChatCaller.from(null, "127.0.0.1"))) + .isInstanceOfSatisfying(GlobalException.class, + exception -> assertThat(exception.getResultCode()).isEqualTo(CHATBOT_SESSION_FORBIDDEN)); + } + + @Test + void rateLimiterRejectsBeforeDailyLimitCanBeExceeded() { + StringRedisTemplate redis = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") ValueOperations values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + when(values.increment(anyString())).thenReturn(1L, 31L); + ChatRateLimiter limiter = new ChatRateLimiter(redis, properties); + + assertThatThrownBy(() -> limiter.check(ChatCaller.from(null, "127.0.0.1"))) + .isInstanceOfSatisfying(GlobalException.class, + exception -> assertThat(exception.getResultCode()).isEqualTo(CHATBOT_RATE_LIMITED)); + } + + @Test + void toolCallLimiterBlocksSeventhInvocation() { + ChatbotToolCallLimiter limiter = new ChatbotToolCallLimiter(properties); + try (ChatbotToolCallLimiter.Scope scope = limiter.open()) { + for (int index = 0; index < 6; index++) { + limiter.beforeToolCall(); + } + assertThat(scope.callCount()).isEqualTo(6); + assertThatThrownBy(limiter::beforeToolCall).isInstanceOf(ToolCallLimitExceededException.class); + } + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsToolLimitTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsToolLimitTest.java new file mode 100644 index 00000000..6e383ac2 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsToolLimitTest.java @@ -0,0 +1,34 @@ +package devkor.com.teamcback.domain.chatbot.tool; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter; +import devkor.com.teamcback.domain.chatbot.service.ToolCallLimitExceededException; +import org.junit.jupiter.api.Test; + +class CampusChatbotToolsToolLimitTest { + @Test + void seventhActualToolMethodDoesNotReachAdapter() { + CampusToolAdapter adapter = mock(CampusToolAdapter.class); + ChatbotProperties properties = new ChatbotProperties(true, + new ChatbotProperties.Llm("google", "model", 500, 8), + new ChatbotProperties.Agent(6, 5, 60), + new ChatbotProperties.Tools(new ChatbotProperties.Limits(5, 10), + new ChatbotProperties.Limits(10, 20), 7, new ChatbotProperties.Limits(5, 10)), + new ChatbotProperties.RateLimit(30, 10, 5, "Asia/Seoul")); + ChatbotToolCallLimiter limiter = new ChatbotToolCallLimiter(properties); + CampusChatbotTools tools = new CampusChatbotTools(adapter, limiter); + + try (ChatbotToolCallLimiter.Scope ignored = limiter.open()) { + for (int index = 0; index < 6; index++) { + tools.getCampusStatus(); + } + assertThatThrownBy(tools::getCampusStatus).isInstanceOf(ToolCallLimitExceededException.class); + } + verify(adapter, times(6)).getCampusStatus(); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusLifeToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusLifeToolAdapterTest.java index 2673a225..bb861aca 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusLifeToolAdapterTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusLifeToolAdapterTest.java @@ -55,7 +55,8 @@ void setUp() { new ChatbotProperties.Agent(6, 5, 60), new ChatbotProperties.Tools(new ChatbotProperties.Limits(5, 10), new ChatbotProperties.Limits(10, 20), 7, - new ChatbotProperties.Limits(5, 10))); + new ChatbotProperties.Limits(5, 10)), + new ChatbotProperties.RateLimit(30, 10, 5, "Asia/Seoul")); adapter = new CampusToolAdapter(searchService, routeService, cafeteriaMenuService, courseService, schoolCalendarService, null, null, properties); } diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRealtimeReviewToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRealtimeReviewToolAdapterTest.java index 301ef17c..d63ed2a0 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRealtimeReviewToolAdapterTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRealtimeReviewToolAdapterTest.java @@ -56,7 +56,8 @@ void setUp() { new ChatbotProperties.Agent(6, 5, 60), new ChatbotProperties.Tools(new ChatbotProperties.Limits(5, 10), new ChatbotProperties.Limits(10, 20), 7, - new ChatbotProperties.Limits(5, 10))); + new ChatbotProperties.Limits(5, 10)), + new ChatbotProperties.RateLimit(30, 10, 5, "Asia/Seoul")); adapter = new CampusToolAdapter(searchService, routeService, cafeteriaMenuService, courseService, schoolCalendarService, bleService, reviewService, properties); } diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRouteToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRouteToolAdapterTest.java index 94eb0074..95f68762 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRouteToolAdapterTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRouteToolAdapterTest.java @@ -52,7 +52,8 @@ void setUp() { new ChatbotProperties.Agent(6, 5, 60), new ChatbotProperties.Tools(new ChatbotProperties.Limits(5, 10), new ChatbotProperties.Limits(10, 20), 7, - new ChatbotProperties.Limits(5, 10))); + new ChatbotProperties.Limits(5, 10)), + new ChatbotProperties.RateLimit(30, 10, 5, "Asia/Seoul")); adapter = new CampusToolAdapter(searchService, routeService, null, null, null, null, null, properties); } diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java index fa9ada7b..7e562fe1 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java @@ -60,7 +60,8 @@ void setUp() { new ChatbotProperties.Tools( new ChatbotProperties.Limits(5, 10), new ChatbotProperties.Limits(10, 20), 7, - new ChatbotProperties.Limits(5, 10))); + new ChatbotProperties.Limits(5, 10)), + new ChatbotProperties.RateLimit(30, 10, 5, "Asia/Seoul")); adapter = new CampusToolAdapter(searchService, routeService, null, null, null, null, null, properties); } From 65c08fb1cda814dd8888a778999c8bd8c06bb80f Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Sat, 26 Sep 2026 17:24:27 +0900 Subject: [PATCH 07/21] feat(chatbot): improve route tool calling guidance --- .gitignore | 2 +- .../BuildingNicknameRepository.java | 8 + .../chatbot/config/ChatbotProperties.java | 3 +- .../domain/chatbot/dto/ClientAction.java | 7 + .../domain/chatbot/dto/ClientActionType.java | 5 + .../chatbot/dto/NavigateRouteAction.java | 19 + .../chatbot/dto/PendingLocationRef.java | 7 + .../domain/chatbot/dto/PendingRouteState.java | 21 + .../domain/chatbot/dto/ResolvedLocation.java | 26 ++ .../chatbot/dto/SearchResolutionTrace.java | 21 + .../chatbot/dto/response/ChatMessageRes.java | 8 +- .../domain/chatbot/gateway/LlmGateway.java | 36 +- .../chatbot/gateway/SpringAiLlmGateway.java | 382 +++++++++++++++++- .../domain/chatbot/service/ChatService.java | 285 +++++++++++-- .../service/ChatbotToolCallLimiter.java | 26 +- .../service/PendingRouteStateService.java | 70 ++++ .../service/ResolvedLocationCollector.java | 78 ++++ .../chatbot/tool/CampusChatbotTools.java | 120 ++++-- .../chatbot/tool/CampusToolAdapter.java | 101 ++++- .../chatbot/tool/dto/SearchCampusIntent.java | 11 + .../chatbot/tool/dto/SearchCampusItem.java | 3 +- .../tool/dto/SearchCampusMatchType.java | 7 + .../chatbot/tool/dto/SearchCampusRole.java | 11 + .../tool/dto/SearchCampusToolRequest.java | 17 +- .../repository/PlaceNicknameRepository.java | 5 + .../dto/response/ChatbotSearchCandidate.java | 16 + .../domain/search/service/SearchService.java | 85 ++++ src/main/resources/application-dev-local.yml | 40 ++ src/main/resources/application.yml | 10 +- .../controller/ChatControllerTest.java | 30 +- .../gateway/SpringAiLlmGatewayTest.java | 137 ++++++- .../service/ChatServiceActionTest.java | 250 ++++++++++++ .../ChatServiceCurrentLocationTest.java | 41 +- .../service/ChatServicePromptTest.java | 23 ++ .../service/ChatbotOperationsTest.java | 6 +- .../service/PendingRouteStateServiceTest.java | 65 +++ .../ResolvedLocationCollectorTest.java | 81 ++++ .../chatbot/tool/CampusChatbotToolsTest.java | 47 ++- .../tool/CampusChatbotToolsToolLimitTest.java | 9 +- .../tool/CampusChatbotToolsWiringTest.java | 42 ++ .../tool/CampusLifeToolAdapterTest.java | 2 +- .../CampusRealtimeReviewToolAdapterTest.java | 2 +- .../tool/CampusRouteToolAdapterTest.java | 2 +- .../chatbot/tool/CampusToolAdapterTest.java | 124 +++++- 44 files changed, 2106 insertions(+), 185 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/ClientAction.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/ClientActionType.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/NavigateRouteAction.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/PendingLocationRef.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/PendingRouteState.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/ResolvedLocation.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/SearchResolutionTrace.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/PendingRouteStateService.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/ResolvedLocationCollector.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusIntent.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusMatchType.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusRole.java create mode 100644 src/main/java/devkor/com/teamcback/domain/search/dto/response/ChatbotSearchCandidate.java create mode 100644 src/main/resources/application-dev-local.yml create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServicePromptTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/service/PendingRouteStateServiceTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/service/ResolvedLocationCollectorTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsWiringTest.java diff --git a/.gitignore b/.gitignore index 4055adb4..92b610de 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,4 @@ out/ # Local-only config /src/main/resources/application-local.yml /.env.local -/docker-compose.local.yml \ No newline at end of file +/docker-compose.local.yml diff --git a/src/main/java/devkor/com/teamcback/domain/building/repository/BuildingNicknameRepository.java b/src/main/java/devkor/com/teamcback/domain/building/repository/BuildingNicknameRepository.java index 01c29ae5..429b0d8d 100644 --- a/src/main/java/devkor/com/teamcback/domain/building/repository/BuildingNicknameRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/building/repository/BuildingNicknameRepository.java @@ -2,14 +2,22 @@ import devkor.com.teamcback.domain.building.entity.Building; import devkor.com.teamcback.domain.building.entity.BuildingNickname; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.EntityGraph; import java.util.List; public interface BuildingNicknameRepository extends JpaRepository { + @EntityGraph(attributePaths = "building") List findAllByChosungContaining(String chosung); + @EntityGraph(attributePaths = "building") List findAllByJasoDecomposeContaining(String jaso); + @EntityGraph(attributePaths = "building") + List findAllByChosungContainingOrderByNickname(String chosung, Pageable pageable); + @EntityGraph(attributePaths = "building") + List findAllByJasoDecomposeContainingOrderByNickname(String jaso, Pageable pageable); List findByChosungIsNullOrJasoDecomposeIsNull(); List findAllByBuilding(Building building); List findAllByNicknameContaining(String blank); diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java b/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java index 8fd597fd..afd263f8 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java @@ -11,7 +11,8 @@ public Duration timeout() { } } - public record Agent(int maxToolCallsPerMessage, int historyTurns, int sessionTtlMinutes) { + public record Agent(int maxToolCallsPerMessage, int historyTurns, int sessionTtlMinutes, + int pendingRouteTtlMinutes) { } public record Tools(Limits search, Limits facilities, int menuMaxDays, Limits reviews) { diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/ClientAction.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/ClientAction.java new file mode 100644 index 00000000..24fcb591 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/ClientAction.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +public record ClientAction( + ClientActionType type, + NavigateRouteAction payload +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/ClientActionType.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/ClientActionType.java new file mode 100644 index 00000000..c211a7b1 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/ClientActionType.java @@ -0,0 +1,5 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +public enum ClientActionType { + NAVIGATE_ROUTE +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/NavigateRouteAction.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/NavigateRouteAction.java new file mode 100644 index 00000000..1b92cdc4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/NavigateRouteAction.java @@ -0,0 +1,19 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteCondition; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteEndpointType; +import java.util.List; + +public record NavigateRouteAction( + RouteEndpointType startType, + Long startId, + String startName, + RouteEndpointType endType, + Long endId, + String endName, + List conditions +) { + public NavigateRouteAction { + conditions = conditions == null ? List.of() : List.copyOf(conditions); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/PendingLocationRef.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/PendingLocationRef.java new file mode 100644 index 00000000..4174376e --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/PendingLocationRef.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteEndpointType; + +/** Minimal, non-sensitive location reference used by pending interactions. */ +public record PendingLocationRef(RouteEndpointType locationType, Long locationId, String name) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/PendingRouteState.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/PendingRouteState.java new file mode 100644 index 00000000..d29ddcad --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/PendingRouteState.java @@ -0,0 +1,21 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteCondition; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusRole; +import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation.RouteIntent; +import java.util.List; + +/** Minimal typed state for a route interaction that needs another user turn. */ +public record PendingRouteState( + RouteIntent interactionType, + PendingLocationRef resolvedStart, + PendingLocationRef resolvedEnd, + SearchCampusRole unresolvedRole, + List conditions, + List ambiguousCandidates +) { + public PendingRouteState { + conditions = conditions == null ? List.of() : List.copyOf(conditions); + ambiguousCandidates = ambiguousCandidates == null ? List.of() : List.copyOf(ambiguousCandidates); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/ResolvedLocation.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/ResolvedLocation.java new file mode 100644 index 00000000..0bbe4f79 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/ResolvedLocation.java @@ -0,0 +1,26 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteCondition; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteEndpointType; +import java.util.List; + +public record ResolvedLocation( + EndpointRole role, + RouteEndpointType type, + Long id, + String name, + RouteIntent intent, + List conditions +) { + public ResolvedLocation { + conditions = conditions == null ? List.of() : List.copyOf(conditions); + } + + public enum EndpointRole { + START, END + } + + public enum RouteIntent { + NAVIGATE_ROUTE, TEXT_ROUTE + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/SearchResolutionTrace.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/SearchResolutionTrace.java new file mode 100644 index 00000000..8caf03f2 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/SearchResolutionTrace.java @@ -0,0 +1,21 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteCondition; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusIntent; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusRole; +import java.util.List; + +/** Request-local trace of searchCampus, including ambiguous candidates. */ +public record SearchResolutionTrace( + SearchCampusRole role, + SearchCampusIntent intent, + String query, + boolean ambiguous, + List candidates, + List conditions +) { + public SearchResolutionTrace { + candidates = candidates == null ? List.of() : List.copyOf(candidates); + conditions = conditions == null ? List.of() : List.copyOf(conditions); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/response/ChatMessageRes.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/response/ChatMessageRes.java index 5fdbe7f7..3f69cac9 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/response/ChatMessageRes.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/response/ChatMessageRes.java @@ -1,6 +1,12 @@ package devkor.com.teamcback.domain.chatbot.dto.response; +import com.fasterxml.jackson.annotation.JsonInclude; +import devkor.com.teamcback.domain.chatbot.dto.ClientAction; import java.util.UUID; -public record ChatMessageRes(UUID sessionId, String reply) { +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ChatMessageRes(UUID sessionId, String reply, ClientAction action) { + public ChatMessageRes(UUID sessionId, String reply) { + this(sessionId, reply, null); + } } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java index b61536a3..32f2a040 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java @@ -1,5 +1,39 @@ package devkor.com.teamcback.domain.chatbot.gateway; +import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation; +import devkor.com.teamcback.domain.chatbot.dto.SearchResolutionTrace; +import java.util.List; + public interface LlmGateway { - String generate(String systemPrompt, String userMessage); + LlmResult generate(String systemPrompt, List history, String userMessage); + + record LlmResult(String reply, List resolvedLocations, CompletionStatus completionStatus, + List searchResolutions) { + public LlmResult(String reply, List resolvedLocations) { + this(reply, resolvedLocations, CompletionStatus.COMPLETE, List.of()); + } + + public LlmResult(String reply, List resolvedLocations, CompletionStatus completionStatus) { + this(reply, resolvedLocations, completionStatus, List.of()); + } + + public LlmResult { + resolvedLocations = resolvedLocations == null ? List.of() : List.copyOf(resolvedLocations); + completionStatus = completionStatus == null ? CompletionStatus.COMPLETE : completionStatus; + searchResolutions = searchResolutions == null ? List.of() : List.copyOf(searchResolutions); + } + } + + enum CompletionStatus { + COMPLETE, + FAILED_AFTER_TOOL_EXECUTION + } + + enum Role { + USER, + ASSISTANT + } + + record ConversationMessage(Role role, String content) { + } } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java index 858226ff..89111371 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java @@ -3,17 +3,29 @@ import static devkor.com.teamcback.global.response.ResultCode.CHATBOT_TEMPORARILY_UNAVAILABLE; import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation; +import devkor.com.teamcback.domain.chatbot.service.ResolvedLocationCollector; import devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter; import devkor.com.teamcback.domain.chatbot.service.ToolCallLimitExceededException; import devkor.com.teamcback.domain.chatbot.tool.CampusChatbotTools; import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.ChatClientResponse; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.metadata.ChatGenerationMetadata; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import lombok.extern.slf4j.Slf4j; @@ -28,6 +40,9 @@ public class SpringAiLlmGateway implements LlmGateway { private final ExecutorService chatbotLlmExecutor; private final CampusChatbotTools campusChatbotTools; private final ChatbotToolCallLimiter toolCallLimiter; + /** Enables stack-frame diagnostics without ever logging prompt/tool payloads. */ + @Value("${chatbot.llm.diagnostics-enabled:false}") + private boolean diagnosticsEnabled; public SpringAiLlmGateway(ChatClient.Builder chatClientBuilder, ChatbotProperties properties, ExecutorService chatbotLlmExecutor, CampusChatbotTools campusChatbotTools, @@ -40,55 +55,366 @@ public SpringAiLlmGateway(ChatClient.Builder chatClientBuilder, ChatbotPropertie } @Override - public String generate(String systemPrompt, String userMessage) { - Future response = chatbotLlmExecutor.submit(() -> invoke(systemPrompt, userMessage)); + public LlmResult generate(String systemPrompt, List history, String userMessage) { + ResolvedLocationCollector collector = new ResolvedLocationCollector(); + Future response = chatbotLlmExecutor.submit( + () -> invoke(systemPrompt, history, userMessage, collector)); try { GatewayResult result = response.get(properties.llm().timeout().toMillis(), TimeUnit.MILLISECONDS); log.info("chatbot_llm outcome={} provider={} model={} latencyMs={} toolCalls={} inputTokens=unavailable outputTokens=unavailable", result.outcome(), properties.llm().provider(), properties.llm().model(), result.latencyMillis(), result.toolCalls()); - return result.content(); + return result.result(); } catch (InterruptedException exception) { response.cancel(true); Thread.currentThread().interrupt(); - log.warn("chatbot_llm outcome=TEMPORARILY_UNAVAILABLE provider={} model={}", - properties.llm().provider(), properties.llm().model()); + logFailure("TEMPORARILY_UNAVAILABLE", "ASYNC_WAIT", exception, false); throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); - } catch (ExecutionException | TimeoutException exception) { + } catch (TimeoutException exception) { + response.cancel(true); + if (collector.hasNavigateIntent()) { + logFailure("NAVIGATE_ROUTE_FAIL_SAFE", "ASYNC_WAIT_AFTER_TOOL_EXECUTION", exception, true); + return new LlmResult(null, collector.snapshot(), + LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION, + collector.searchResolutionSnapshot()); + } + logFailure("TEMPORARILY_UNAVAILABLE", "ASYNC_WAIT", exception, true); + throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); + } catch (ExecutionException exception) { response.cancel(true); if (hasCause(exception, ToolCallLimitExceededException.class)) { log.info("chatbot_llm outcome=TOOL_LIMIT provider={} model={}", properties.llm().provider(), properties.llm().model()); - return TOOL_LIMIT_FALLBACK; + return new LlmResult(TOOL_LIMIT_FALLBACK, List.of()); } - log.warn("chatbot_llm outcome=TEMPORARILY_UNAVAILABLE provider={} model={}", - properties.llm().provider(), properties.llm().model()); + logFailure("TEMPORARILY_UNAVAILABLE", failureStage(exception), exception, false); throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); } } - private GatewayResult invoke(String systemPrompt, String userMessage) { + private GatewayResult invoke(String systemPrompt, List history, String userMessage, + ResolvedLocationCollector collector) { long startedAt = System.nanoTime(); try (ChatbotToolCallLimiter.Scope scope = toolCallLimiter.open()) { try { - String content = chatClient.prompt() + CampusChatbotTools requestTools = campusChatbotTools.forRequest(collector, scope); + ChatClient.CallResponseSpec callResponse = chatClient.prompt() .system(systemPrompt) - .user(userMessage) - .tools(campusChatbotTools) - .call() - .content(); + .messages(toSpringMessages(history, userMessage)) + .tools(requestTools) + .call(); + ChatClientResponse clientResponse = callResponse == null ? null : callResponse.chatClientResponse(); + ChatResponse chatResponse = clientResponse == null ? null : clientResponse.chatResponse(); + String content = extractContent(chatResponse); if (content == null || content.isBlank()) { - throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); + logEmptyCompletionDiagnostics(chatResponse, scope, collector); + throw new EmptyLlmCompletionException(); } - return new GatewayResult(content, "SUCCESS", elapsedMillis(startedAt), scope.callCount()); + return new GatewayResult(new LlmGateway.LlmResult(content, collector.snapshot(), + LlmGateway.CompletionStatus.COMPLETE, collector.searchResolutionSnapshot()), "SUCCESS", + elapsedMillis(startedAt), scope.callCount()); } catch (RuntimeException exception) { if (hasCause(exception, ToolCallLimitExceededException.class)) { - return new GatewayResult(TOOL_LIMIT_FALLBACK, "TOOL_LIMIT", elapsedMillis(startedAt), + return new GatewayResult(new LlmGateway.LlmResult(TOOL_LIMIT_FALLBACK, java.util.List.of()), + "TOOL_LIMIT", elapsedMillis(startedAt), scope.callCount()); } - throw exception; + List resolvedLocations = collector.snapshot(); + String stage = resolvedLocations.isEmpty() ? "MODEL_TOOL_LOOP" : "POST_TOOL_EXECUTION"; + if (collector.hasNavigateIntent()) { + logFailure("NAVIGATE_ROUTE_FAIL_SAFE", stage, exception, false); + return new GatewayResult(new LlmGateway.LlmResult(null, resolvedLocations, + LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION, + collector.searchResolutionSnapshot()), + "NAVIGATE_ROUTE_FAIL_SAFE", elapsedMillis(startedAt), scope.callCount()); + } + throw new StagedLlmInvocationException(stage, exception); + } + } + } + + private void logEmptyCompletionDiagnostics(ChatResponse response, ChatbotToolCallLimiter.Scope scope, + ResolvedLocationCollector collector) { + if (!diagnosticsEnabled) { + return; + } + if (response == null) { + log.error("chatbot_llm empty_completion responseNull=true generationCount=0 " + + "toolCallCount=0 toolCallNames=[] toolCallsPresent=false " + + "textState=unavailable finishReasons=[] metadataClass=unavailable " + + "toolCalls={} resolvedLocationCount={} searchResolutionCount={} " + + "navigateIntent={} ambiguousTrace={}", + scope.callCount(), collector.snapshot().size(), collector.searchResolutionSnapshot().size(), + collector.hasNavigateIntent(), hasAmbiguousTrace(collector)); + return; + } + + List generations = response.getResults() == null ? List.of() : response.getResults(); + List toolNames = new ArrayList<>(); + int outputCount = 0; + int assistantCount = 0; + int blankTextCount = 0; + List finishReasons = new ArrayList<>(); + for (Generation generation : generations) { + if (generation == null) { + continue; + } + if (generation.getOutput() != null) { + outputCount++; + AssistantMessage assistant = generation.getOutput(); + assistantCount++; + String text = assistant.getText(); + if (text == null || text.isBlank()) { + blankTextCount++; + } + if (assistant.getToolCalls() != null) { + assistant.getToolCalls().forEach(toolCall -> { + if (toolCall != null && toolCall.name() != null) { + toolNames.add(toolCall.name()); + } + }); + } + } + ChatGenerationMetadata metadata = generation.getMetadata(); + if (metadata != null && metadata.getFinishReason() != null) { + finishReasons.add(metadata.getFinishReason()); + } + } + log.error("chatbot_llm empty_completion responseNull=false generationCount={} outputCount={} " + + "assistantCount={} blankTextCount={} toolCallCount={} toolCallNames={} " + + "toolCallsPresent={} finishReasons={} responseMetadataClass={} " + + "toolCalls={} resolvedLocationCount={} searchResolutionCount={} " + + "navigateIntent={} ambiguousTrace={}", + generations.size(), outputCount, assistantCount, blankTextCount, toolNames.size(), toolNames, + !toolNames.isEmpty(), finishReasons, + response.getMetadata() == null ? "unavailable" : response.getMetadata().getClass().getName(), + scope.callCount(), collector.snapshot().size(), collector.searchResolutionSnapshot().size(), + collector.hasNavigateIntent(), hasAmbiguousTrace(collector)); + } + + /** Mirrors ChatClient.content() extraction without issuing a second ChatModel call. */ + private String extractContent(ChatResponse response) { + if (response == null || response.getResults() == null || response.getResults().isEmpty()) { + return null; + } + Generation generation = response.getResults().get(0); + if (generation == null || generation.getOutput() == null) { + return null; + } + return generation.getOutput().getText(); + } + + private boolean hasAmbiguousTrace(ResolvedLocationCollector collector) { + return collector.searchResolutionSnapshot().stream().anyMatch(trace -> trace.ambiguous()); + } + + private void logFailure(String outcome, String stage, Throwable exception, boolean timeout) { + Throwable failure = unwrapExecutionException(exception); + Throwable rootCause = rootCause(failure); + String providerStatus = providerStatus(exception); + boolean timedOut = timeout || isTimeout(exception, providerStatus); + log.warn("chatbot_llm outcome={} stage={} provider={} model={} exceptionClass={} rootCauseClass={} " + + "safeMessage={} timeout={} providerStatus={}", + outcome, stage, properties.llm().provider(), properties.llm().model(), + failure.getClass().getName(), rootCause.getClass().getName(), + safeMessage(exception, timedOut, providerStatus), timedOut, + providerStatus == null ? "unavailable" : providerStatus); + logDiagnosticFailure(stage, failure, rootCause, providerStatus, timedOut); + } + + private void logDiagnosticFailure(String stage, Throwable failure, Throwable rootCause, + String providerStatus, boolean timedOut) { + if (!diagnosticsEnabled) { + return; + } + log.error("chatbot_llm diagnostic stage={} exceptionClass={} rootCauseClass={} " + + "originalSafeMessage={} timeout={} providerStatus={} causeChain={} " + + "suppressedCount={} firstApplicationFrame={} firstSpringAiFrame={} " + + "firstProviderFrame={} stackFrames={}", + stage, failure.getClass().getName(), rootCause.getClass().getName(), + diagnosticMessage(rootCause), timedOut, + providerStatus == null ? "unavailable" : providerStatus, + causeChain(failure), suppressedCount(failure), + firstFrame(failure, "devkor."), firstFrame(failure, "org.springframework.ai."), + firstProviderFrame(failure), stackFrames(failure)); + } + + private String diagnosticMessage(Throwable exception) { + String message = exception.getMessage(); + if (message == null || message.isBlank()) { + return ""; + } + // Messages may contain model/tool arguments. Keep only a bounded, non-JSON hint. + if (message.contains("{") || message.contains("}") || message.contains("\"") + || message.length() > 240) { + return ""; + } + return message.replaceAll("(?i)(token|secret|password|authorization|credential|coordinate|prompt)\\s*[:=].*", + "$1="); + } + + private String causeChain(Throwable exception) { + List classes = new ArrayList<>(); + Throwable current = exception; + while (current != null && classes.size() < 12) { + classes.add(current.getClass().getName()); + current = current.getCause(); + } + return String.join(" -> ", classes); + } + + private int suppressedCount(Throwable exception) { + int count = 0; + Throwable current = exception; + while (current != null) { + count += current.getSuppressed().length; + current = current.getCause(); + } + return count; + } + + private String firstFrame(Throwable exception, String packagePrefix) { + Throwable current = exception; + while (current != null) { + for (StackTraceElement frame : current.getStackTrace()) { + if (frame.getClassName().startsWith(packagePrefix)) { + return frame.toString(); + } + } + current = current.getCause(); + } + return "unavailable"; + } + + private String firstProviderFrame(Throwable exception) { + Throwable current = exception; + while (current != null) { + for (StackTraceElement frame : current.getStackTrace()) { + String className = frame.getClassName(); + if (className.startsWith("com.google.") || className.startsWith("com.fasterxml.") + || className.startsWith("io.grpc.")) { + return frame.toString(); + } + } + current = current.getCause(); + } + return "unavailable"; + } + + private String stackFrames(Throwable exception) { + List frames = new ArrayList<>(); + Throwable current = exception; + while (current != null && frames.size() < 80) { + for (StackTraceElement frame : current.getStackTrace()) { + if (frames.size() >= 80) { + break; + } + frames.add(frame.toString()); + } + current = current.getCause(); + } + return String.join(" | ", frames); + } + + private boolean isTimeout(Throwable exception, String providerStatus) { + if ("DEADLINE_EXCEEDED".equals(providerStatus) || "REQUEST_TIMEOUT".equals(providerStatus) + || "408".equals(providerStatus) || "504".equals(providerStatus)) { + return true; + } + Throwable current = exception; + while (current != null) { + String className = current.getClass().getSimpleName(); + if (current instanceof TimeoutException || className.contains("Timeout") + || className.contains("DeadlineExceeded")) { + return true; + } + current = current.getCause(); + } + return false; + } + + private Throwable unwrapExecutionException(Throwable exception) { + Throwable current = exception; + while ((current instanceof ExecutionException || current instanceof StagedLlmInvocationException) + && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + private Throwable rootCause(Throwable exception) { + Throwable current = exception; + while (current.getCause() != null && current.getCause() != current) { + current = current.getCause(); + } + return current; + } + + private String failureStage(Throwable exception) { + Throwable current = exception; + while (current != null) { + if (current instanceof StagedLlmInvocationException staged) { + return staged.stage(); + } + current = current.getCause(); + } + return "ASYNC_EXECUTION"; + } + + private String safeMessage(Throwable exception, boolean timeout, String providerStatus) { + if (timeout) { + return "LLM invocation timed out"; + } + if (hasCause(exception, EmptyLlmCompletionException.class)) { + return "Provider returned no final text completion"; + } + if (providerStatus != null) { + return "Provider request failed with status " + providerStatus; + } + return "LLM invocation failed"; + } + + private String providerStatus(Throwable exception) { + Throwable current = exception; + while (current != null) { + try { + Object status = current.getClass().getMethod("getStatusCode").invoke(current); + if (status != null) { + Object code = invokeNoArg(status, "getCode"); + if (code == null) { + code = invokeNoArg(status, "value"); + } + String value = String.valueOf(code == null ? status : code); + if (value.matches("[A-Za-z0-9_.-]{1,64}")) { + return value; + } + } + } catch (ReflectiveOperationException ignored) { + // Provider-neutral best effort: not every exception exposes a status. } + current = current.getCause(); } + return null; + } + + private Object invokeNoArg(Object target, String methodName) { + try { + return target.getClass().getMethod(methodName).invoke(target); + } catch (ReflectiveOperationException ignored) { + return null; + } + } + + private List toSpringMessages(List history, String userMessage) { + List messages = new ArrayList<>(); + for (ConversationMessage message : history) { + messages.add(switch (message.role()) { + case USER -> new UserMessage(message.content()); + case ASSISTANT -> new AssistantMessage(message.content()); + }); + } + messages.add(new UserMessage(userMessage)); + return List.copyOf(messages); } private boolean hasCause(Throwable exception, Class type) { @@ -106,6 +432,22 @@ private long elapsedMillis(long startedAt) { return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt); } - private record GatewayResult(String content, String outcome, long latencyMillis, int toolCalls) { + private record GatewayResult(LlmGateway.LlmResult result, String outcome, long latencyMillis, int toolCalls) { + } + + private static final class EmptyLlmCompletionException extends RuntimeException { + } + + private static final class StagedLlmInvocationException extends RuntimeException { + private final String stage; + + private StagedLlmInvocationException(String stage, RuntimeException cause) { + super(cause); + this.stage = stage; + } + + private String stage() { + return stage; + } } } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java index e8c70df6..9b02a8da 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java @@ -1,10 +1,29 @@ package devkor.com.teamcback.domain.chatbot.service; +import static devkor.com.teamcback.global.response.ResultCode.CHATBOT_TEMPORARILY_UNAVAILABLE; + +import devkor.com.teamcback.domain.chatbot.dto.ClientAction; +import devkor.com.teamcback.domain.chatbot.dto.ClientActionType; +import devkor.com.teamcback.domain.chatbot.dto.NavigateRouteAction; +import devkor.com.teamcback.domain.chatbot.dto.PendingLocationRef; +import devkor.com.teamcback.domain.chatbot.dto.PendingRouteState; +import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation; +import devkor.com.teamcback.domain.chatbot.dto.SearchResolutionTrace; import devkor.com.teamcback.domain.chatbot.dto.request.ChatMessageReq; import devkor.com.teamcback.domain.chatbot.dto.request.CurrentLocationReq; import devkor.com.teamcback.domain.chatbot.dto.response.ChatMessageRes; import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteEndpointType; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusIntent; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusRole; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; @@ -12,40 +31,267 @@ @ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") public class ChatService { static final String SYSTEM_PROMPT = """ - 당신은 고려대학교 캠퍼스 생활 도우미입니다. - 실제 교내 정보는 도구가 제공되는 경우 반드시 도구 결과에 근거하고, 없는 사실을 만들지 마세요. - 정보가 없거나 지원하지 않는 요청은 명확히 알리세요. - 개인정보, 인증 정보, 내부 식별자는 노출하지 마세요. - 학사 규정, 졸업 요건, 장학금 및 RAG 기반 지식 질의는 V1 지원 범위가 아닙니다. - 한국어로 간결하고 실용적으로 답하세요. - Use campus tools for dynamic campus facts. If a route endpoint ID is unknown, call searchCampus first. - Never choose an ambiguous location candidate. Request currentLocation is valid only for this request. - Never repeat raw latitude or longitude in the final answer. - BARRIERFREE only means stair nodes are excluded; do not claim complete wheelchair accessibility. + You are a concise Korean Korea University campus assistant. + + [GROUNDING] + Use read-only campus tools for dynamic campus facts. Tool results are the only source of truth. + Never invent locations, IDs, routes, menus, operating hours, crowd data, or reviews. Do not expose + private data, authentication data, raw coordinates, or internal identifiers. Prefer the latest user + correction or negation over older conversation and never select a denied candidate again. Use concise, + practical Korean. If data is unavailable, say so. Do not end with only a future promise such as + "찾아보겠습니다" when a required Tool can be called. Stay within the six-call limit. + + Future-only responses are prohibited when a Tool can complete the request. + [ROUTE_BEHAVIOR] + Decide intent first: NAVIGATE_ROUTE opens the existing route screen; TEXT_ROUTE returns route facts in chat. + NAVIGATE_ROUTE requires searchCampus(query, role=START, intent=NAVIGATE_ROUTE) and + searchCampus(query, role=END, intent=NAVIGATE_ROUTE). Preserve specific place wording, use only + actual Tool results, and do not call findRoute. Once both endpoints are unique, the backend creates + NAVIGATE_ROUTE ClientAction; never invent or write an Action ID. + TEXT_ROUTE requires searchCampus for START and END with intent=TEXT_ROUTE, then findRoute only after + both endpoints are unique. Answer only from findRoute. + Allowed route values are exactly START/END and NAVIGATE_ROUTE/TEXT_ROUTE. Do not create other role or + intent values, and do not create NODE/COORD Actions. + Ambiguous searchCampus results must not be guessed, merged, or invented. Ask using only actual candidate + names and create no Action until unique. + If PENDING_ROUTE_CONTINUATION is present and the current message is related, re-search both endpoints in + this request with explicit roles and NAVIGATE_ROUTE; never copy a previous ID. Unrelated requests stay + unrelated. Use only supported conditions; BARRIERFREE excludes stair nodes where supported and is not a + complete accessibility guarantee. + Examples: UI route -> START search, END search, no findRoute; text route -> START search, END search, + findRoute; pending END clarification -> re-search both endpoints; ambiguous END -> ask which candidate. """; private final LlmGateway llmGateway; private final ChatSessionMemoryService memoryService; private final ChatRateLimiter rateLimiter; + private final PendingRouteStateService pendingRouteStateService; + /** Compatibility constructor for focused unit tests that do not exercise pending state. */ public ChatService(LlmGateway llmGateway, ChatSessionMemoryService memoryService, ChatRateLimiter rateLimiter) { + this(llmGateway, memoryService, rateLimiter, null); + } + + @Autowired + public ChatService(LlmGateway llmGateway, ChatSessionMemoryService memoryService, ChatRateLimiter rateLimiter, + PendingRouteStateService pendingRouteStateService) { this.llmGateway = llmGateway; this.memoryService = memoryService; this.rateLimiter = rateLimiter; + this.pendingRouteStateService = pendingRouteStateService; } public ChatMessageRes sendMessage(ChatMessageReq request, ChatCaller caller) { UUID sessionId = request.sessionId() == null ? UUID.randomUUID() : request.sessionId(); rateLimiter.check(caller); var history = memoryService.load(sessionId, caller); - String reply = llmGateway.generate(SYSTEM_PROMPT, messageWithHistoryAndRequestContext(request, history)); + PendingRouteState pending = loadPending(sessionId, caller); + LlmGateway.LlmResult result = llmGateway.generate(promptWithPendingState(pending), toGatewayHistory(history), + messageWithRequestContext(request)); + ClientAction action = assembleRouteAction(result.resolvedLocations()); + if (action != null) { + deletePending(sessionId, caller); + } else { + savePendingIfRouteIsIncomplete(sessionId, caller, pending, result.searchResolutions()); + } + boolean completionFailed = result.completionStatus() == LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION + || result.reply() == null || result.reply().isBlank(); + if (completionFailed && action == null) { + String clarification = deterministicClarification(result.searchResolutions()); + if (clarification != null) { + memoryService.save(sessionId, caller, request.message(), clarification); + return new ChatMessageRes(sessionId, clarification, null); + } + throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); + } + String reply = action == null ? result.reply() : routeActionReply(action); memoryService.save(sessionId, caller, request.message(), reply); - return new ChatMessageRes(sessionId, reply); + return new ChatMessageRes(sessionId, reply, action); } - private String messageWithHistoryAndRequestContext(ChatMessageReq request, - java.util.List history) { - String message = history.isEmpty() ? request.message() : historyPrefix(history) + request.message(); + private PendingRouteState loadPending(UUID sessionId, ChatCaller caller) { + return pendingRouteStateService == null ? null + : pendingRouteStateService.load(sessionId, caller).orElse(null); + } + + private void deletePending(UUID sessionId, ChatCaller caller) { + if (pendingRouteStateService != null) { + pendingRouteStateService.delete(sessionId, caller); + } + } + + private String promptWithPendingState(PendingRouteState pending) { + if (pending == null) { + return SYSTEM_PROMPT; + } + StringBuilder context = new StringBuilder(SYSTEM_PROMPT); + context.append("\n\n[PENDING_ROUTE_CONTINUATION]\n") + .append("interaction=NAVIGATE_ROUTE\n") + .append("unresolvedRole=").append(pending.unresolvedRole()).append("\n"); + if (pending.resolvedStart() != null) { + context.append("resolvedStartName=").append(pending.resolvedStart().name()).append("\n"); + } + if (pending.resolvedEnd() != null) { + context.append("resolvedEndName=").append(pending.resolvedEnd().name()).append("\n"); + } + if (!pending.ambiguousCandidates().isEmpty()) { + context.append("ambiguousCandidateNames=") + .append(pending.ambiguousCandidates().stream().map(PendingLocationRef::name) + .collect(Collectors.joining(", "))).append("\n"); + } + context.append("Use ROUTE_BEHAVIOR for continuation: if related, re-search both endpoints in this request " + + "with explicit roles and NAVIGATE_ROUTE; never reuse an old ID. If unrelated, handle normally.\n" + + "[/PENDING_ROUTE_CONTINUATION]"); + return context.toString(); + } + + private void savePendingIfRouteIsIncomplete(UUID sessionId, ChatCaller caller, PendingRouteState previous, + List traces) { + if (pendingRouteStateService == null || traces == null) { + return; + } + List routeTraces = traces.stream() + .filter(trace -> trace.intent() == SearchCampusIntent.NAVIGATE_ROUTE).toList(); + if (routeTraces.isEmpty()) { + return; + } + Map latest = routeTraces.stream() + .collect(Collectors.toMap(SearchResolutionTrace::role, Function.identity(), (first, second) -> second)); + SearchResolutionTrace startTrace = latest.get(SearchCampusRole.START); + SearchResolutionTrace endTrace = latest.get(SearchCampusRole.END); + boolean hasAmbiguous = routeTraces.stream().anyMatch(SearchResolutionTrace::ambiguous); + if (!hasAmbiguous && previous == null) { + return; + } + PendingLocationRef start = uniqueCandidate(startTrace); + PendingLocationRef end = uniqueCandidate(endTrace); + if (start == null && previous != null) { + start = previous.resolvedStart(); + } + if (end == null && previous != null) { + end = previous.resolvedEnd(); + } + SearchCampusRole unresolved = unresolvedRole(startTrace, endTrace, previous); + if (unresolved == null) { + return; + } + SearchResolutionTrace unresolvedTrace = unresolved == SearchCampusRole.START ? startTrace : endTrace; + List candidates = unresolvedTrace == null + ? (previous == null ? List.of() : previous.ambiguousCandidates()) + : deduplicateCandidates(unresolvedTrace.candidates()); + List conditions = routeTraces.stream() + .flatMap(trace -> trace.conditions().stream()).collect(Collectors.toCollection(LinkedHashSet::new)) + .stream().toList(); + if (conditions.isEmpty() && previous != null) { + conditions = previous.conditions(); + } + pendingRouteStateService.save(sessionId, caller, + new PendingRouteState(ResolvedLocation.RouteIntent.NAVIGATE_ROUTE, start, end, unresolved, + conditions, candidates)); + } + + private String deterministicClarification(List traces) { + if (traces == null) { + return null; + } + SearchResolutionTrace ambiguous = traces.stream() + .filter(trace -> trace.intent() == SearchCampusIntent.NAVIGATE_ROUTE) + .filter(SearchResolutionTrace::ambiguous) + .filter(trace -> !trace.candidates().isEmpty()) + .reduce((first, second) -> second) + .orElse(null); + if (ambiguous == null) { + return null; + } + List names = deduplicateCandidates(ambiguous.candidates()).stream() + .map(PendingLocationRef::name) + .toList(); + if (names.isEmpty()) { + return null; + } + String label = ambiguous.role() == SearchCampusRole.START ? "출발지" : "도착지"; + return label + " 후보가 여러 개 있어요. " + String.join(", ", names) + " 중 어디로 갈까요?"; + } + + private List deduplicateCandidates(List candidates) { + if (candidates == null || candidates.isEmpty()) { + return List.of(); + } + Map unique = new java.util.LinkedHashMap<>(); + for (PendingLocationRef candidate : candidates) { + if (candidate == null || candidate.locationType() == null || candidate.locationId() == null) { + continue; + } + unique.putIfAbsent(candidate.locationType() + ":" + candidate.locationId(), candidate); + } + return List.copyOf(unique.values()); + } + + private PendingLocationRef uniqueCandidate(SearchResolutionTrace trace) { + if (trace == null || trace.ambiguous() || trace.candidates().size() != 1) { + return null; + } + return trace.candidates().get(0); + } + + private SearchCampusRole unresolvedRole(SearchResolutionTrace startTrace, SearchResolutionTrace endTrace, + PendingRouteState previous) { + if (startTrace != null && (startTrace.ambiguous() || uniqueCandidate(startTrace) == null)) { + return SearchCampusRole.START; + } + if (endTrace != null && (endTrace.ambiguous() || uniqueCandidate(endTrace) == null)) { + return SearchCampusRole.END; + } + return previous == null ? null : previous.unresolvedRole(); + } + + private ClientAction assembleRouteAction(List locations) { + if (locations == null || locations.isEmpty()) { + return null; + } + List navigate = locations.stream() + .filter(location -> location.intent() == ResolvedLocation.RouteIntent.NAVIGATE_ROUTE) + .filter(location -> location.role() != null) + .filter(location -> location.type() == RouteEndpointType.BUILDING + || location.type() == RouteEndpointType.PLACE) + .toList(); + ResolvedLocation start = uniqueRole(navigate, ResolvedLocation.EndpointRole.START); + ResolvedLocation end = uniqueRole(navigate, ResolvedLocation.EndpointRole.END); + if (start == null || end == null || start.id() == null || end.id() == null) { + return null; + } + if (start.type() == end.type() && start.id().equals(end.id())) { + return null; + } + LinkedHashSet conditions = new LinkedHashSet<>(); + conditions.addAll(start.conditions()); + conditions.addAll(end.conditions()); + return new ClientAction(ClientActionType.NAVIGATE_ROUTE, + new NavigateRouteAction(start.type(), start.id(), start.name(), end.type(), end.id(), end.name(), + conditions.stream().toList())); + } + + private ResolvedLocation uniqueRole(List locations, ResolvedLocation.EndpointRole role) { + List matches = locations.stream().filter(location -> location.role() == role).toList(); + return matches.size() == 1 ? matches.get(0) : null; + } + + private String routeActionReply(ClientAction action) { + NavigateRouteAction route = action.payload(); + return route.startName() + "에서 " + route.endName() + "까지 길찾기 화면으로 안내할게요."; + } + + private List toGatewayHistory( + List history) { + return history.stream() + .flatMap(turn -> java.util.stream.Stream.of( + new LlmGateway.ConversationMessage(LlmGateway.Role.USER, turn.userMessage()), + new LlmGateway.ConversationMessage(LlmGateway.Role.ASSISTANT, turn.assistantReply()))) + .toList(); + } + + private String messageWithRequestContext(ChatMessageReq request) { + String message = request.message(); if (request.context() == null || request.context().currentLocation() == null) { return message; } @@ -54,13 +300,4 @@ private String messageWithHistoryAndRequestContext(ChatMessageReq request, + "use start/end type COORD when needed; latitude=" + location.latitude() + ", longitude=" + location.longitude() + "; never reveal these raw coordinates]"; } - - private String historyPrefix(java.util.List history) { - StringBuilder prompt = new StringBuilder("[RECENT_CONVERSATION]\n"); - for (ChatSessionMemoryService.ChatTurn turn : history) { - prompt.append("User: ").append(turn.userMessage()).append("\nAssistant: ") - .append(turn.assistantReply()).append("\n"); - } - return prompt.append("[/RECENT_CONVERSATION]\n").toString(); - } } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatbotToolCallLimiter.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatbotToolCallLimiter.java index 3748fb18..a361a8e6 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatbotToolCallLimiter.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatbotToolCallLimiter.java @@ -8,46 +8,32 @@ @ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") public class ChatbotToolCallLimiter { private final ChatbotProperties properties; - private final ThreadLocal current = new ThreadLocal<>(); public ChatbotToolCallLimiter(ChatbotProperties properties) { this.properties = properties; } public Scope open() { - ScopeState previous = current.get(); - ScopeState scope = new ScopeState(properties.agent().maxToolCallsPerMessage()); - current.set(scope); - return new Scope(scope, previous); - } - - public void beforeToolCall() { - ScopeState scope = current.get(); - if (scope != null) { - scope.increment(); - } + return new Scope(new ScopeState(properties.agent().maxToolCallsPerMessage())); } public final class Scope implements AutoCloseable { private final ScopeState scope; - private final ScopeState previous; - private Scope(ScopeState scope, ScopeState previous) { + private Scope(ScopeState scope) { this.scope = scope; - this.previous = previous; } public int callCount() { return scope.callCount; } + public void beforeToolCall() { + scope.increment(); + } + @Override public void close() { - if (previous == null) { - current.remove(); - } else { - current.set(previous); - } } } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/PendingRouteStateService.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/PendingRouteStateService.java new file mode 100644 index 00000000..ea150327 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/PendingRouteStateService.java @@ -0,0 +1,70 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static devkor.com.teamcback.global.response.ResultCode.CHATBOT_SESSION_FORBIDDEN; +import static devkor.com.teamcback.global.response.ResultCode.CHATBOT_TEMPORARILY_UNAVAILABLE; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import devkor.com.teamcback.domain.chatbot.dto.PendingRouteState; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +/** Redis-backed state for an incomplete NAVIGATE_ROUTE interaction. */ +@Service +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class PendingRouteStateService { + private static final String KEY_PREFIX = "chatbot:pending:route:"; + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + private final ChatbotProperties properties; + + public Optional load(UUID sessionId, ChatCaller caller) { + try { + String value = redisTemplate.opsForValue().get(key(sessionId)); + if (value == null) { + return Optional.empty(); + } + StoredPendingRoute stored = objectMapper.readValue(value, StoredPendingRoute.class); + if (!stored.owner().equals(caller.key())) { + throw new GlobalException(CHATBOT_SESSION_FORBIDDEN); + } + redisTemplate.expire(key(sessionId), properties.agent().pendingRouteTtlMinutes(), TimeUnit.MINUTES); + return Optional.ofNullable(stored.state()); + } catch (GlobalException exception) { + throw exception; + } catch (Exception exception) { + throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); + } + } + + public void save(UUID sessionId, ChatCaller caller, PendingRouteState state) { + try { + String value = objectMapper.writeValueAsString(new StoredPendingRoute(caller.key(), state)); + redisTemplate.opsForValue().set(key(sessionId), value, + properties.agent().pendingRouteTtlMinutes(), TimeUnit.MINUTES); + } catch (JsonProcessingException exception) { + throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); + } + } + + public void delete(UUID sessionId, ChatCaller caller) { + load(sessionId, caller); + redisTemplate.delete(key(sessionId)); + } + + private String key(UUID sessionId) { + return KEY_PREFIX + sessionId; + } + + record StoredPendingRoute(String owner, PendingRouteState state) { + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ResolvedLocationCollector.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ResolvedLocationCollector.java new file mode 100644 index 00000000..6aa99313 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ResolvedLocationCollector.java @@ -0,0 +1,78 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation; +import devkor.com.teamcback.domain.chatbot.dto.PendingLocationRef; +import devkor.com.teamcback.domain.chatbot.dto.SearchResolutionTrace; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteEndpointType; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolResult; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Collections; +import java.util.Map; + +/** Request-local Tool execution data. Each LLM invocation owns one instance. */ +public final class ResolvedLocationCollector { + private final Map locations = Collections.synchronizedMap(new LinkedHashMap<>()); + private final List searchResolutions = Collections.synchronizedList(new ArrayList<>()); + + public void record(SearchCampusToolRequest request, SearchCampusToolResult result) { + if (request == null || request.role() == null || request.intent() == null || result == null) { + return; + } + List candidates = result.candidates() == null ? List.of() : result.candidates().stream() + .filter(item -> item != null && item.locationId() != null && item.locationId() > 0 + && item.locationType() != null && item.name() != null && !item.name().isBlank()) + .map(item -> { + try { + return new PendingLocationRef(RouteEndpointType.valueOf(item.locationType().name()), + item.locationId(), item.name()); + } catch (IllegalArgumentException exception) { + return null; + } + }) + .filter(java.util.Objects::nonNull) + .toList(); + searchResolutions.add(new SearchResolutionTrace(request.role(), request.intent(), request.query(), + result.ambiguous(), candidates, request.conditions())); + if (result.ambiguous() || result.error() != null + || result.candidates() == null || result.candidates().size() != 1) { + return; + } + var item = result.candidates().get(0); + if (item == null || item.locationId() == null || item.locationId() <= 0 || item.locationType() == null + || item.name() == null || item.name().isBlank()) { + return; + } + RouteEndpointType type; + try { + type = RouteEndpointType.valueOf(item.locationType().name()); + } catch (IllegalArgumentException exception) { + return; + } + ResolvedLocation location = new ResolvedLocation( + request.role().toEndpointRole(), type, item.locationId(), item.name(), + request.intent().toRouteIntent(), request.conditions()); + locations.put(request.role() + ":" + type + ":" + item.locationId(), location); + } + + public List snapshot() { + synchronized (locations) { + return List.copyOf(new ArrayList<>(locations.values())); + } + } + + public boolean hasNavigateIntent() { + synchronized (locations) { + return locations.values().stream() + .anyMatch(location -> location.intent() == ResolvedLocation.RouteIntent.NAVIGATE_ROUTE); + } + } + + public List searchResolutionSnapshot() { + synchronized (searchResolutions) { + return List.copyOf(searchResolutions); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java index a3ae3ee4..9ec8bad7 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java @@ -1,98 +1,152 @@ package devkor.com.teamcback.domain.chatbot.tool; +import devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter; +import devkor.com.teamcback.domain.chatbot.service.ResolvedLocationCollector; +import devkor.com.teamcback.domain.chatbot.tool.dto.CampusStatusToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.FindFacilitiesToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.FindFacilitiesToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolResult; -import devkor.com.teamcback.domain.chatbot.tool.dto.CampusStatusToolResult; -import devkor.com.teamcback.domain.chatbot.tool.dto.GetCrowdStatusToolRequest; -import devkor.com.teamcback.domain.chatbot.tool.dto.GetCrowdStatusToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.GetCafeteriaMenuToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.GetCafeteriaMenuToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetCrowdStatusToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetCrowdStatusToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolResult; -import devkor.com.teamcback.domain.chatbot.tool.dto.GetRoomCoursesToolRequest; -import devkor.com.teamcback.domain.chatbot.tool.dto.GetRoomCoursesToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.GetPlaceReviewsToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.GetPlaceReviewsToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetRoomCoursesToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetRoomCoursesToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolResult; -import devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter; +import lombok.extern.slf4j.Slf4j; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @Component @ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +@Slf4j public class CampusChatbotTools { private final CampusToolAdapter adapter; private final ChatbotToolCallLimiter toolCallLimiter; + private final ResolvedLocationCollector resolvedLocationCollector; + private final ChatbotToolCallLimiter.Scope toolCallScope; + @Autowired public CampusChatbotTools(CampusToolAdapter adapter, ChatbotToolCallLimiter toolCallLimiter) { + this(adapter, toolCallLimiter, null, null); + } + + private CampusChatbotTools(CampusToolAdapter adapter, ChatbotToolCallLimiter toolCallLimiter, + ResolvedLocationCollector resolvedLocationCollector, + ChatbotToolCallLimiter.Scope toolCallScope) { this.adapter = adapter; this.toolCallLimiter = toolCallLimiter; + this.resolvedLocationCollector = resolvedLocationCollector; + this.toolCallScope = toolCallScope; + } + + /** Creates an isolated Tool object for one LLM invocation. */ + public CampusChatbotTools forRequest(ResolvedLocationCollector collector, + ChatbotToolCallLimiter.Scope scope) { + return new CampusChatbotTools(adapter, toolCallLimiter, collector, scope); + } + + private void beforeToolCall() { + if (toolCallScope != null) { + toolCallScope.beforeToolCall(); + } } - @Tool(name = "searchCampus", description = "장소 ID를 모를 때 가장 먼저 사용해 고려대학교 건물 또는 장소 이름 후보를 찾습니다.") + @Tool(name = "searchCampus", description = "Resolve a named campus building or place to real BUILDING/PLACE candidates. " + + "Use when an ID is unknown. For a route request, search both endpoints and set role START or END, " + + "intent NAVIGATE_ROUTE for a UI action or TEXT_ROUTE for textual route computation. " + + "Preserve the user's specific place wording. Use only returned candidates; never invent or select an " + + "ambiguous candidate. For NAVIGATE_ROUTE, resolve START and END and do not call findRoute; the backend " + + "creates the UI action. For TEXT_ROUTE, resolve both endpoints before findRoute.") public SearchCampusToolResult searchCampus( - @ToolParam(description = "검색어와 선택적 결과 제한") SearchCampusToolRequest request) { - toolCallLimiter.beforeToolCall(); - return adapter.searchCampus(request); + @ToolParam(description = "Place name query, optional START/END role, route intent, and supported conditions") + SearchCampusToolRequest request) { + log.info("chatbot_tool searchCampus query={} limit={} role={} intent={}", + request.query(), request.limit(), request.role(), request.intent()); + beforeToolCall(); + SearchCampusToolResult result = adapter.searchCampus(request); + if (resolvedLocationCollector != null) { + resolvedLocationCollector.record(request, result); + } + return result; } - @Tool(name = "getLocationDetail", description = "searchCampus로 확인한 BUILDING 또는 PLACE ID의 상세와 운영 정보를 조회합니다.") + @Tool(name = "getLocationDetail", description = "Get detail or operating information for a resolved BUILDING/PLACE ID. " + + "Use searchCampus first when the ID is unknown; do not use this to calculate a route.") public GetLocationDetailToolResult getLocationDetail( - @ToolParam(description = "BUILDING 또는 PLACE 유형과 위치 ID") GetLocationDetailToolRequest request) { - toolCallLimiter.beforeToolCall(); + @ToolParam(description = "Resolved BUILDING or PLACE type and ID") GetLocationDetailToolRequest request) { + beforeToolCall(); return adapter.getLocationDetail(request); } - @Tool(name = "findFacilities", description = "시설 유형이나 건물/층 조건으로 고려대학교 시설을 찾습니다. 층은 건물 ID와 함께 사용해야 합니다.") + @Tool(name = "findFacilities", description = "Find campus facilities by type/building/floor. " + + "Resolve an unknown building with searchCampus first; do not use this for route navigation.") public FindFacilitiesToolResult findFacilities( - @ToolParam(description = "시설 유형, 건물 ID, 층, 선택적 결과 제한") FindFacilitiesToolRequest request) { - toolCallLimiter.beforeToolCall(); + @ToolParam(description = "Facility type and optional building/floor filters") FindFacilitiesToolRequest request) { + beforeToolCall(); return adapter.findFacilities(request); } - @Tool(name = "findRoute", description = "Find a campus route between BUILDING/PLACE IDs or request-scoped COORD coordinates. Use searchCampus first when an ID is unknown and never select an ambiguous candidate. BARRIERFREE only excludes stair nodes and is not a complete accessibility guarantee.") + @Tool(name = "findRoute", description = "Compute a textual route result with duration/steps. " + + "Use searchCampus for unknown BUILDING/PLACE IDs first. Do not call this only to open the UI route screen; " + + "call this only for TEXT_ROUTE after START and END are uniquely resolved. NAVIGATE_ROUTE UI actions are " + + "assembled by the backend after endpoint resolution.") public FindRouteToolResult findRoute( - @ToolParam(description = "Start, end, and optional BARRIERFREE/SHUTTLE/STUDENTCARD/OPERATING conditions") FindRouteToolRequest request) { - toolCallLimiter.beforeToolCall(); + @ToolParam(description = "Start/end endpoint and optional BARRIERFREE, SHUTTLE, STUDENTCARD, OPERATING conditions") + FindRouteToolRequest request) { + beforeToolCall(); + if (resolvedLocationCollector != null && resolvedLocationCollector.hasNavigateIntent()) { + return new FindRouteToolResult(null, + devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolError.of( + devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.UNSUPPORTED)); + } return adapter.findRoute(request); } - @Tool(name = "getCafeteriaMenu", description = "교내 식당의 지정 날짜 또는 최대 7일 메뉴를 조회합니다. 일반 카페 상품 메뉴 조회에는 사용하지 않습니다.") + @Tool(name = "getCafeteriaMenu", description = "Get a cafeteria meal menu for a resolved PLACE ID and date range. " + + "Use searchCampus first; do not invent missing menu data.") public GetCafeteriaMenuToolResult getCafeteriaMenu( - @ToolParam(description = "식당 PLACE ID와 조회 시작일, 선택적 종료일") GetCafeteriaMenuToolRequest request) { - toolCallLimiter.beforeToolCall(); + @ToolParam(description = "Cafeteria PLACE ID and requested dates") GetCafeteriaMenuToolRequest request) { + beforeToolCall(); return adapter.getCafeteriaMenu(request); } - @Tool(name = "getRoomCourses", description = "특정 강의실의 현재 학기 수업 일정을 조회합니다. 개인 시간표나 사용자 수강정보에는 사용하지 않습니다.") + @Tool(name = "getRoomCourses", description = "Get courses scheduled in a resolved classroom PLACE ID. " + + "This is not a personal timetable.") public GetRoomCoursesToolResult getRoomCourses( - @ToolParam(description = "강의실 PLACE ID와 선택적 요일") GetRoomCoursesToolRequest request) { - toolCallLimiter.beforeToolCall(); + @ToolParam(description = "Classroom PLACE ID and optional weekday") GetRoomCoursesToolRequest request) { + beforeToolCall(); return adapter.getRoomCourses(request); } - @Tool(name = "getCampusStatus", description = "현재 학기, 방학 여부, 고연전 기간 여부를 조회합니다. 건물 운영시간이나 과거·미래 일정 조회에는 사용하지 않습니다.") + @Tool(name = "getCampusStatus", description = "Get current campus term, vacation, and Koyeon status. No location ID is needed.") public CampusStatusToolResult getCampusStatus() { - toolCallLimiter.beforeToolCall(); + beforeToolCall(); return adapter.getCampusStatus(); } - @Tool(name = "getCrowdStatus", description = "BLE 센서가 설치된 장소의 추정 혼잡도를 조회합니다. 센서가 없거나 데이터가 오래되면 추정하지 않습니다.") + @Tool(name = "getCrowdStatus", description = "Get sensor-backed crowd status for a resolved PLACE ID. " + + "Do not guess when sensor data is unavailable.") public GetCrowdStatusToolResult getCrowdStatus( - @ToolParam(description = "PLACE ID와 선택적 최근 한 달 시간대별 패턴 포함 여부") GetCrowdStatusToolRequest request) { - toolCallLimiter.beforeToolCall(); + @ToolParam(description = "PLACE ID and crowd-pattern option") GetCrowdStatusToolRequest request) { + beforeToolCall(); return adapter.getCrowdStatus(request); } - @Tool(name = "getPlaceReviews", description = "리뷰가 지원되는 장소의 평점, 대표 태그, 최근 텍스트 리뷰를 조회합니다. 작성자 정보나 리뷰 이미지는 제공하지 않습니다.") + @Tool(name = "getPlaceReviews", description = "Get compact reviews for a resolved PLACE ID. " + + "Do not expose or invent review-author identity.") public GetPlaceReviewsToolResult getPlaceReviews( - @ToolParam(description = "리뷰 지원 PLACE ID와 선택적 리뷰 개수") GetPlaceReviewsToolRequest request) { - toolCallLimiter.beforeToolCall(); + @ToolParam(description = "PLACE ID and review limit") GetPlaceReviewsToolRequest request) { + beforeToolCall(); return adapter.getPlaceReviews(request); } } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java index 7a5037bb..9d20adea 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java @@ -45,6 +45,7 @@ import devkor.com.teamcback.domain.chatbot.tool.dto.GetPlaceReviewsToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.LocationDetailToolData; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusItem; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusMatchType; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType; @@ -76,6 +77,7 @@ import devkor.com.teamcback.domain.review.dto.response.SearchPlaceReviewRes; import devkor.com.teamcback.domain.review.service.ReviewService; import devkor.com.teamcback.domain.search.dto.response.GlobalSearchRes; +import devkor.com.teamcback.domain.search.dto.response.ChatbotSearchCandidate; import devkor.com.teamcback.domain.search.dto.response.SearchBuildingDetailRes; import devkor.com.teamcback.domain.search.dto.response.SearchFacilityRes; import devkor.com.teamcback.domain.search.dto.response.SearchPlaceDetailRes; @@ -89,16 +91,22 @@ import java.time.DateTimeException; import java.time.LocalDate; import java.time.temporal.ChronoUnit; +import java.text.Normalizer; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @Component +@Slf4j @ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") public class CampusToolAdapter { + private static final int CHATBOT_SEARCH_RESULT_LIMIT = 5; private final SearchService searchService; private final RouteService routeService; private final CafeteriaMenuService cafeteriaMenuService; @@ -126,35 +134,63 @@ public SearchCampusToolResult searchCampus(SearchCampusToolRequest request) { if (request == null || request.query() == null || request.query().trim().isEmpty()) { return new SearchCampusToolResult(List.of(), false, error(INVALID_INPUT)); } - Integer limit = resolveLimit(request.limit(), properties.tools().search()); - if (limit == null) { - return new SearchCampusToolResult(List.of(), false, error(INVALID_INPUT)); - } - + long startedAt = System.nanoTime(); + int limit = Math.min(CHATBOT_SEARCH_RESULT_LIMIT, properties.tools().search().maxLimit()); try { - List actualLocations = searchService.globalSearch(request.query().trim(), null).getList() - .stream() - .filter(item -> item.getId() != null) - .filter(item -> item.getLocationType() == LocationType.BUILDING - || item.getLocationType() == LocationType.PLACE) + String query = request.query().trim(); + List actualLocations = deduplicate( + searchService.chatbotSearch(query, limit)); + String normalizedQuery = normalizeSearchName(query); + List ranked = actualLocations.stream() + .map(item -> new RankedSearchCandidate(item, matchType(normalizedQuery, item.name()))) + .sorted(Comparator.comparingInt(item -> item.matchType().ordinal())) .toList(); - List candidates = actualLocations.stream() + List exactMatches = ranked.stream() + .filter(item -> item.matchType() == SearchCampusMatchType.EXACT).toList(); + List strongMatches = ranked.stream() + .filter(item -> item.matchType() == SearchCampusMatchType.STRONG).toList(); + List resolvedLocations = !exactMatches.isEmpty() + ? exactMatches : !strongMatches.isEmpty() ? strongMatches : ranked; + List candidates = resolvedLocations.stream() .limit(limit) - .map(this::toSearchItem) + .map(item -> toSearchItem(item.candidate(), item.matchType())) .toList(); if (candidates.isEmpty()) { + log.info("chatbot_search query={} limit={} durationMs={} candidateCount=0 ambiguous=false candidates=[]", + query, limit, elapsedMillis(startedAt)); return new SearchCampusToolResult(List.of(), false, error(NOT_FOUND)); } - boolean ambiguous = actualLocations.size() > 1; + boolean ambiguous = resolvedLocations.size() > 1; + log.info("chatbot_search query={} limit={} durationMs={} candidateCount={} ambiguous={} candidates={}", + query, limit, elapsedMillis(startedAt), candidates.size(), ambiguous, + candidates.stream().map(item -> item.name() + ":" + item.locationType() + ":" + item.matchType()).toList()); return new SearchCampusToolResult(candidates, ambiguous, ambiguous ? error(AMBIGUOUS_LOCATION) : null); } catch (GlobalException exception) { + log.info("chatbot_search query={} limit={} durationMs={} candidateCount=0 ambiguous=false outcome={}", + request.query().trim(), limit, elapsedMillis(startedAt), mapDomainError(exception).code()); return new SearchCampusToolResult(List.of(), false, mapDomainError(exception)); } catch (RuntimeException exception) { + log.info("chatbot_search query={} limit={} durationMs={} outcome=TEMPORARILY_UNAVAILABLE", + request.query().trim(), limit, elapsedMillis(startedAt)); return new SearchCampusToolResult(List.of(), false, error(TEMPORARILY_UNAVAILABLE)); } } + private List deduplicate(List candidates) { + if (candidates == null || candidates.isEmpty()) { + return List.of(); + } + Map unique = new LinkedHashMap<>(); + for (ChatbotSearchCandidate candidate : candidates) { + if (candidate == null || candidate.locationType() == null || candidate.locationId() == null) { + continue; + } + unique.putIfAbsent(candidate.locationType() + ":" + candidate.locationId(), candidate); + } + return unique.values().stream().toList(); + } + public GetLocationDetailToolResult getLocationDetail(GetLocationDetailToolRequest request) { if (request == null || request.locationType() == null || request.locationId() == null || request.locationId() <= 0) { @@ -554,13 +590,40 @@ private List flattenBuildingFacilities(FindFacilitiesToolReque .toList(); } - private SearchCampusItem toSearchItem(GlobalSearchRes item) { - ToolLocationType type = item.getLocationType() == LocationType.BUILDING + private SearchCampusItem toSearchItem(ChatbotSearchCandidate item, SearchCampusMatchType matchType) { + ToolLocationType type = item.locationType() == LocationType.BUILDING ? ToolLocationType.BUILDING : ToolLocationType.PLACE; - Long buildingId = type == ToolLocationType.BUILDING ? item.getId() : item.getBuildingId(); - String buildingName = type == ToolLocationType.BUILDING ? item.getName() : null; - return new SearchCampusItem(item.getId(), type, item.getName(), buildingId, buildingName, - item.getFloor(), item.getPlaceType(), normalizeDetail(item.getDetail())); + Long buildingId = type == ToolLocationType.BUILDING ? item.locationId() : item.buildingId(); + String buildingName = type == ToolLocationType.BUILDING ? item.name() : null; + return new SearchCampusItem(item.locationId(), type, item.name(), buildingId, buildingName, + item.floor(), item.placeType(), normalizeDetail(item.detail()), matchType); + } + + private SearchCampusMatchType matchType(String normalizedQuery, String candidateName) { + String normalizedCandidate = normalizeSearchName(candidateName); + if (normalizedQuery.equals(normalizedCandidate)) { + return SearchCampusMatchType.EXACT; + } + if (!normalizedCandidate.isEmpty() && normalizedQuery.contains(normalizedCandidate)) { + return SearchCampusMatchType.STRONG; + } + return SearchCampusMatchType.PARTIAL; + } + + private long elapsedMillis(long startedAt) { + return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt); + } + + private record RankedSearchCandidate(ChatbotSearchCandidate candidate, SearchCampusMatchType matchType) { + } + + private String normalizeSearchName(String value) { + if (value == null) { + return ""; + } + return Normalizer.normalize(value, Normalizer.Form.NFC) + .replaceAll("\\s+", "") + .toLowerCase(Locale.ROOT); } private LocationDetailToolData toBuildingDetail(SearchBuildingDetailRes detail) { diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusIntent.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusIntent.java new file mode 100644 index 00000000..147bbcab --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusIntent.java @@ -0,0 +1,11 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation.RouteIntent; + +public enum SearchCampusIntent { + NAVIGATE_ROUTE, TEXT_ROUTE; + + public RouteIntent toRouteIntent() { + return RouteIntent.valueOf(name()); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusItem.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusItem.java index 5fa00d82..d264c116 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusItem.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusItem.java @@ -12,6 +12,7 @@ public record SearchCampusItem( String buildingName, Double floor, PlaceType placeType, - String detail + String detail, + SearchCampusMatchType matchType ) { } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusMatchType.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusMatchType.java new file mode 100644 index 00000000..19d8793b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusMatchType.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public enum SearchCampusMatchType { + EXACT, + STRONG, + PARTIAL +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusRole.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusRole.java new file mode 100644 index 00000000..2f369977 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusRole.java @@ -0,0 +1,11 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation.EndpointRole; + +public enum SearchCampusRole { + START, END; + + public EndpointRole toEndpointRole() { + return EndpointRole.valueOf(name()); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusToolRequest.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusToolRequest.java index 540df6f1..b0faf52c 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusToolRequest.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusToolRequest.java @@ -1,4 +1,19 @@ package devkor.com.teamcback.domain.chatbot.tool.dto; -public record SearchCampusToolRequest(String query, Integer limit) { +import java.util.List; + +public record SearchCampusToolRequest( + String query, + Integer limit, + SearchCampusRole role, + SearchCampusIntent intent, + List conditions +) { + public SearchCampusToolRequest(String query, Integer limit) { + this(query, limit, null, null, List.of()); + } + + public SearchCampusToolRequest { + conditions = conditions == null ? List.of() : List.copyOf(conditions); + } } diff --git a/src/main/java/devkor/com/teamcback/domain/place/repository/PlaceNicknameRepository.java b/src/main/java/devkor/com/teamcback/domain/place/repository/PlaceNicknameRepository.java index 51e8835f..ae76c4f0 100644 --- a/src/main/java/devkor/com/teamcback/domain/place/repository/PlaceNicknameRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/place/repository/PlaceNicknameRepository.java @@ -3,17 +3,22 @@ import devkor.com.teamcback.domain.place.entity.Place; import devkor.com.teamcback.domain.place.entity.PlaceNickname; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; public interface PlaceNicknameRepository extends JpaRepository { + @EntityGraph(attributePaths = {"place", "place.building"}) List findAllByChosungContainingOrderByNickname(String chosung, Pageable pageable); + @EntityGraph(attributePaths = {"place", "place.building"}) List findAllByJasoDecomposeContainingOrderByNickname(String jaso, Pageable pageable); List findAllByPlace(Place place); + @EntityGraph(attributePaths = {"place", "place.building"}) List findByChosungContainingAndPlaceInOrderByNickname(String chosung, List list, Pageable pageable); + @EntityGraph(attributePaths = {"place", "place.building"}) List findByJasoDecomposeContainingAndPlaceInOrderByNickname(String jaso, List list, Pageable pageable); List findByChosungIsNullOrJasoDecomposeIsNull(); List findAllByNicknameContaining(String blank); diff --git a/src/main/java/devkor/com/teamcback/domain/search/dto/response/ChatbotSearchCandidate.java b/src/main/java/devkor/com/teamcback/domain/search/dto/response/ChatbotSearchCandidate.java new file mode 100644 index 00000000..2d47249b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/search/dto/response/ChatbotSearchCandidate.java @@ -0,0 +1,16 @@ +package devkor.com.teamcback.domain.search.dto.response; + +import devkor.com.teamcback.domain.common.LocationType; +import devkor.com.teamcback.domain.place.entity.PlaceType; + +/** Minimal, node-free search result for chatbot candidate resolution. */ +public record ChatbotSearchCandidate( + Long locationId, + LocationType locationType, + String name, + Long buildingId, + Double floor, + PlaceType placeType, + String detail +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/search/service/SearchService.java b/src/main/java/devkor/com/teamcback/domain/search/service/SearchService.java index 0d9c0b40..3ed8c439 100644 --- a/src/main/java/devkor/com/teamcback/domain/search/service/SearchService.java +++ b/src/main/java/devkor/com/teamcback/domain/search/service/SearchService.java @@ -141,6 +141,91 @@ public GlobalSearchListRes globalSearch(String word, Long userId) { return new GlobalSearchListRes(orderSequence(scores)); } + /** + * Chatbot-only candidate lookup. Unlike globalSearch, this never touches a + * building/place Node and only fetches the associations needed to name a + * candidate. The public search API remains unchanged. + */ + @Transactional(readOnly = true) + public List chatbotSearch(String word, int limit) { + int fetchLimit = Math.max(10, limit * 2); + Pageable pageable = PageRequest.of(0, fetchLimit, Sort.by("id").ascending()); + LinkedHashMap candidates = new LinkedHashMap<>(); + List queries = new ArrayList<>(); + String trimmed = word == null ? "" : word.trim(); + if (!trimmed.isEmpty()) { + queries.add(trimmed); + if (trimmed.contains(" ")) { + queries.addAll(Arrays.stream(trimmed.split("\\s+")) + .filter(token -> !token.isBlank()) + .toList()); + } + } + + for (String query : queries) { + addChatbotBuildingCandidates(candidates, query); + addChatbotPlaceCandidates(candidates, query, pageable); + } + return candidates.values().stream().limit(fetchLimit).toList(); + } + + private void addChatbotBuildingCandidates(Map candidates, String query) { + String jaso = decomposeHangulString(query.replace(" ", "")); + addChatbotBuildings(candidates, + buildingNicknameRepository.findAllByJasoDecomposeContainingOrderByNickname(jaso, + PageRequest.of(0, 10))); + if (isConsonantOnly(query.replace(" ", ""))) { + addChatbotBuildings(candidates, + buildingNicknameRepository.findAllByChosungContainingOrderByNickname( + extractChosung(query.replace(" ", "")), PageRequest.of(0, 10))); + } + } + + private void addChatbotBuildings(Map candidates, + List nicknames) { + for (BuildingNickname nickname : nicknames) { + Building building = nickname.getBuilding(); + if (building == null || building.getId() == null) { + continue; + } + ChatbotSearchCandidate candidate = new ChatbotSearchCandidate(building.getId(), LocationType.BUILDING, + building.getName(), building.getId(), building.getFloor(), null, building.getDetail()); + candidates.putIfAbsent(candidateKey(candidate), candidate); + } + } + + private void addChatbotPlaceCandidates(Map candidates, String query, + Pageable pageable) { + String normalized = query.replace(" ", ""); + String jaso = decomposeHangulString(normalized); + addChatbotPlaces(candidates, placeNicknameRepository + .findAllByJasoDecomposeContainingOrderByNickname(jaso, pageable)); + if (isConsonantOnly(normalized)) { + addChatbotPlaces(candidates, placeNicknameRepository + .findAllByChosungContainingOrderByNickname(extractChosung(normalized), pageable)); + } + } + + private void addChatbotPlaces(Map candidates, + List nicknames) { + for (PlaceNickname nickname : nicknames) { + Place place = nickname.getPlace(); + if (place == null || place.getId() == null || place.getBuilding() == null + || place.getBuilding().getId() == null) { + continue; + } + Building building = place.getBuilding(); + ChatbotSearchCandidate candidate = new ChatbotSearchCandidate(place.getId(), LocationType.PLACE, + building.getName() + " " + place.getName(), building.getId(), place.getFloor(), + place.getType(), place.getDetail()); + candidates.putIfAbsent(candidateKey(candidate), candidate); + } + } + + private String candidateKey(ChatbotSearchCandidate candidate) { + return candidate.locationType() + ":" + candidate.locationId(); + } + /** * 모든 or 편의시설에 해당하는 건물 검색 */ diff --git a/src/main/resources/application-dev-local.yml b/src/main/resources/application-dev-local.yml new file mode 100644 index 00000000..28e3a93d --- /dev/null +++ b/src/main/resources/application-dev-local.yml @@ -0,0 +1,40 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + + jpa: + hibernate: + # dev DB 스키마 절대 수정하지 않음 + ddl-auto: none + show-sql: true + + sql: + init: + # schema.sql / data.sql 실행 금지 + mode: never + +# 챗봇은 실제 Vertex 사용 +chatbot: + enabled: true + llm: + diagnostics-enabled: true + +# 혹시 공통 설정에서 켜질 가능성까지 명시적으로 차단 +push: + event: + crowd-enabled: false + report-enabled: false + character-enabled: false + survey-enabled: false + + worker: + enabled: false + + receipt-worker: + enabled: false + + recovery-worker: + enabled: false diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 96b15dae..8f4379a2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -176,11 +176,13 @@ chatbot: provider: ${CHATBOT_LLM_PROVIDER:google} model: ${CHATBOT_LLM_MODEL:gemini-2.5-flash-lite} max-output-tokens: ${CHATBOT_MAX_OUTPUT_TOKENS:500} - timeout-seconds: ${CHATBOT_TIMEOUT_SECONDS:8} + timeout-seconds: ${CHATBOT_TIMEOUT_SECONDS:20} + diagnostics-enabled: ${CHATBOT_LLM_DIAGNOSTICS_ENABLED:false} agent: max-tool-calls-per-message: ${CHATBOT_MAX_TOOL_CALLS:6} history-turns: ${CHATBOT_HISTORY_TURNS:5} session-ttl-minutes: ${CHATBOT_SESSION_TTL_MINUTES:60} + pending-route-ttl-minutes: ${CHATBOT_PENDING_ROUTE_TTL_MINUTES:15} tools: search: default-limit: ${CHATBOT_SEARCH_DEFAULT_LIMIT:5} @@ -194,8 +196,10 @@ chatbot: max-limit: ${CHATBOT_REVIEWS_MAX_LIMIT:10} rate-limit: authenticated-daily-limit: ${CHATBOT_AUTHENTICATED_DAILY_LIMIT:30} - anonymous-daily-limit: ${CHATBOT_ANONYMOUS_DAILY_LIMIT:10} - burst-per-minute: ${CHATBOT_BURST_PER_MINUTE:5} + anonymous-daily-limit: ${CHATBOT_ANONYMOUS_DAILY_LIMIT:100} + burst-per-minute: ${CHATBOT_BURST_PER_MINUTE:50} + #anonymous-daily-limit: ${CHATBOT_ANONYMOUS_DAILY_LIMIT:10} + #burst-per-minute: ${CHATBOT_BURST_PER_MINUTE:5} daily-reset-zone: ${CHATBOT_DAILY_RESET_ZONE:Asia/Seoul} push: diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java index 7c7ecc3d..c2020bf4 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java @@ -5,11 +5,12 @@ import static org.hamcrest.Matchers.blankOrNullString; import static org.hamcrest.Matchers.not; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -42,8 +43,7 @@ void setUp() { ChatService chatService = new ChatService(llmGateway, memoryService, rateLimiter); lenient().when(memoryService.load(any(), any())).thenReturn(List.of()); mockMvc = MockMvcBuilders.standaloneSetup(new ChatController(chatService)) - .setControllerAdvice(new GlobalExceptionHandler()) - .build(); + .setControllerAdvice(new GlobalExceptionHandler()).build(); } @Test @@ -64,29 +64,31 @@ void rejectsMessageLongerThanOneThousandCharactersBeforeLlmCall() throws Excepti @Test void rejectsInvalidCoordinatesBeforeLlmCall() throws Exception { - mockMvc.perform(post("/api/chatbot/messages").contentType(MediaType.APPLICATION_JSON).content(""" - {"message":"길을 알려줘","context":{"currentLocation":{"latitude":91.0,"longitude":-181.0}}} - """)) + mockMvc.perform(post("/api/chatbot/messages").contentType(MediaType.APPLICATION_JSON) + .content("{\"message\":\"route\",\"context\":{\"currentLocation\":{" + + "\"latitude\":91.0,\"longitude\":-181.0}}}")) .andExpect(status().isBadRequest()); verifyNoInteractions(llmGateway); } @Test - void generatesSessionIdAndReturnsGatewayReply() throws Exception { - when(llmGateway.generate(anyString(), anyString())).thenReturn("안녕하세요."); + void generatesSessionIdAndReturnsGatewayReplyWithoutAction() throws Exception { + when(llmGateway.generate(anyString(), anyList(), anyString())) + .thenReturn(new LlmGateway.LlmResult("hello", List.of())); mockMvc.perform(post("/api/chatbot/messages").contentType(MediaType.APPLICATION_JSON) - .content("{\"message\":\" 안녕 \"}")) + .content("{\"message\":\"hello\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.statusCode").value(0)) .andExpect(jsonPath("$.data.sessionId", not(blankOrNullString()))) - .andExpect(jsonPath("$.data.reply").value("안녕하세요.")); + .andExpect(jsonPath("$.data.reply").value("hello")) + .andExpect(jsonPath("$.data.action").doesNotExist()); } @Test void returns429BeforeProviderCallWhenRateLimited() throws Exception { doThrow(new GlobalException(CHATBOT_RATE_LIMITED)).when(rateLimiter).check(any()); mockMvc.perform(post("/api/chatbot/messages").contentType(MediaType.APPLICATION_JSON) - .content("{\"message\":\"안녕\"}")) + .content("{\"message\":\"hello\"}")) .andExpect(status().isTooManyRequests()) .andExpect(jsonPath("$.statusCode").value(20001)); verifyNoInteractions(llmGateway); @@ -94,10 +96,10 @@ void returns429BeforeProviderCallWhenRateLimited() throws Exception { @Test void hidesProviderFailureDetails() throws Exception { - when(llmGateway.generate(anyString(), anyString())) + when(llmGateway.generate(anyString(), anyList(), anyString())) .thenThrow(new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE)); mockMvc.perform(post("/api/chatbot/messages").contentType(MediaType.APPLICATION_JSON) - .content("{\"message\":\"안녕\"}")) + .content("{\"message\":\"hello\"}")) .andExpect(status().isServiceUnavailable()) .andExpect(jsonPath("$.statusCode").value(20000)) .andExpect(jsonPath("$.data").doesNotExist()); diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java index a4ccb46b..98f35a72 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java @@ -3,18 +3,39 @@ import static devkor.com.teamcback.global.response.ResultCode.CHATBOT_TEMPORARILY_UNAVAILABLE; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter; import devkor.com.teamcback.domain.chatbot.tool.CampusChatbotTools; +import devkor.com.teamcback.domain.chatbot.tool.CampusToolAdapter; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusIntent; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusItem; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusMatchType; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusRole; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType; import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.ChatClientResponse; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.mockito.ArgumentCaptor; class SpringAiLlmGatewayTest { private final ExecutorService executor = Executors.newSingleThreadExecutor(); @@ -28,26 +49,116 @@ void tearDown() { void mapsProviderFailureWithoutExposingProviderDetails() { ChatClient chatClient = mock(ChatClient.class, RETURNS_DEEP_STUBS); ChatClient.Builder builder = mock(ChatClient.Builder.class); + CampusChatbotTools tools = mock(CampusChatbotTools.class); when(builder.build()).thenReturn(chatClient); - when(chatClient.prompt().system("system").user("hello").call().content()) + when(tools.forRequest(any(), any())).thenReturn(tools); + when(chatClient.prompt().system("system").messages(anyList()).tools(any(CampusChatbotTools.class)).call().content()) .thenThrow(new IllegalStateException("provider secret and internal details")); - ChatbotProperties properties = new ChatbotProperties(true, - new ChatbotProperties.Llm("google", "gemini-2.5-flash-lite", 500, 8), - new ChatbotProperties.Agent(6, 5, 60), - new ChatbotProperties.Tools( - new ChatbotProperties.Limits(5, 10), - new ChatbotProperties.Limits(10, 20), 7, - new ChatbotProperties.Limits(5, 10)), - new ChatbotProperties.RateLimit(30, 10, 5, "Asia/Seoul")); - CampusChatbotTools tools = mock(CampusChatbotTools.class); - SpringAiLlmGateway gateway = new SpringAiLlmGateway(builder, properties, executor, tools, - new devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter(properties)); + SpringAiLlmGateway gateway = new SpringAiLlmGateway(builder, properties(), executor, tools, + new ChatbotToolCallLimiter(properties())); - assertThatThrownBy(() -> gateway.generate("system", "hello")) + assertThatThrownBy(() -> gateway.generate("system", List.of(), "hello")) .isInstanceOfSatisfying(GlobalException.class, exception -> { assertThat(exception.getResultCode()).isEqualTo(CHATBOT_TEMPORARILY_UNAVAILABLE); assertThat(exception.getMessage()).isNull(); assertThat(exception.getCause()).isNull(); }); } + + @Test + void sendsHistoryAsOrderedRoleMessagesAndRegistersRequestLocalTools() { + ChatClient chatClient = mock(ChatClient.class); + ChatClient.Builder builder = mock(ChatClient.Builder.class); + ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class); + ChatClient.CallResponseSpec responseSpec = mock(ChatClient.CallResponseSpec.class); + CampusChatbotTools tools = mock(CampusChatbotTools.class); + CampusChatbotTools requestTools = mock(CampusChatbotTools.class); + when(builder.build()).thenReturn(chatClient); + when(tools.forRequest(any(), any())).thenReturn(requestTools); + when(chatClient.prompt()).thenReturn(requestSpec); + when(requestSpec.system("system")).thenReturn(requestSpec); + when(requestSpec.messages(anyList())).thenReturn(requestSpec); + when(requestSpec.tools(requestTools)).thenReturn(requestSpec); + when(requestSpec.call()).thenReturn(responseSpec); + when(responseSpec.chatClientResponse()).thenReturn(new ChatClientResponse( + new ChatResponse(List.of(new Generation(new AssistantMessage("done")))), java.util.Map.of())); + SpringAiLlmGateway gateway = new SpringAiLlmGateway(builder, properties(), executor, tools, + new ChatbotToolCallLimiter(properties())); + + LlmGateway.LlmResult result = gateway.generate("system", List.of( + new LlmGateway.ConversationMessage(LlmGateway.Role.USER, "old user"), + new LlmGateway.ConversationMessage(LlmGateway.Role.ASSISTANT, "old assistant")), "current user"); + + assertThat(result.reply()).isEqualTo("done"); + assertThat(result.resolvedLocations()).isEmpty(); + @SuppressWarnings("unchecked") + ArgumentCaptor> messages = ArgumentCaptor.forClass(List.class); + verify(requestSpec).messages(messages.capture()); + assertThat(messages.getValue()).hasSize(3); + assertThat(messages.getValue().get(0)).isInstanceOf(UserMessage.class); + assertThat(messages.getValue().get(1)).isInstanceOf(AssistantMessage.class); + assertThat(messages.getValue().get(2)).isInstanceOf(UserMessage.class); + verify(requestSpec).tools(requestTools); + } + + @Test + void preservesNavigateTraceWhenFinalModelFollowUpFails() { + ChatClient chatClient = mock(ChatClient.class); + ChatClient.Builder builder = mock(ChatClient.Builder.class); + ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class); + ChatClient.CallResponseSpec responseSpec = mock(ChatClient.CallResponseSpec.class); + CampusToolAdapter adapter = mock(CampusToolAdapter.class); + ChatbotProperties properties = properties(); + ChatbotToolCallLimiter limiter = new ChatbotToolCallLimiter(properties); + CampusChatbotTools baseTools = new CampusChatbotTools(adapter, limiter); + AtomicReference requestTools = new AtomicReference<>(); + SearchCampusToolRequest startRequest = new SearchCampusToolRequest("start", 1, + SearchCampusRole.START, SearchCampusIntent.NAVIGATE_ROUTE, List.of()); + SearchCampusToolRequest endRequest = new SearchCampusToolRequest("end", 1, + SearchCampusRole.END, SearchCampusIntent.NAVIGATE_ROUTE, List.of()); + when(builder.build()).thenReturn(chatClient); + when(chatClient.prompt()).thenReturn(requestSpec); + when(requestSpec.system("system")).thenReturn(requestSpec); + when(requestSpec.messages(anyList())).thenReturn(requestSpec); + when(requestSpec.tools(any(CampusChatbotTools.class))).thenAnswer(invocation -> { + requestTools.set(invocation.getArgument(0)); + return requestSpec; + }); + when(requestSpec.call()).thenReturn(responseSpec); + when(adapter.searchCampus(startRequest)).thenReturn(resolved(11L, "start")); + when(adapter.searchCampus(endRequest)).thenReturn(resolved(22L, "end")); + when(responseSpec.chatClientResponse()).thenAnswer(invocation -> { + requestTools.get().searchCampus(startRequest); + requestTools.get().searchCampus(endRequest); + return new ChatClientResponse( + new ChatResponse(List.of(new Generation(new AssistantMessage("")))), java.util.Map.of()); + }); + SpringAiLlmGateway gateway = new SpringAiLlmGateway(builder, properties, executor, baseTools, limiter); + + LlmGateway.LlmResult result = gateway.generate("system", List.of(), "route request"); + + assertThat(result.reply()).isNull(); + assertThat(result.completionStatus()) + .isEqualTo(LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION); + assertThat(result.resolvedLocations()).hasSize(2); + assertThat(result.resolvedLocations()).extracting(location -> location.id()) + .containsExactly(11L, 22L); + } + + private SearchCampusToolResult resolved(long id, String name) { + return new SearchCampusToolResult(List.of(new SearchCampusItem( + id, ToolLocationType.BUILDING, name, id, name, null, null, null, + SearchCampusMatchType.EXACT)), false, null); + } + + private ChatbotProperties properties() { + return new ChatbotProperties(true, + new ChatbotProperties.Llm("google", "gemini-2.5-flash-lite", 500, 8), + new ChatbotProperties.Agent(6, 5, 60, 15), + new ChatbotProperties.Tools( + new ChatbotProperties.Limits(5, 10), + new ChatbotProperties.Limits(10, 20), 7, + new ChatbotProperties.Limits(5, 10)), + new ChatbotProperties.RateLimit(30, 10, 5, "Asia/Seoul")); + } } diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java new file mode 100644 index 00000000..5fc49bf6 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java @@ -0,0 +1,250 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.chatbot.dto.PendingLocationRef; +import devkor.com.teamcback.domain.chatbot.dto.PendingRouteState; +import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation; +import devkor.com.teamcback.domain.chatbot.dto.SearchResolutionTrace; +import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation.EndpointRole; +import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation.RouteIntent; +import devkor.com.teamcback.domain.chatbot.dto.request.ChatMessageReq; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteCondition; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteEndpointType; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusIntent; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusRole; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ChatServiceActionTest { + @Mock LlmGateway llmGateway; + @Mock ChatSessionMemoryService memoryService; + @Mock ChatRateLimiter rateLimiter; + @Mock PendingRouteStateService pendingRouteStateService; + + @Test + void assemblesNavigateRouteOnlyFromResolvedToolReferences() { + when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( + "model invented id 999999", List.of( + new ResolvedLocation(EndpointRole.START, RouteEndpointType.BUILDING, 123L, + "송현스퀘어", RouteIntent.NAVIGATE_ROUTE, List.of(RouteCondition.BARRIERFREE)), + new ResolvedLocation(EndpointRole.END, RouteEndpointType.PLACE, 456L, + "중앙도서관", RouteIntent.NAVIGATE_ROUTE, List.of())))); + ChatService service = new ChatService(llmGateway, memoryService, rateLimiter); + UUID sessionId = UUID.randomUUID(); + ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); + when(memoryService.load(sessionId, caller)).thenReturn(List.of()); + + var response = service.sendMessage(new ChatMessageReq(sessionId, "길찾기 해줘", null), caller); + + assertThat(response.action()).isNotNull(); + assertThat(response.action().payload().startId()).isEqualTo(123L); + assertThat(response.action().payload().endId()).isEqualTo(456L); + assertThat(response.action().payload().conditions()).containsExactly(RouteCondition.BARRIERFREE); + assertThat(response.reply()).doesNotContain("999999"); + } + + @Test + void doesNotAssembleActionForAmbiguousOrTextRouteTrace() { + when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( + "text route", List.of(new ResolvedLocation(EndpointRole.START, RouteEndpointType.BUILDING, 123L, + "학생회관", RouteIntent.TEXT_ROUTE, List.of())))); + ChatService service = new ChatService(llmGateway, memoryService, rateLimiter); + UUID sessionId = UUID.randomUUID(); + ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); + when(memoryService.load(sessionId, caller)).thenReturn(List.of()); + + var response = service.sendMessage(new ChatMessageReq(sessionId, "몇 분 걸려?", null), caller); + + assertThat(response.action()).isNull(); + assertThat(response.reply()).isEqualTo("text route"); + } + + @Test + void returnsValidatedActionWhenFinalModelFollowUpFailed() { + when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( + null, List.of( + new ResolvedLocation(EndpointRole.START, RouteEndpointType.BUILDING, 123L, + "start", RouteIntent.NAVIGATE_ROUTE, List.of()), + new ResolvedLocation(EndpointRole.END, RouteEndpointType.PLACE, 456L, + "end", RouteIntent.NAVIGATE_ROUTE, List.of())), + LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION)); + ChatService service = new ChatService(llmGateway, memoryService, rateLimiter); + UUID sessionId = UUID.randomUUID(); + ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); + when(memoryService.load(sessionId, caller)).thenReturn(List.of()); + + var response = service.sendMessage(new ChatMessageReq(sessionId, "route", null), caller); + + assertThat(response.action()).isNotNull(); + assertThat(response.action().payload().startId()).isEqualTo(123L); + assertThat(response.action().payload().endId()).isEqualTo(456L); + assertThat(response.reply()).contains("start", "end"); + } + + @Test + void keepsProviderFailureWhenFailedFollowUpHasOnlyOneEndpoint() { + when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( + null, List.of(new ResolvedLocation(EndpointRole.START, RouteEndpointType.BUILDING, 123L, + "start", RouteIntent.NAVIGATE_ROUTE, List.of())), + LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION)); + ChatService service = new ChatService(llmGateway, memoryService, rateLimiter); + UUID sessionId = UUID.randomUUID(); + ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); + when(memoryService.load(sessionId, caller)).thenReturn(List.of()); + + assertThatThrownBy(() -> service.sendMessage(new ChatMessageReq(sessionId, "route", null), caller)) + .isInstanceOf(devkor.com.teamcback.global.exception.exception.GlobalException.class) + .satisfies(exception -> assertThat( + ((devkor.com.teamcback.global.exception.exception.GlobalException) exception).getResultCode()) + .isEqualTo(devkor.com.teamcback.global.response.ResultCode.CHATBOT_TEMPORARILY_UNAVAILABLE)); + } + + @Test + void storesAmbiguousEndpointAsPendingRouteState() { + when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( + "어느 문과대학 서관인지 알려주세요", List.of( + new ResolvedLocation(EndpointRole.START, RouteEndpointType.BUILDING, 123L, "송현스퀘어", + RouteIntent.NAVIGATE_ROUTE, List.of())), LlmGateway.CompletionStatus.COMPLETE, + List.of(new SearchResolutionTrace(SearchCampusRole.START, SearchCampusIntent.NAVIGATE_ROUTE, + "송현스퀘어", false, + List.of(new PendingLocationRef(RouteEndpointType.BUILDING, 123L, "송현스퀘어")), List.of()), + new SearchResolutionTrace(SearchCampusRole.END, SearchCampusIntent.NAVIGATE_ROUTE, + "문과대학 서관", true, + List.of(new PendingLocationRef(RouteEndpointType.BUILDING, 201L, "문과대학 서관"), + new PendingLocationRef(RouteEndpointType.PLACE, 202L, "문과대학 서관 1층 라운지")), List.of())))); + ChatService service = new ChatService(llmGateway, memoryService, rateLimiter, pendingRouteStateService); + UUID sessionId = UUID.randomUUID(); + ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); + when(memoryService.load(sessionId, caller)).thenReturn(List.of()); + when(pendingRouteStateService.load(sessionId, caller)).thenReturn(java.util.Optional.empty()); + + var response = service.sendMessage(new ChatMessageReq(sessionId, "route", null), caller); + + assertThat(response.action()).isNull(); + org.mockito.ArgumentCaptor state = org.mockito.ArgumentCaptor.forClass(PendingRouteState.class); + verify(pendingRouteStateService).save(eq(sessionId), eq(caller), state.capture()); + assertThat(state.getValue().unresolvedRole()).isEqualTo(SearchCampusRole.END); + assertThat(state.getValue().resolvedStart().locationId()).isEqualTo(123L); + assertThat(state.getValue().ambiguousCandidates()).hasSize(2); + } + + @Test + void createsActionOnlyFromCurrentTurnAfterPendingClarification() { + PendingRouteState pending = new PendingRouteState(RouteIntent.NAVIGATE_ROUTE, + new PendingLocationRef(RouteEndpointType.BUILDING, 999L, "old start"), null, + SearchCampusRole.END, List.of(), List.of(new PendingLocationRef(RouteEndpointType.BUILDING, 201L, "old end"))); + when(pendingRouteStateService.load(org.mockito.ArgumentMatchers.any(UUID.class), org.mockito.ArgumentMatchers.any())) + .thenReturn(java.util.Optional.of(pending)); + when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( + "완료", List.of( + new ResolvedLocation(EndpointRole.START, RouteEndpointType.BUILDING, 123L, "new start", + RouteIntent.NAVIGATE_ROUTE, List.of()), + new ResolvedLocation(EndpointRole.END, RouteEndpointType.PLACE, 456L, "new end", + RouteIntent.NAVIGATE_ROUTE, List.of())))); + ChatService service = new ChatService(llmGateway, memoryService, rateLimiter, pendingRouteStateService); + UUID sessionId = UUID.randomUUID(); + ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); + when(memoryService.load(sessionId, caller)).thenReturn(List.of()); + + var response = service.sendMessage(new ChatMessageReq(sessionId, "1층 라운지로 해줘", null), caller); + + assertThat(response.action()).isNotNull(); + assertThat(response.action().payload().startId()).isEqualTo(123L); + assertThat(response.action().payload().endId()).isEqualTo(456L); + verify(pendingRouteStateService).delete(sessionId, caller); + org.mockito.ArgumentCaptor prompt = org.mockito.ArgumentCaptor.forClass(String.class); + verify(llmGateway).generate(prompt.capture(), anyList(), eq("1층 라운지로 해줘")); + assertThat(prompt.getValue()).contains("PENDING_ROUTE_CONTINUATION", "interaction=NAVIGATE_ROUTE", + "unresolvedRole=END", "re-search both endpoints", "never reuse an old ID"); + } + + @Test + void returnsDeterministicClarificationWhenEndIsAmbiguousAndCompletionIsBlank() { + when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( + null, List.of(new ResolvedLocation(EndpointRole.START, RouteEndpointType.BUILDING, 123L, "송현스퀘어", + RouteIntent.NAVIGATE_ROUTE, List.of())), LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION, + List.of(new SearchResolutionTrace(SearchCampusRole.END, SearchCampusIntent.NAVIGATE_ROUTE, + "중앙도서관", true, + List.of(new PendingLocationRef(RouteEndpointType.BUILDING, 201L, "중앙도서관(신관)"), + new PendingLocationRef(RouteEndpointType.PLACE, 202L, "중앙도서관(대학원)")), List.of())))); + ChatService service = new ChatService(llmGateway, memoryService, rateLimiter, pendingRouteStateService); + UUID sessionId = UUID.randomUUID(); + ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); + when(memoryService.load(sessionId, caller)).thenReturn(List.of()); + when(pendingRouteStateService.load(sessionId, caller)).thenReturn(java.util.Optional.empty()); + + var response = service.sendMessage(new ChatMessageReq(sessionId, "route", null), caller); + + assertThat(response.action()).isNull(); + assertThat(response.reply()).contains("중앙도서관(신관)", "중앙도서관(대학원)"); + verify(memoryService).save(eq(sessionId), eq(caller), eq("route"), org.mockito.ArgumentMatchers.contains("도착지")); + verify(pendingRouteStateService).save(eq(sessionId), eq(caller), org.mockito.ArgumentMatchers.any(PendingRouteState.class)); + } + + @Test + void returnsDeterministicClarificationWhenStartIsAmbiguousAndCompletionIsBlank() { + when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( + "", List.of(new ResolvedLocation(EndpointRole.END, RouteEndpointType.BUILDING, 456L, "중앙도서관", + RouteIntent.NAVIGATE_ROUTE, List.of())), LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION, + List.of(new SearchResolutionTrace(SearchCampusRole.START, SearchCampusIntent.NAVIGATE_ROUTE, + "학생회관", true, + List.of(new PendingLocationRef(RouteEndpointType.BUILDING, 101L, "학생회관 본관")), List.of())))); + ChatService service = new ChatService(llmGateway, memoryService, rateLimiter, pendingRouteStateService); + UUID sessionId = UUID.randomUUID(); + ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); + when(memoryService.load(sessionId, caller)).thenReturn(List.of()); + when(pendingRouteStateService.load(sessionId, caller)).thenReturn(java.util.Optional.empty()); + + var response = service.sendMessage(new ChatMessageReq(sessionId, "route", null), caller); + + assertThat(response.reply()).contains("출발지 후보가 여러 개 있어요", "학생회관 본관"); + assertThat(response.action()).isNull(); + } + + @Test + void deduplicatesSameActualCandidateInDeterministicClarification() { + when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( + null, List.of(), LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION, + List.of(new SearchResolutionTrace(SearchCampusRole.START, SearchCampusIntent.NAVIGATE_ROUTE, + "중앙도서관", true, + List.of(new PendingLocationRef(RouteEndpointType.PLACE, 300L, "야외 중앙도서관 장애인주차장"), + new PendingLocationRef(RouteEndpointType.PLACE, 300L, "야외 중앙도서관 장애인주차장"), + new PendingLocationRef(RouteEndpointType.PLACE, 301L, "야외 중앙도서관 장애인주차장")), List.of())))); + ChatService service = new ChatService(llmGateway, memoryService, rateLimiter, pendingRouteStateService); + UUID sessionId = UUID.randomUUID(); + ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); + when(memoryService.load(sessionId, caller)).thenReturn(List.of()); + when(pendingRouteStateService.load(sessionId, caller)).thenReturn(java.util.Optional.empty()); + + var response = service.sendMessage(new ChatMessageReq(sessionId, "route", null), caller); + + assertThat(response.reply()).isEqualTo("출발지 후보가 여러 개 있어요. 야외 중앙도서관 장애인주차장, 야외 중앙도서관 장애인주차장 중 어디로 갈까요?"); + } + + @Test + void keepsTextRouteCompletionFailureAsError() { + when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( + null, List.of(), LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION)); + ChatService service = new ChatService(llmGateway, memoryService, rateLimiter, pendingRouteStateService); + UUID sessionId = UUID.randomUUID(); + ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); + when(memoryService.load(sessionId, caller)).thenReturn(List.of()); + when(pendingRouteStateService.load(sessionId, caller)).thenReturn(java.util.Optional.empty()); + + assertThatThrownBy(() -> service.sendMessage(new ChatMessageReq(sessionId, "text route", null), caller)) + .isInstanceOf(devkor.com.teamcback.global.exception.exception.GlobalException.class); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceCurrentLocationTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceCurrentLocationTest.java index 3f4d1373..5141bdcc 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceCurrentLocationTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceCurrentLocationTest.java @@ -1,6 +1,7 @@ package devkor.com.teamcback.domain.chatbot.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -25,23 +26,45 @@ class ChatServiceCurrentLocationTest { @Test void passesCurrentLocationOnlyInCurrentProviderRequestAndNeverSavesIt() { - when(llmGateway.generate(anyString(), anyString())).thenReturn("경로 안내"); + when(llmGateway.generate(anyString(), anyList(), anyString())) + .thenReturn(new LlmGateway.LlmResult("route answer", List.of())); ChatService service = new ChatService(llmGateway, memoryService, rateLimiter); UUID sessionId = UUID.randomUUID(); ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); when(memoryService.load(sessionId, caller)).thenReturn(List.of()); - service.sendMessage(new ChatMessageReq(sessionId, "현재 위치에서 중도까지 가줘", + service.sendMessage(new ChatMessageReq(sessionId, "route from current location", new ChatContextReq(new CurrentLocationReq(37.5861, 127.0290))), caller); - service.sendMessage(new ChatMessageReq(sessionId, "다시 알려줘", null), caller); + service.sendMessage(new ChatMessageReq(sessionId, "tell me again", null), caller); ArgumentCaptor messages = ArgumentCaptor.forClass(String.class); - verify(llmGateway, org.mockito.Mockito.times(2)).generate(anyString(), messages.capture()); - assertThat(messages.getAllValues().get(0)) - .contains("현재 위치에서 중도까지 가줘", "latitude=37.5861", "longitude=127.029"); - assertThat(messages.getAllValues().get(1)).isEqualTo("다시 알려줘") + verify(llmGateway, org.mockito.Mockito.times(2)).generate(anyString(), anyList(), messages.capture()); + assertThat(messages.getAllValues().get(0)).contains("latitude=37.5861", "longitude=127.029"); + assertThat(messages.getAllValues().get(1)).isEqualTo("tell me again") .doesNotContain("37.5861", "127.029", "currentLocation"); - verify(memoryService).save(sessionId, caller, "현재 위치에서 중도까지 가줘", "경로 안내"); - verify(memoryService).save(sessionId, caller, "다시 알려줘", "경로 안내"); + verify(memoryService).save(sessionId, caller, "route from current location", "route answer"); + verify(memoryService).save(sessionId, caller, "tell me again", "route answer"); + } + + @Test + void preservesRecentRolesAndKeepsCurrentCorrectionAsLatestUserMessage() { + when(llmGateway.generate(anyString(), anyList(), anyString())) + .thenReturn(new LlmGateway.LlmResult("answer", List.of())); + ChatService service = new ChatService(llmGateway, memoryService, rateLimiter); + UUID sessionId = UUID.randomUUID(); + ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); + when(memoryService.load(sessionId, caller)).thenReturn(List.of( + new ChatSessionMemoryService.ChatTurn("old user", "old assistant"))); + + service.sendMessage(new ChatMessageReq(sessionId, "current correction", null), caller); + + @SuppressWarnings("unchecked") + ArgumentCaptor> history = ArgumentCaptor.forClass(List.class); + ArgumentCaptor currentMessage = ArgumentCaptor.forClass(String.class); + verify(llmGateway).generate(anyString(), history.capture(), currentMessage.capture()); + assertThat(history.getValue()).containsExactly( + new LlmGateway.ConversationMessage(LlmGateway.Role.USER, "old user"), + new LlmGateway.ConversationMessage(LlmGateway.Role.ASSISTANT, "old assistant")); + assertThat(currentMessage.getValue()).isEqualTo("current correction"); } } diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServicePromptTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServicePromptTest.java new file mode 100644 index 00000000..301c847c --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServicePromptTest.java @@ -0,0 +1,23 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class ChatServicePromptTest { + @Test + void separatesUiActionAndTextRouteOrchestration() { + assertThat(ChatService.SYSTEM_PROMPT) + .contains("[ROUTE_BEHAVIOR]", "intent=NAVIGATE_ROUTE", "do not call findRoute") + .contains("intent=TEXT_ROUTE", "then findRoute") + .contains("backend creates", "actual Tool results") + .contains("never invent", "ambiguous", "PENDING_ROUTE_CONTINUATION", "never copy"); + } + + @Test + void routeContractKeepsAllowedRolesAndIntentsExplicit() { + assertThat(ChatService.SYSTEM_PROMPT) + .contains("role=START", "role=END", "START/END", "NAVIGATE_ROUTE/TEXT_ROUTE") + .contains("BARRIERFREE", "six-call limit", "latest user"); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatbotOperationsTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatbotOperationsTest.java index 8520dd93..50403ffc 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatbotOperationsTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatbotOperationsTest.java @@ -22,7 +22,7 @@ class ChatbotOperationsTest { private final ChatbotProperties properties = new ChatbotProperties(true, new ChatbotProperties.Llm("google", "model", 500, 8), - new ChatbotProperties.Agent(6, 5, 60), + new ChatbotProperties.Agent(6, 5, 60, 15), new ChatbotProperties.Tools(new ChatbotProperties.Limits(5, 10), new ChatbotProperties.Limits(10, 20), 7, new ChatbotProperties.Limits(5, 10)), new ChatbotProperties.RateLimit(30, 10, 5, "Asia/Seoul")); @@ -90,10 +90,10 @@ void toolCallLimiterBlocksSeventhInvocation() { ChatbotToolCallLimiter limiter = new ChatbotToolCallLimiter(properties); try (ChatbotToolCallLimiter.Scope scope = limiter.open()) { for (int index = 0; index < 6; index++) { - limiter.beforeToolCall(); + scope.beforeToolCall(); } assertThat(scope.callCount()).isEqualTo(6); - assertThatThrownBy(limiter::beforeToolCall).isInstanceOf(ToolCallLimitExceededException.class); + assertThatThrownBy(scope::beforeToolCall).isInstanceOf(ToolCallLimitExceededException.class); } } } diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/PendingRouteStateServiceTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/PendingRouteStateServiceTest.java new file mode 100644 index 00000000..6f52c66d --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/PendingRouteStateServiceTest.java @@ -0,0 +1,65 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import devkor.com.teamcback.domain.chatbot.dto.PendingLocationRef; +import devkor.com.teamcback.domain.chatbot.dto.PendingRouteState; +import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation.RouteIntent; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteEndpointType; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusRole; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.data.redis.core.StringRedisTemplate; + +@ExtendWith(MockitoExtension.class) +class PendingRouteStateServiceTest { + @Mock StringRedisTemplate redisTemplate; + @Mock ValueOperations values; + + @Test + void storesTypedStateInSeparateShortLivedKey() { + ChatbotProperties properties = new ChatbotProperties(true, null, + new ChatbotProperties.Agent(6, 5, 60, 15), null, null); + when(redisTemplate.opsForValue()).thenReturn(values); + PendingRouteStateService service = new PendingRouteStateService(redisTemplate, new ObjectMapper(), properties); + UUID sessionId = UUID.randomUUID(); + ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); + PendingRouteState state = new PendingRouteState(RouteIntent.NAVIGATE_ROUTE, + new PendingLocationRef(RouteEndpointType.BUILDING, 1L, "start"), null, + SearchCampusRole.END, List.of(), List.of()); + + service.save(sessionId, caller, state); + + verify(values).set(eq("chatbot:pending:route:" + sessionId), any(String.class), eq(15L), eq(TimeUnit.MINUTES)); + } + + @Test + void ownerMismatchDoesNotReturnPendingState() throws Exception { + ChatbotProperties properties = new ChatbotProperties(true, null, + new ChatbotProperties.Agent(6, 5, 60, 15), null, null); + when(redisTemplate.opsForValue()).thenReturn(values); + UUID sessionId = UUID.randomUUID(); + PendingRouteStateService service = new PendingRouteStateService(redisTemplate, new ObjectMapper(), properties); + String stored = new ObjectMapper().writeValueAsString(new PendingRouteStateService.StoredPendingRoute( + "another-owner", new PendingRouteState(RouteIntent.NAVIGATE_ROUTE, null, null, + SearchCampusRole.END, List.of(), List.of()))); + when(values.get("chatbot:pending:route:" + sessionId)).thenReturn(stored); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.load(sessionId, + ChatCaller.from(null, "127.0.0.1"))) + .isInstanceOf(devkor.com.teamcback.global.exception.exception.GlobalException.class); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ResolvedLocationCollectorTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ResolvedLocationCollectorTest.java new file mode 100644 index 00000000..c0d90382 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ResolvedLocationCollectorTest.java @@ -0,0 +1,81 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteCondition; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusIntent; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusRole; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusItem; +import devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.jupiter.api.Test; + +class ResolvedLocationCollectorTest { + @Test + void recordsOnlyAUniqueNonAmbiguousToolResult() { + ResolvedLocationCollector collector = new ResolvedLocationCollector(); + SearchCampusToolRequest request = new SearchCampusToolRequest( + "중앙도서관", 5, SearchCampusRole.END, SearchCampusIntent.NAVIGATE_ROUTE, + List.of(RouteCondition.BARRIERFREE)); + collector.record(request, new SearchCampusToolResult(List.of( + new SearchCampusItem(456L, ToolLocationType.BUILDING, "중앙도서관", 456L, + "중앙도서관", null, null, null, null)), false, null)); + + assertThat(collector.snapshot()).singleElement().satisfies(location -> { + assertThat(location.id()).isEqualTo(456L); + assertThat(location.role().name()).isEqualTo("END"); + assertThat(location.intent().name()).isEqualTo("NAVIGATE_ROUTE"); + }); + } + + @Test + void ignoresAmbiguousResultsAndDeduplicatesSameReference() { + ResolvedLocationCollector collector = new ResolvedLocationCollector(); + SearchCampusToolRequest request = new SearchCampusToolRequest( + "학생회관", 5, SearchCampusRole.START, SearchCampusIntent.NAVIGATE_ROUTE, List.of()); + SearchCampusItem item = new SearchCampusItem(123L, ToolLocationType.BUILDING, "학생회관", 123L, + "학생회관", null, null, null, null); + collector.record(request, new SearchCampusToolResult(List.of(item), true, null)); + collector.record(request, new SearchCampusToolResult(List.of(item), false, null)); + collector.record(request, new SearchCampusToolResult(List.of(item), false, null)); + + assertThat(collector.snapshot()).hasSize(1); + assertThat(collector.searchResolutionSnapshot()).hasSize(3); + assertThat(collector.searchResolutionSnapshot().get(0).ambiguous()).isTrue(); + assertThat(collector.searchResolutionSnapshot().get(0).candidates()).singleElement() + .satisfies(candidate -> assertThat(candidate.locationId()).isEqualTo(123L)); + } + + @Test + void separateCollectorsDoNotShareResolvedLocations() throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future> first = executor.submit(() -> { + ResolvedLocationCollector collector = new ResolvedLocationCollector(); + collector.record(new SearchCampusToolRequest("one", 5, SearchCampusRole.START, + SearchCampusIntent.NAVIGATE_ROUTE, List.of()), uniqueResult(1L, "one")); + return collector.snapshot(); + }); + Future> second = executor.submit(() -> { + ResolvedLocationCollector collector = new ResolvedLocationCollector(); + collector.record(new SearchCampusToolRequest("two", 5, SearchCampusRole.END, + SearchCampusIntent.NAVIGATE_ROUTE, List.of()), uniqueResult(2L, "two")); + return collector.snapshot(); + }); + assertThat(first.get()).extracting("id").containsExactly(1L); + assertThat(second.get()).extracting("id").containsExactly(2L); + } finally { + executor.shutdownNow(); + } + } + + private SearchCampusToolResult uniqueResult(Long id, String name) { + return new SearchCampusToolResult(List.of(new SearchCampusItem(id, ToolLocationType.BUILDING, + name, id, name, null, null, null, null)), false, null); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsTest.java index 97daa5ed..3c0b694e 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsTest.java @@ -1,14 +1,17 @@ package devkor.com.teamcback.domain.chatbot.tool; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.Arrays; import org.junit.jupiter.api.Test; +import org.springframework.ai.support.ToolCallbacks; import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.ToolCallback; class CampusChatbotToolsTest { @Test - void registersExactlyThePhaseThreeTools() { + void registersExactlyNineApprovedTools() { assertThat(Arrays.stream(CampusChatbotTools.class.getDeclaredMethods()) .filter(method -> method.isAnnotationPresent(Tool.class)) .map(method -> method.getAnnotation(Tool.class).name())) @@ -21,4 +24,46 @@ void registersExactlyThePhaseThreeTools() { void campusStatusHasNoToolInput() throws NoSuchMethodException { assertThat(CampusChatbotTools.class.getDeclaredMethod("getCampusStatus").getParameterCount()).isZero(); } + + @Test + void descriptionsDistinguishResolverAndUiRouteActionFromTextRoute() throws NoSuchMethodException { + String searchDescription = CampusChatbotTools.class + .getDeclaredMethod("searchCampus", + devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest.class) + .getAnnotation(Tool.class).description(); + String routeDescription = CampusChatbotTools.class + .getDeclaredMethod("findRoute", + devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolRequest.class) + .getAnnotation(Tool.class).description(); + + assertThat(searchDescription).contains("role START or END", "NAVIGATE_ROUTE", "never invent or select"); + assertThat(routeDescription).contains("textual route", "Do not call this only to open the UI route screen", + "NAVIGATE_ROUTE"); + } + + @Test + void generatesSearchCampusSchemaForRequestLocalTool() { + ToolCallback callback = Arrays.stream(ToolCallbacks.from(new CampusChatbotTools(null, null))) + .filter(candidate -> candidate.getToolDefinition().name().equals("searchCampus")) + .findFirst() + .orElseThrow(); + + assertThat(callback.getToolDefinition().inputSchema()) + .contains("\"query\"", "\"limit\"", "\"role\"", "\"intent\"", + "NAVIGATE_ROUTE", "START", "END"); + } + + @Test + void malformedSearchCampusEnumFailsBeforeToolMethodInvocation() { + ToolCallback callback = Arrays.stream(ToolCallbacks.from(new CampusChatbotTools(null, null))) + .filter(candidate -> candidate.getToolDefinition().name().equals("searchCampus")) + .findFirst() + .orElseThrow(); + + assertThatThrownBy(() -> callback.call( + "{\"request\":{\"query\":\"x\",\"limit\":1," + + "\"role\":\"INVALID\",\"intent\":\"NAVIGATE_ROUTE\",\"conditions\":[]}}")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Conversion from JSON"); + } } diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsToolLimitTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsToolLimitTest.java index 6e383ac2..c295ab29 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsToolLimitTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsToolLimitTest.java @@ -7,6 +7,7 @@ import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; import devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter; +import devkor.com.teamcback.domain.chatbot.service.ResolvedLocationCollector; import devkor.com.teamcback.domain.chatbot.service.ToolCallLimitExceededException; import org.junit.jupiter.api.Test; @@ -16,14 +17,14 @@ void seventhActualToolMethodDoesNotReachAdapter() { CampusToolAdapter adapter = mock(CampusToolAdapter.class); ChatbotProperties properties = new ChatbotProperties(true, new ChatbotProperties.Llm("google", "model", 500, 8), - new ChatbotProperties.Agent(6, 5, 60), + new ChatbotProperties.Agent(6, 5, 60, 15), new ChatbotProperties.Tools(new ChatbotProperties.Limits(5, 10), new ChatbotProperties.Limits(10, 20), 7, new ChatbotProperties.Limits(5, 10)), new ChatbotProperties.RateLimit(30, 10, 5, "Asia/Seoul")); ChatbotToolCallLimiter limiter = new ChatbotToolCallLimiter(properties); - CampusChatbotTools tools = new CampusChatbotTools(adapter, limiter); - - try (ChatbotToolCallLimiter.Scope ignored = limiter.open()) { + try (ChatbotToolCallLimiter.Scope scope = limiter.open()) { + CampusChatbotTools tools = new CampusChatbotTools(adapter, limiter) + .forRequest(new ResolvedLocationCollector(), scope); for (int index = 0; index < 6; index++) { tools.getCampusStatus(); } diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsWiringTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsWiringTest.java new file mode 100644 index 00000000..a06ac466 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsWiringTest.java @@ -0,0 +1,42 @@ +package devkor.com.teamcback.domain.chatbot.tool; + +import devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter; +import devkor.com.teamcback.domain.chatbot.service.ResolvedLocationCollector; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class CampusChatbotToolsWiringTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("chatbot.enabled=true") + .withBean(CampusToolAdapter.class, () -> mock(CampusToolAdapter.class)) + .withBean(ChatbotToolCallLimiter.class, () -> mock(ChatbotToolCallLimiter.class)) + .withUserConfiguration(TestConfig.class); + + @Test + void springCreatesBaseBeanAndBaseBeanCreatesRequestLocalTools() { + contextRunner.run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(CampusChatbotTools.class); + + CampusChatbotTools baseTools = context.getBean(CampusChatbotTools.class); + CampusChatbotTools firstRequestTools = baseTools.forRequest( + new ResolvedLocationCollector(), mock(ChatbotToolCallLimiter.Scope.class)); + CampusChatbotTools secondRequestTools = baseTools.forRequest( + new ResolvedLocationCollector(), mock(ChatbotToolCallLimiter.Scope.class)); + + assertThat(firstRequestTools).isNotSameAs(baseTools); + assertThat(secondRequestTools).isNotSameAs(baseTools); + assertThat(secondRequestTools).isNotSameAs(firstRequestTools); + }); + } + + @Configuration(proxyBeanMethods = false) + @Import(CampusChatbotTools.class) + static class TestConfig { + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusLifeToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusLifeToolAdapterTest.java index bb861aca..c4ed76a9 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusLifeToolAdapterTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusLifeToolAdapterTest.java @@ -52,7 +52,7 @@ class CampusLifeToolAdapterTest { void setUp() { ChatbotProperties properties = new ChatbotProperties(true, new ChatbotProperties.Llm("google", "gemini-2.5-flash-lite", 500, 8), - new ChatbotProperties.Agent(6, 5, 60), + new ChatbotProperties.Agent(6, 5, 60, 15), new ChatbotProperties.Tools(new ChatbotProperties.Limits(5, 10), new ChatbotProperties.Limits(10, 20), 7, new ChatbotProperties.Limits(5, 10)), diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRealtimeReviewToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRealtimeReviewToolAdapterTest.java index d63ed2a0..bdf172d6 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRealtimeReviewToolAdapterTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRealtimeReviewToolAdapterTest.java @@ -53,7 +53,7 @@ class CampusRealtimeReviewToolAdapterTest { void setUp() { ChatbotProperties properties = new ChatbotProperties(true, new ChatbotProperties.Llm("google", "gemini-2.5-flash-lite", 500, 8), - new ChatbotProperties.Agent(6, 5, 60), + new ChatbotProperties.Agent(6, 5, 60, 15), new ChatbotProperties.Tools(new ChatbotProperties.Limits(5, 10), new ChatbotProperties.Limits(10, 20), 7, new ChatbotProperties.Limits(5, 10)), diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRouteToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRouteToolAdapterTest.java index 95f68762..4647f814 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRouteToolAdapterTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRouteToolAdapterTest.java @@ -49,7 +49,7 @@ class CampusRouteToolAdapterTest { void setUp() { ChatbotProperties properties = new ChatbotProperties(true, new ChatbotProperties.Llm("google", "gemini-2.5-flash-lite", 500, 8), - new ChatbotProperties.Agent(6, 5, 60), + new ChatbotProperties.Agent(6, 5, 60, 15), new ChatbotProperties.Tools(new ChatbotProperties.Limits(5, 10), new ChatbotProperties.Limits(10, 20), 7, new ChatbotProperties.Limits(5, 10)), diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java index 7e562fe1..8489cf5a 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java @@ -8,6 +8,7 @@ import static devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType.PLACE; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -23,6 +24,7 @@ import devkor.com.teamcback.domain.place.entity.PlaceType; import devkor.com.teamcback.domain.search.dto.response.GlobalSearchListRes; import devkor.com.teamcback.domain.search.dto.response.GlobalSearchRes; +import devkor.com.teamcback.domain.search.dto.response.ChatbotSearchCandidate; import devkor.com.teamcback.domain.search.dto.response.SearchBuildingDetailRes; import devkor.com.teamcback.domain.search.dto.response.SearchBuildingFacilityListRes; import devkor.com.teamcback.domain.search.dto.response.SearchFacilityListRes; @@ -56,7 +58,7 @@ class CampusToolAdapterTest { void setUp() { ChatbotProperties properties = new ChatbotProperties(true, new ChatbotProperties.Llm("google", "gemini-2.5-flash-lite", 500, 8), - new ChatbotProperties.Agent(6, 5, 60), + new ChatbotProperties.Agent(6, 5, 60, 15), new ChatbotProperties.Tools( new ChatbotProperties.Limits(5, 10), new ChatbotProperties.Limits(10, 20), 7, @@ -68,7 +70,8 @@ void setUp() { @Test void centralLibraryNicknameSearchReturnsBuildingCandidateWithoutPersonalization() { GlobalSearchRes building = searchResult(11L, "중앙도서관", LocationType.BUILDING, null); - when(searchService.globalSearch("중도", null)).thenReturn(new GlobalSearchListRes(List.of(building))); + when(searchService.chatbotSearch("중도", 5)).thenReturn(List.of( + chatbotSearchResult(11L, LocationType.BUILDING, "중앙도서관", 11L, null, null))); var result = adapter.searchCampus(new SearchCampusToolRequest(" 중도 ", null)); @@ -79,24 +82,111 @@ void centralLibraryNicknameSearchReturnsBuildingCandidateWithoutPersonalization( assertThat(item.locationType()).isEqualTo(BUILDING); assertThat(item.name()).isEqualTo("중앙도서관"); }); - verify(searchService).globalSearch("중도", null); + verify(searchService).chatbotSearch("중도", 5); } @Test - void multipleSearchCandidatesRemainAmbiguousInsteadOfBeingSelected() { - GlobalSearchRes first = searchResult(11L, "중앙도서관", LocationType.BUILDING, null); - GlobalSearchRes second = searchResult(12L, "중앙도서관 신관", LocationType.BUILDING, null); - when(searchService.globalSearch("중앙도서관", null)) - .thenReturn(new GlobalSearchListRes(List.of(first, second))); + void uniqueExactMatchWinsOverPartialMatches() { + when(searchService.chatbotSearch("중앙도서관", 5)).thenReturn(List.of( + chatbotSearchResult(11L, LocationType.BUILDING, "중앙도서관", 11L, null, null), + chatbotSearchResult(12L, LocationType.BUILDING, "중앙도서관 신관", 12L, null, null))); var result = adapter.searchCampus(new SearchCampusToolRequest("중앙도서관", 1)); - assertThat(result.ambiguous()).isTrue(); - assertThat(result.error().code()).isEqualTo(AMBIGUOUS_LOCATION); + assertThat(result.ambiguous()).isFalse(); + assertThat(result.error()).isNull(); assertThat(result.candidates()).extracting("locationId").containsExactly(11L); + assertThat(result.candidates()).allMatch(item -> item.matchType() + == devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusMatchType.EXACT); verifyNoInteractions(routeService); } + @Test + void multipleExactMatchesRemainAmbiguous() { + when(searchService.chatbotSearch("학생회관", 5)).thenReturn(List.of( + chatbotSearchResult(11L, LocationType.BUILDING, "학생 회관", 11L, null, null), + chatbotSearchResult(12L, LocationType.PLACE, "학생회관", 12L, 12.0, PlaceType.LOUNGE))); + + var result = adapter.searchCampus(new SearchCampusToolRequest("학생회관", null)); + + assertThat(result.ambiguous()).isTrue(); + assertThat(result.error().code()).isEqualTo(AMBIGUOUS_LOCATION); + assertThat(result.candidates()).hasSize(2).allMatch(item -> item.matchType() + == devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusMatchType.EXACT); + } + + @Test + void multiplePartialMatchesRemainAmbiguous() { + when(searchService.chatbotSearch("학생회관", 5)).thenReturn(List.of( + chatbotSearchResult(11L, LocationType.BUILDING, "학생회관 신관", 11L, null, null), + chatbotSearchResult(12L, LocationType.PLACE, "학생회관 학생식당", 12L, 12.0, PlaceType.CAFETERIA))); + + var result = adapter.searchCampus(new SearchCampusToolRequest("학생회관", null)); + + assertThat(result.ambiguous()).isTrue(); + assertThat(result.error().code()).isEqualTo(AMBIGUOUS_LOCATION); + assertThat(result.candidates()).hasSize(2).noneMatch(item -> item.matchType() + == devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusMatchType.EXACT); + } + + @Test + void ignoresModelLimitForAmbiguityAndUsesAtMostFiveCandidates() { + List candidates = IntStream.rangeClosed(1, 50) + .mapToObj(id -> chatbotSearchResult((long) id, LocationType.BUILDING, + "장소" + id, (long) id, null, null)) + .toList(); + when(searchService.chatbotSearch("장소", 5)).thenReturn(candidates); + + var result = adapter.searchCampus(new SearchCampusToolRequest("장소", 1)); + + assertThat(result.candidates()).hasSize(5); + assertThat(result.ambiguous()).isTrue(); + verify(searchService).chatbotSearch("장소", 5); + } + + @Test + void deduplicatesSameLocationBeforeRanking() { + when(searchService.chatbotSearch("중도", 5)).thenReturn(List.of( + chatbotSearchResult(11L, LocationType.BUILDING, "중앙도서관", 11L, null, null), + chatbotSearchResult(11L, LocationType.BUILDING, "중앙도서관", 11L, null, null), + chatbotSearchResult(12L, LocationType.BUILDING, "중앙도서관 신관", 12L, null, null))); + + var result = adapter.searchCampus(new SearchCampusToolRequest("중도", null)); + + assertThat(result.candidates()).extracting("locationId").containsExactly(11L, 12L); + assertThat(result.ambiguous()).isTrue(); + } + + @Test + void uniqueStrongMatchWinsOverUnrelatedPartialMatches() { + when(searchService.chatbotSearch("송현스퀘어 학생회관", 5)).thenReturn(List.of( + chatbotSearchResult(11L, LocationType.BUILDING, "송현스퀘어", 11L, null, null), + chatbotSearchResult(12L, LocationType.BUILDING, "한투스퀘어", 12L, null, null))); + + var result = adapter.searchCampus(new SearchCampusToolRequest("송현스퀘어 학생회관", 1)); + + assertThat(result.candidates()).singleElement().satisfies(item -> { + assertThat(item.locationId()).isEqualTo(11L); + assertThat(item.matchType()).isEqualTo( + devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusMatchType.STRONG); + }); + assertThat(result.ambiguous()).isFalse(); + } + + @Test + void twoStrongMatchesRemainAmbiguous() { + when(searchService.chatbotSearch("송현스퀘어 학생회관", 5)).thenReturn(List.of( + chatbotSearchResult(11L, LocationType.BUILDING, "송현스퀘어", 11L, null, null), + chatbotSearchResult(12L, LocationType.BUILDING, "학생회관", 12L, null, null))); + + var result = adapter.searchCampus(new SearchCampusToolRequest("송현스퀘어 학생회관", null)); + + assertThat(result.candidates()).hasSize(2) + .allMatch(item -> item.matchType() + == devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusMatchType.STRONG); + assertThat(result.ambiguous()).isTrue(); + } + @Test void searchFiltersVirtualFacilityAndEnforcesMaximumLimit() { GlobalSearchRes virtualFacility = searchResult(null, "화장실", LocationType.FACILITY, PlaceType.TOILET); @@ -106,11 +196,14 @@ void searchFiltersVirtualFacilityAndEnforcesMaximumLimit() { List mixed = new java.util.ArrayList<>(); mixed.add(virtualFacility); mixed.addAll(places); - when(searchService.globalSearch("시설", null)).thenReturn(new GlobalSearchListRes(mixed)); + doReturn(places.stream() + .map(item -> chatbotSearchResult(item.getId(), LocationType.PLACE, item.getName(), + item.getBuildingId(), item.getFloor(), item.getPlaceType())) + .toList()).when(searchService).chatbotSearch("시설", 5); var result = adapter.searchCampus(new SearchCampusToolRequest("시설", 100)); - assertThat(result.candidates()).hasSize(10) + assertThat(result.candidates()).hasSize(5) .allMatch(item -> item.locationId() != null && item.locationType() == PLACE); } @@ -120,7 +213,7 @@ void blankSearchAndNoResultsReturnStableErrors() { .isEqualTo(INVALID_INPUT); verifyNoInteractions(searchService); - when(searchService.globalSearch("없는 곳", null)).thenReturn(new GlobalSearchListRes(List.of())); + when(searchService.chatbotSearch("없는 곳", 5)).thenReturn(List.of()); assertThat(adapter.searchCampus(new SearchCampusToolRequest("없는 곳", null)).error().code()) .isEqualTo(NOT_FOUND); } @@ -252,6 +345,11 @@ private GlobalSearchRes searchResult(Long id, String name, LocationType type, Pl return result; } + private ChatbotSearchCandidate chatbotSearchResult(Long id, LocationType type, String name, + Long buildingId, Double floor, PlaceType placeType) { + return new ChatbotSearchCandidate(id, type, name, buildingId, floor, placeType, null); + } + private List recordFieldNames(Class type) { return IntStream.range(0, type.getRecordComponents().length) .mapToObj(index -> type.getRecordComponents()[index]) From 6998d31b0d283aaea0721604e26b832a83679842 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Sat, 26 Sep 2026 22:26:07 +0900 Subject: [PATCH 08/21] refactor(chatbot): reuse global campus search --- .../chatbot/tool/CampusToolAdapter.java | 55 ++-- .../dto/response/ChatbotSearchCandidate.java | 16 - .../domain/search/service/SearchService.java | 85 ----- .../chatbot/tool/CampusToolAdapterTest.java | 311 +++--------------- 4 files changed, 73 insertions(+), 394 deletions(-) delete mode 100644 src/main/java/devkor/com/teamcback/domain/search/dto/response/ChatbotSearchCandidate.java diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java index 9d20adea..fd6ea202 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java @@ -77,7 +77,8 @@ import devkor.com.teamcback.domain.review.dto.response.SearchPlaceReviewRes; import devkor.com.teamcback.domain.review.service.ReviewService; import devkor.com.teamcback.domain.search.dto.response.GlobalSearchRes; -import devkor.com.teamcback.domain.search.dto.response.ChatbotSearchCandidate; +import devkor.com.teamcback.domain.search.dto.response.GlobalSearchListRes; +import devkor.com.teamcback.domain.search.dto.response.GlobalSearchRes; import devkor.com.teamcback.domain.search.dto.response.SearchBuildingDetailRes; import devkor.com.teamcback.domain.search.dto.response.SearchFacilityRes; import devkor.com.teamcback.domain.search.dto.response.SearchPlaceDetailRes; @@ -138,29 +139,20 @@ public SearchCampusToolResult searchCampus(SearchCampusToolRequest request) { int limit = Math.min(CHATBOT_SEARCH_RESULT_LIMIT, properties.tools().search().maxLimit()); try { String query = request.query().trim(); - List actualLocations = deduplicate( - searchService.chatbotSearch(query, limit)); + GlobalSearchListRes searchResult = searchService.globalSearch(query, null); + List actualLocations = deduplicate(searchResult == null + ? List.of() : searchResult.getList()); String normalizedQuery = normalizeSearchName(query); - List ranked = actualLocations.stream() - .map(item -> new RankedSearchCandidate(item, matchType(normalizedQuery, item.name()))) - .sorted(Comparator.comparingInt(item -> item.matchType().ordinal())) - .toList(); - List exactMatches = ranked.stream() - .filter(item -> item.matchType() == SearchCampusMatchType.EXACT).toList(); - List strongMatches = ranked.stream() - .filter(item -> item.matchType() == SearchCampusMatchType.STRONG).toList(); - List resolvedLocations = !exactMatches.isEmpty() - ? exactMatches : !strongMatches.isEmpty() ? strongMatches : ranked; - List candidates = resolvedLocations.stream() + List candidates = actualLocations.stream() .limit(limit) - .map(item -> toSearchItem(item.candidate(), item.matchType())) + .map(item -> toSearchItem(item, matchType(normalizedQuery, item.getName()))) .toList(); if (candidates.isEmpty()) { log.info("chatbot_search query={} limit={} durationMs={} candidateCount=0 ambiguous=false candidates=[]", query, limit, elapsedMillis(startedAt)); return new SearchCampusToolResult(List.of(), false, error(NOT_FOUND)); } - boolean ambiguous = resolvedLocations.size() > 1; + boolean ambiguous = actualLocations.size() > 1; log.info("chatbot_search query={} limit={} durationMs={} candidateCount={} ambiguous={} candidates={}", query, limit, elapsedMillis(startedAt), candidates.size(), ambiguous, candidates.stream().map(item -> item.name() + ":" + item.locationType() + ":" + item.matchType()).toList()); @@ -177,16 +169,18 @@ query, limit, elapsedMillis(startedAt), candidates.size(), ambiguous, } } - private List deduplicate(List candidates) { + private List deduplicate(List candidates) { if (candidates == null || candidates.isEmpty()) { return List.of(); } - Map unique = new LinkedHashMap<>(); - for (ChatbotSearchCandidate candidate : candidates) { - if (candidate == null || candidate.locationType() == null || candidate.locationId() == null) { + Map unique = new LinkedHashMap<>(); + for (GlobalSearchRes candidate : candidates) { + if (candidate == null || candidate.getLocationType() == null || candidate.getId() == null + || (candidate.getLocationType() != LocationType.BUILDING + && candidate.getLocationType() != LocationType.PLACE)) { continue; } - unique.putIfAbsent(candidate.locationType() + ":" + candidate.locationId(), candidate); + unique.putIfAbsent(candidate.getLocationType() + ":" + candidate.getId(), candidate); } return unique.values().stream().toList(); } @@ -590,13 +584,13 @@ private List flattenBuildingFacilities(FindFacilitiesToolReque .toList(); } - private SearchCampusItem toSearchItem(ChatbotSearchCandidate item, SearchCampusMatchType matchType) { - ToolLocationType type = item.locationType() == LocationType.BUILDING + private SearchCampusItem toSearchItem(GlobalSearchRes item, SearchCampusMatchType matchType) { + ToolLocationType type = item.getLocationType() == LocationType.BUILDING ? ToolLocationType.BUILDING : ToolLocationType.PLACE; - Long buildingId = type == ToolLocationType.BUILDING ? item.locationId() : item.buildingId(); - String buildingName = type == ToolLocationType.BUILDING ? item.name() : null; - return new SearchCampusItem(item.locationId(), type, item.name(), buildingId, buildingName, - item.floor(), item.placeType(), normalizeDetail(item.detail()), matchType); + Long buildingId = type == ToolLocationType.BUILDING ? item.getId() : item.getBuildingId(); + String buildingName = type == ToolLocationType.BUILDING ? item.getName() : null; + return new SearchCampusItem(item.getId(), type, item.getName(), buildingId, buildingName, + item.getFloor(), item.getPlaceType(), normalizeDetail(item.getDetail()), matchType); } private SearchCampusMatchType matchType(String normalizedQuery, String candidateName) { @@ -614,15 +608,16 @@ private long elapsedMillis(long startedAt) { return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt); } - private record RankedSearchCandidate(ChatbotSearchCandidate candidate, SearchCampusMatchType matchType) { - } - private String normalizeSearchName(String value) { if (value == null) { return ""; } return Normalizer.normalize(value, Normalizer.Form.NFC) .replaceAll("\\s+", "") + .replace("(", "") + .replace(")", "") + .replace("[", "") + .replace("]", "") .toLowerCase(Locale.ROOT); } diff --git a/src/main/java/devkor/com/teamcback/domain/search/dto/response/ChatbotSearchCandidate.java b/src/main/java/devkor/com/teamcback/domain/search/dto/response/ChatbotSearchCandidate.java deleted file mode 100644 index 2d47249b..00000000 --- a/src/main/java/devkor/com/teamcback/domain/search/dto/response/ChatbotSearchCandidate.java +++ /dev/null @@ -1,16 +0,0 @@ -package devkor.com.teamcback.domain.search.dto.response; - -import devkor.com.teamcback.domain.common.LocationType; -import devkor.com.teamcback.domain.place.entity.PlaceType; - -/** Minimal, node-free search result for chatbot candidate resolution. */ -public record ChatbotSearchCandidate( - Long locationId, - LocationType locationType, - String name, - Long buildingId, - Double floor, - PlaceType placeType, - String detail -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/search/service/SearchService.java b/src/main/java/devkor/com/teamcback/domain/search/service/SearchService.java index 3ed8c439..0d9c0b40 100644 --- a/src/main/java/devkor/com/teamcback/domain/search/service/SearchService.java +++ b/src/main/java/devkor/com/teamcback/domain/search/service/SearchService.java @@ -141,91 +141,6 @@ public GlobalSearchListRes globalSearch(String word, Long userId) { return new GlobalSearchListRes(orderSequence(scores)); } - /** - * Chatbot-only candidate lookup. Unlike globalSearch, this never touches a - * building/place Node and only fetches the associations needed to name a - * candidate. The public search API remains unchanged. - */ - @Transactional(readOnly = true) - public List chatbotSearch(String word, int limit) { - int fetchLimit = Math.max(10, limit * 2); - Pageable pageable = PageRequest.of(0, fetchLimit, Sort.by("id").ascending()); - LinkedHashMap candidates = new LinkedHashMap<>(); - List queries = new ArrayList<>(); - String trimmed = word == null ? "" : word.trim(); - if (!trimmed.isEmpty()) { - queries.add(trimmed); - if (trimmed.contains(" ")) { - queries.addAll(Arrays.stream(trimmed.split("\\s+")) - .filter(token -> !token.isBlank()) - .toList()); - } - } - - for (String query : queries) { - addChatbotBuildingCandidates(candidates, query); - addChatbotPlaceCandidates(candidates, query, pageable); - } - return candidates.values().stream().limit(fetchLimit).toList(); - } - - private void addChatbotBuildingCandidates(Map candidates, String query) { - String jaso = decomposeHangulString(query.replace(" ", "")); - addChatbotBuildings(candidates, - buildingNicknameRepository.findAllByJasoDecomposeContainingOrderByNickname(jaso, - PageRequest.of(0, 10))); - if (isConsonantOnly(query.replace(" ", ""))) { - addChatbotBuildings(candidates, - buildingNicknameRepository.findAllByChosungContainingOrderByNickname( - extractChosung(query.replace(" ", "")), PageRequest.of(0, 10))); - } - } - - private void addChatbotBuildings(Map candidates, - List nicknames) { - for (BuildingNickname nickname : nicknames) { - Building building = nickname.getBuilding(); - if (building == null || building.getId() == null) { - continue; - } - ChatbotSearchCandidate candidate = new ChatbotSearchCandidate(building.getId(), LocationType.BUILDING, - building.getName(), building.getId(), building.getFloor(), null, building.getDetail()); - candidates.putIfAbsent(candidateKey(candidate), candidate); - } - } - - private void addChatbotPlaceCandidates(Map candidates, String query, - Pageable pageable) { - String normalized = query.replace(" ", ""); - String jaso = decomposeHangulString(normalized); - addChatbotPlaces(candidates, placeNicknameRepository - .findAllByJasoDecomposeContainingOrderByNickname(jaso, pageable)); - if (isConsonantOnly(normalized)) { - addChatbotPlaces(candidates, placeNicknameRepository - .findAllByChosungContainingOrderByNickname(extractChosung(normalized), pageable)); - } - } - - private void addChatbotPlaces(Map candidates, - List nicknames) { - for (PlaceNickname nickname : nicknames) { - Place place = nickname.getPlace(); - if (place == null || place.getId() == null || place.getBuilding() == null - || place.getBuilding().getId() == null) { - continue; - } - Building building = place.getBuilding(); - ChatbotSearchCandidate candidate = new ChatbotSearchCandidate(place.getId(), LocationType.PLACE, - building.getName() + " " + place.getName(), building.getId(), place.getFloor(), - place.getType(), place.getDetail()); - candidates.putIfAbsent(candidateKey(candidate), candidate); - } - } - - private String candidateKey(ChatbotSearchCandidate candidate) { - return candidate.locationType() + ":" + candidate.locationId(); - } - /** * 모든 or 편의시설에 해당하는 건물 검색 */ diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java index 8489cf5a..9132638d 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java @@ -1,44 +1,22 @@ package devkor.com.teamcback.domain.chatbot.tool; -import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusFacilityType.TOILET; -import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.AMBIGUOUS_LOCATION; -import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.INVALID_INPUT; -import static devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.NOT_FOUND; -import static devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType.BUILDING; -import static devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType.PLACE; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; -import devkor.com.teamcback.domain.chatbot.tool.dto.FacilityToolItem; -import devkor.com.teamcback.domain.chatbot.tool.dto.FindFacilitiesToolRequest; -import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; import devkor.com.teamcback.domain.common.LocationType; import devkor.com.teamcback.domain.place.entity.PlaceType; +import devkor.com.teamcback.domain.routes.service.RouteService; import devkor.com.teamcback.domain.search.dto.response.GlobalSearchListRes; import devkor.com.teamcback.domain.search.dto.response.GlobalSearchRes; -import devkor.com.teamcback.domain.search.dto.response.ChatbotSearchCandidate; -import devkor.com.teamcback.domain.search.dto.response.SearchBuildingDetailRes; -import devkor.com.teamcback.domain.search.dto.response.SearchBuildingFacilityListRes; -import devkor.com.teamcback.domain.search.dto.response.SearchFacilityListRes; -import devkor.com.teamcback.domain.search.dto.response.SearchFacilityRes; -import devkor.com.teamcback.domain.search.dto.response.SearchFloorInfoRes; -import devkor.com.teamcback.domain.search.dto.response.SearchPlaceDetailRes; -import devkor.com.teamcback.domain.search.dto.response.SearchPlaceRes; -import devkor.com.teamcback.domain.search.dto.response.SearchRoomDetailRes; import devkor.com.teamcback.domain.search.service.SearchService; -import devkor.com.teamcback.domain.routes.service.RouteService; -import java.lang.reflect.RecordComponent; import java.util.List; -import java.util.Map; -import java.util.stream.IntStream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -68,88 +46,53 @@ void setUp() { } @Test - void centralLibraryNicknameSearchReturnsBuildingCandidateWithoutPersonalization() { - GlobalSearchRes building = searchResult(11L, "중앙도서관", LocationType.BUILDING, null); - when(searchService.chatbotSearch("중도", 5)).thenReturn(List.of( - chatbotSearchResult(11L, LocationType.BUILDING, "중앙도서관", 11L, null, null))); - - var result = adapter.searchCampus(new SearchCampusToolRequest(" 중도 ", null)); - - assertThat(result.error()).isNull(); - assertThat(result.ambiguous()).isFalse(); - assertThat(result.candidates()).singleElement().satisfies(item -> { - assertThat(item.locationId()).isEqualTo(11L); - assertThat(item.locationType()).isEqualTo(BUILDING); - assertThat(item.name()).isEqualTo("중앙도서관"); - }); - verify(searchService).chatbotSearch("중도", 5); - } - - @Test - void uniqueExactMatchWinsOverPartialMatches() { - when(searchService.chatbotSearch("중앙도서관", 5)).thenReturn(List.of( - chatbotSearchResult(11L, LocationType.BUILDING, "중앙도서관", 11L, null, null), - chatbotSearchResult(12L, LocationType.BUILDING, "중앙도서관 신관", 12L, null, null))); - - var result = adapter.searchCampus(new SearchCampusToolRequest("중앙도서관", 1)); - - assertThat(result.ambiguous()).isFalse(); - assertThat(result.error()).isNull(); - assertThat(result.candidates()).extracting("locationId").containsExactly(11L); - assertThat(result.candidates()).allMatch(item -> item.matchType() - == devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusMatchType.EXACT); - verifyNoInteractions(routeService); - } - - @Test - void multipleExactMatchesRemainAmbiguous() { - when(searchService.chatbotSearch("학생회관", 5)).thenReturn(List.of( - chatbotSearchResult(11L, LocationType.BUILDING, "학생 회관", 11L, null, null), - chatbotSearchResult(12L, LocationType.PLACE, "학생회관", 12L, 12.0, PlaceType.LOUNGE))); + void reusesGlobalSearchOrderAndPassesAnonymousUser() { + doReturn(new GlobalSearchListRes(List.of( + searchResult(11L, "중앙도서관(대학원)", LocationType.BUILDING, null), + searchResult(12L, "중앙도서관(신관)", LocationType.BUILDING, null)))) + .when(searchService).globalSearch("중도", null); - var result = adapter.searchCampus(new SearchCampusToolRequest("학생회관", null)); + var result = adapter.searchCampus(new SearchCampusToolRequest(" 중도 ", 1)); assertThat(result.ambiguous()).isTrue(); - assertThat(result.error().code()).isEqualTo(AMBIGUOUS_LOCATION); - assertThat(result.candidates()).hasSize(2).allMatch(item -> item.matchType() - == devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusMatchType.EXACT); + assertThat(result.candidates()).extracting("locationId").containsExactly(11L, 12L); + verify(searchService).globalSearch("중도", null); } @Test - void multiplePartialMatchesRemainAmbiguous() { - when(searchService.chatbotSearch("학생회관", 5)).thenReturn(List.of( - chatbotSearchResult(11L, LocationType.BUILDING, "학생회관 신관", 11L, null, null), - chatbotSearchResult(12L, LocationType.PLACE, "학생회관 학생식당", 12L, 12.0, PlaceType.CAFETERIA))); + void doesNotReRankGlobalSearchResultsByMatchType() { + doReturn(new GlobalSearchListRes(List.of( + searchResult(21L, "문과대학(서관)", LocationType.BUILDING, null), + searchResult(29L, "문과대학(서관) 129B", LocationType.PLACE, PlaceType.CLASSROOM)))) + .when(searchService).globalSearch("문과대학 서관", null); - var result = adapter.searchCampus(new SearchCampusToolRequest("학생회관", null)); + var result = adapter.searchCampus(new SearchCampusToolRequest("문과대학 서관", null)); + assertThat(result.candidates()).extracting("locationId").containsExactly(21L, 29L); assertThat(result.ambiguous()).isTrue(); - assertThat(result.error().code()).isEqualTo(AMBIGUOUS_LOCATION); - assertThat(result.candidates()).hasSize(2).noneMatch(item -> item.matchType() - == devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusMatchType.EXACT); } @Test - void ignoresModelLimitForAmbiguityAndUsesAtMostFiveCandidates() { - List candidates = IntStream.rangeClosed(1, 50) - .mapToObj(id -> chatbotSearchResult((long) id, LocationType.BUILDING, - "장소" + id, (long) id, null, null)) - .toList(); - when(searchService.chatbotSearch("장소", 5)).thenReturn(candidates); + void filtersVirtualFacilitiesAndInvalidIdsWhileKeepingGlobalOrder() { + doReturn(new GlobalSearchListRes(List.of( + searchResult(null, "장애인주차장", LocationType.FACILITY, PlaceType.DISABLED_PARKING), + searchResult(31L, "중앙도서관 장애인주차장", LocationType.PLACE, PlaceType.DISABLED_PARKING), + searchResult(32L, "중앙도서관 129B", LocationType.PLACE, PlaceType.CLASSROOM)))) + .when(searchService).globalSearch("시설", null); - var result = adapter.searchCampus(new SearchCampusToolRequest("장소", 1)); + var result = adapter.searchCampus(new SearchCampusToolRequest("시설", 1)); - assertThat(result.candidates()).hasSize(5); - assertThat(result.ambiguous()).isTrue(); - verify(searchService).chatbotSearch("장소", 5); + assertThat(result.candidates()).extracting("locationId").containsExactly(31L, 32L); + assertThat(result.candidates()).allMatch(item -> item.locationId() != null); } @Test - void deduplicatesSameLocationBeforeRanking() { - when(searchService.chatbotSearch("중도", 5)).thenReturn(List.of( - chatbotSearchResult(11L, LocationType.BUILDING, "중앙도서관", 11L, null, null), - chatbotSearchResult(11L, LocationType.BUILDING, "중앙도서관", 11L, null, null), - chatbotSearchResult(12L, LocationType.BUILDING, "중앙도서관 신관", 12L, null, null))); + void deduplicatesSameLocationWithoutMergingDifferentIds() { + GlobalSearchRes first = searchResult(11L, "중앙도서관", LocationType.BUILDING, null); + GlobalSearchRes duplicate = searchResult(11L, "중앙도서관", LocationType.BUILDING, null); + GlobalSearchRes different = searchResult(12L, "중앙도서관(신관)", LocationType.BUILDING, null); + doReturn(new GlobalSearchListRes(List.of(first, duplicate, different))) + .when(searchService).globalSearch("중도", null); var result = adapter.searchCampus(new SearchCampusToolRequest("중도", null)); @@ -158,182 +101,33 @@ void deduplicatesSameLocationBeforeRanking() { } @Test - void uniqueStrongMatchWinsOverUnrelatedPartialMatches() { - when(searchService.chatbotSearch("송현스퀘어 학생회관", 5)).thenReturn(List.of( - chatbotSearchResult(11L, LocationType.BUILDING, "송현스퀘어", 11L, null, null), - chatbotSearchResult(12L, LocationType.BUILDING, "한투스퀘어", 12L, null, null))); - - var result = adapter.searchCampus(new SearchCampusToolRequest("송현스퀘어 학생회관", 1)); - - assertThat(result.candidates()).singleElement().satisfies(item -> { - assertThat(item.locationId()).isEqualTo(11L); - assertThat(item.matchType()).isEqualTo( - devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusMatchType.STRONG); - }); - assertThat(result.ambiguous()).isFalse(); - } - - @Test - void twoStrongMatchesRemainAmbiguous() { - when(searchService.chatbotSearch("송현스퀘어 학생회관", 5)).thenReturn(List.of( - chatbotSearchResult(11L, LocationType.BUILDING, "송현스퀘어", 11L, null, null), - chatbotSearchResult(12L, LocationType.BUILDING, "학생회관", 12L, null, null))); + void keepsAtMostServerSearchLimitAfterGlobalRanking() { + List results = java.util.stream.IntStream.rangeClosed(1, 10) + .mapToObj(id -> searchResult((long) id, "장소" + id, LocationType.BUILDING, null)) + .toList(); + doReturn(new GlobalSearchListRes(results)).when(searchService).globalSearch("장소", null); - var result = adapter.searchCampus(new SearchCampusToolRequest("송현스퀘어 학생회관", null)); + var result = adapter.searchCampus(new SearchCampusToolRequest("장소", 1)); - assertThat(result.candidates()).hasSize(2) - .allMatch(item -> item.matchType() - == devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusMatchType.STRONG); + assertThat(result.candidates()).hasSize(5); assertThat(result.ambiguous()).isTrue(); } @Test - void searchFiltersVirtualFacilityAndEnforcesMaximumLimit() { - GlobalSearchRes virtualFacility = searchResult(null, "화장실", LocationType.FACILITY, PlaceType.TOILET); - List places = IntStream.rangeClosed(1, 12) - .mapToObj(id -> searchResult((long) id, "장소" + id, LocationType.PLACE, PlaceType.CAFE)) - .toList(); - List mixed = new java.util.ArrayList<>(); - mixed.add(virtualFacility); - mixed.addAll(places); - doReturn(places.stream() - .map(item -> chatbotSearchResult(item.getId(), LocationType.PLACE, item.getName(), - item.getBuildingId(), item.getFloor(), item.getPlaceType())) - .toList()).when(searchService).chatbotSearch("시설", 5); - - var result = adapter.searchCampus(new SearchCampusToolRequest("시설", 100)); - - assertThat(result.candidates()).hasSize(5) - .allMatch(item -> item.locationId() != null && item.locationType() == PLACE); - } + void blankSearchDoesNotCallSearchService() { + var result = adapter.searchCampus(new SearchCampusToolRequest(" ", null)); - @Test - void blankSearchAndNoResultsReturnStableErrors() { - assertThat(adapter.searchCampus(new SearchCampusToolRequest(" ", null)).error().code()) - .isEqualTo(INVALID_INPUT); + assertThat(result.error().code()).isEqualTo(devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.INVALID_INPUT); verifyNoInteractions(searchService); - - when(searchService.chatbotSearch("없는 곳", 5)).thenReturn(List.of()); - assertThat(adapter.searchCampus(new SearchCampusToolRequest("없는 곳", null)).error().code()) - .isEqualTo(NOT_FOUND); - } - - @Test - void centralLibraryOpenQuestionUsesBuildingDetailPath() { - SearchBuildingDetailRes detail = mock(SearchBuildingDetailRes.class); - when(detail.getBuildingId()).thenReturn(11L); - when(detail.getName()).thenReturn("고려대학교 중앙도서관"); - when(detail.isOperating()).thenReturn(true); - when(detail.getNextBuildingTime()).thenReturn("22:00"); - when(detail.getWeekdayOperatingTime()).thenReturn("09:00-22:00"); - when(searchService.searchBuildingDetail(null, 11L)).thenReturn(detail); - - var result = adapter.getLocationDetail(new GetLocationDetailToolRequest(BUILDING, 11L)); - - assertThat(result.error()).isNull(); - assertThat(result.location().openNow()).isTrue(); - assertThat(result.location().nextStatusChangeTime()).isEqualTo("22:00"); - verify(searchService).searchBuildingDetail(null, 11L); - verify(searchService, never()).searchPlaceDetail(null, 11L); - } - - @Test - void placeDetailMapsOperatingAndPlugFields() { - SearchPlaceDetailRes detail = mock(SearchPlaceDetailRes.class); - when(detail.getPlaceId()).thenReturn(31L); - when(detail.getBuildingId()).thenReturn(3L); - when(detail.getName()).thenReturn("하나스퀘어 카페"); - when(detail.getPlaceType()).thenReturn(PlaceType.CAFE); - when(detail.isOperating()).thenReturn(false); - when(detail.getNextPlaceTime()).thenReturn("09:00"); - when(detail.isAvailability()).thenReturn(true); - when(detail.isPlugAvailability()).thenReturn(true); - when(detail.getStarAverage()).thenReturn("4.25"); - when(searchService.searchPlaceDetail(null, 31L)).thenReturn(detail); - - var result = adapter.getLocationDetail(new GetLocationDetailToolRequest(PLACE, 31L)); - - assertThat(result.location().openNow()).isFalse(); - assertThat(result.location().available()).isTrue(); - assertThat(result.location().plugAvailable()).isTrue(); - assertThat(result.location().rating()).isEqualTo(4.25); - verify(searchService).searchPlaceDetail(null, 31L); - } - - @Test - void toolDetailOutputHasNoPrivateOrInternalFields() { - assertThat(recordFieldNames(devkor.com.teamcback.domain.chatbot.tool.dto.LocationDetailToolData.class)) - .doesNotContain("imageUrl", "longitude", "latitude", "xCoord", "yCoord", "maskIndex", - "bookmarked", "userId", "categoryColor", "nodeId"); - assertThat(recordFieldNames(devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusItem.class)) - .doesNotContain("imageUrl", "longitude", "latitude", "bookmarked", "userId", "categoryColor"); - } - - @Test - void studentCenterToiletUsesCampusWideTypeQuery() { - SearchPlaceRes toilet = mock(SearchPlaceRes.class); - when(toilet.getId()).thenReturn(21L); - when(toilet.getName()).thenReturn("학생회관 1층 화장실"); - when(toilet.getPlaceType()).thenReturn(PlaceType.TOILET); - when(toilet.getBuildingId()).thenReturn(5L); - when(toilet.getBuildingName()).thenReturn("학생회관"); - when(searchService.searchFacilitiesWithType(PlaceType.TOILET)) - .thenReturn(new SearchFacilityListRes(List.of(toilet))); - - var result = adapter.findFacilities(new FindFacilitiesToolRequest(TOILET, null, null, null)); - - assertThat(result.error()).isNull(); - assertThat(result.facilities()).singleElement().extracting(FacilityToolItem::buildingName) - .isEqualTo("학생회관"); - verify(searchService).searchFacilitiesWithType(PlaceType.TOILET); - } - - @Test - void buildingTypeQueryUsesBuildingFacilityService() { - SearchFacilityRes toilet = mock(SearchFacilityRes.class); - when(toilet.getId()).thenReturn(22L); - when(toilet.getPlaceType()).thenReturn(PlaceType.TOILET); - when(toilet.getBuildingId()).thenReturn(5L); - SearchBuildingFacilityListRes response = mock(SearchBuildingFacilityListRes.class); - when(response.getFacilities()).thenReturn(Map.of(1.0, List.of(toilet))); - when(searchService.searchBuildingFacilityByType(5L, PlaceType.TOILET)).thenReturn(response); - - var result = adapter.findFacilities(new FindFacilitiesToolRequest(TOILET, 5L, null, null)); - - assertThat(result.facilities()).singleElement().extracting(FacilityToolItem::placeId).isEqualTo(22L); - verify(searchService).searchBuildingFacilityByType(5L, PlaceType.TOILET); } @Test - void buildingFloorQueryExcludesNavigationNodes() { - SearchRoomDetailRes room = mock(SearchRoomDetailRes.class); - when(room.getId()).thenReturn(41L); - when(room.getName()).thenReturn("101호"); - when(room.getPlaceType()).thenReturn(PlaceType.CLASSROOM); - SearchFloorInfoRes response = mock(SearchFloorInfoRes.class); - when(response.getRoomList()).thenReturn(List.of(room)); - when(searchService.searchPlaceByBuildingFloor(5L, 1)).thenReturn(response); - - var result = adapter.findFacilities(new FindFacilitiesToolRequest(null, 5L, 1, null)); - - assertThat(result.facilities()).singleElement().satisfies(item -> { - assertThat(item.placeId()).isEqualTo(41L); - assertThat(item.floor()).isEqualTo(1); - }); - verify(searchService).searchPlaceByBuildingFloor(5L, 1); - } + void emptyGlobalSearchMapsToNotFound() { + doReturn(new GlobalSearchListRes(List.of())).when(searchService).globalSearch("없는 곳", null); - @Test - void floorWithoutBuildingIsRejectedAndFacilityMaximumLimitIsEnforced() { - var invalid = adapter.findFacilities(new FindFacilitiesToolRequest(null, null, 1, null)); - assertThat(invalid.error().code()).isEqualTo(INVALID_INPUT); - verifyNoInteractions(searchService); + var result = adapter.searchCampus(new SearchCampusToolRequest("없는 곳", null)); - List facilities = IntStream.range(0, 25).mapToObj(index -> mock(SearchPlaceRes.class)).toList(); - when(searchService.searchFacilitiesWithType(PlaceType.TOILET)) - .thenReturn(new SearchFacilityListRes(facilities)); - var limited = adapter.findFacilities(new FindFacilitiesToolRequest(TOILET, null, null, 100)); - assertThat(limited.facilities()).hasSize(20); + assertThat(result.error().code()).isEqualTo(devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.NOT_FOUND); } private GlobalSearchRes searchResult(Long id, String name, LocationType type, PlaceType placeType) { @@ -342,18 +136,9 @@ private GlobalSearchRes searchResult(Long id, String name, LocationType type, Pl lenient().when(result.getName()).thenReturn(name); lenient().when(result.getLocationType()).thenReturn(type); lenient().when(result.getPlaceType()).thenReturn(placeType); + lenient().when(result.getBuildingId()).thenReturn(type == LocationType.PLACE ? 1L : null); + lenient().when(result.getFloor()).thenReturn(type == LocationType.PLACE ? 1.0 : null); + lenient().when(result.getDetail()).thenReturn(null); return result; } - - private ChatbotSearchCandidate chatbotSearchResult(Long id, LocationType type, String name, - Long buildingId, Double floor, PlaceType placeType) { - return new ChatbotSearchCandidate(id, type, name, buildingId, floor, placeType, null); - } - - private List recordFieldNames(Class type) { - return IntStream.range(0, type.getRecordComponents().length) - .mapToObj(index -> type.getRecordComponents()[index]) - .map(RecordComponent::getName) - .toList(); - } } From 2a3f81e9001777f863f28e8a34ca694adc17508d Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Sat, 26 Sep 2026 23:01:40 +0900 Subject: [PATCH 09/21] fix(chatbot): harden route execution and tool error handling --- .../config/ChatbotToolExecutionConfig.java | 22 +++++ .../chatbot/dto/RouteExecutionTrace.java | 17 ++++ .../domain/chatbot/gateway/LlmGateway.java | 12 ++- .../chatbot/gateway/SpringAiLlmGateway.java | 92 +++++++++++++++++-- .../domain/chatbot/service/ChatService.java | 21 +++++ .../service/ResolvedLocationCollector.java | 74 +++++++++++++++ .../chatbot/tool/CampusChatbotTools.java | 6 +- .../ChatbotToolExecutionConfigTest.java | 24 +++++ .../gateway/SpringAiLlmGatewayTest.java | 50 ++++++++++ .../service/ChatServiceActionTest.java | 24 +++++ 10 files changed, 329 insertions(+), 13 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotToolExecutionConfig.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/RouteExecutionTrace.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/config/ChatbotToolExecutionConfigTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotToolExecutionConfig.java b/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotToolExecutionConfig.java new file mode 100644 index 00000000..a9cdd471 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotToolExecutionConfig.java @@ -0,0 +1,22 @@ +package devkor.com.teamcback.domain.chatbot.config; + +import org.springframework.ai.tool.execution.ToolExecutionException; +import org.springframework.ai.tool.execution.ToolExecutionExceptionProcessor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Keeps Vertex function responses JSON-shaped when a tool callback fails. */ +@Configuration +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class ChatbotToolExecutionConfig { + @Bean + ToolExecutionExceptionProcessor chatbotToolExecutionExceptionProcessor() { + return new ToolExecutionExceptionProcessor() { + @Override + public String process(ToolExecutionException exception) { + return "{\"success\":false,\"error\":\"TOOL_EXECUTION_FAILED\"}"; + } + }; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/RouteExecutionTrace.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/RouteExecutionTrace.java new file mode 100644 index 00000000..f8d3fb00 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/RouteExecutionTrace.java @@ -0,0 +1,17 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolData; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteCondition; +import java.util.List; + +/** Request-local evidence that the textual route tool was executed. */ +public record RouteExecutionTrace( + ResolvedLocation start, + ResolvedLocation end, + List conditions, + FindRouteToolData route, + boolean successful) { + public RouteExecutionTrace { + conditions = conditions == null ? List.of() : List.copyOf(conditions); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java index 32f2a040..ec0c8726 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java @@ -1,6 +1,7 @@ package devkor.com.teamcback.domain.chatbot.gateway; import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation; +import devkor.com.teamcback.domain.chatbot.dto.RouteExecutionTrace; import devkor.com.teamcback.domain.chatbot.dto.SearchResolutionTrace; import java.util.List; @@ -8,13 +9,18 @@ public interface LlmGateway { LlmResult generate(String systemPrompt, List history, String userMessage); record LlmResult(String reply, List resolvedLocations, CompletionStatus completionStatus, - List searchResolutions) { + List searchResolutions, RouteExecutionTrace routeExecution) { public LlmResult(String reply, List resolvedLocations) { - this(reply, resolvedLocations, CompletionStatus.COMPLETE, List.of()); + this(reply, resolvedLocations, CompletionStatus.COMPLETE, List.of(), null); } public LlmResult(String reply, List resolvedLocations, CompletionStatus completionStatus) { - this(reply, resolvedLocations, completionStatus, List.of()); + this(reply, resolvedLocations, completionStatus, List.of(), null); + } + + public LlmResult(String reply, List resolvedLocations, CompletionStatus completionStatus, + List searchResolutions) { + this(reply, resolvedLocations, completionStatus, searchResolutions, null); } public LlmResult { diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java index 89111371..987ea989 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java @@ -21,6 +21,7 @@ import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.messages.ToolResponseMessage; import org.springframework.ai.chat.metadata.ChatGenerationMetadata; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; @@ -72,11 +73,12 @@ public LlmResult generate(String systemPrompt, List history throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); } catch (TimeoutException exception) { response.cancel(true); - if (collector.hasNavigateIntent()) { - logFailure("NAVIGATE_ROUTE_FAIL_SAFE", "ASYNC_WAIT_AFTER_TOOL_EXECUTION", exception, true); + if (collector.hasNavigateIntent() || collector.hasRouteExecution()) { + logFailure(collector.hasNavigateIntent() ? "NAVIGATE_ROUTE_FAIL_SAFE" : "TEXT_ROUTE_FAIL_SAFE", + "ASYNC_WAIT_AFTER_TOOL_EXECUTION", exception, true); return new LlmResult(null, collector.snapshot(), LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION, - collector.searchResolutionSnapshot()); + collector.searchResolutionSnapshot(), collector.routeExecution()); } logFailure("TEMPORARILY_UNAVAILABLE", "ASYNC_WAIT", exception, true); throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); @@ -96,22 +98,26 @@ private GatewayResult invoke(String systemPrompt, List hist ResolvedLocationCollector collector) { long startedAt = System.nanoTime(); try (ChatbotToolCallLimiter.Scope scope = toolCallLimiter.open()) { + CampusChatbotTools requestTools = campusChatbotTools.forRequest(collector, scope); try { - CampusChatbotTools requestTools = campusChatbotTools.forRequest(collector, scope); + List springMessages = toSpringMessages(history, userMessage); + logRequestMessageDiagnostics(springMessages); ChatClient.CallResponseSpec callResponse = chatClient.prompt() .system(systemPrompt) - .messages(toSpringMessages(history, userMessage)) + .messages(springMessages) .tools(requestTools) .call(); ChatClientResponse clientResponse = callResponse == null ? null : callResponse.chatClientResponse(); ChatResponse chatResponse = clientResponse == null ? null : clientResponse.chatResponse(); + ensureTextRouteExecution(requestTools, collector); String content = extractContent(chatResponse); if (content == null || content.isBlank()) { logEmptyCompletionDiagnostics(chatResponse, scope, collector); throw new EmptyLlmCompletionException(); } return new GatewayResult(new LlmGateway.LlmResult(content, collector.snapshot(), - LlmGateway.CompletionStatus.COMPLETE, collector.searchResolutionSnapshot()), "SUCCESS", + LlmGateway.CompletionStatus.COMPLETE, collector.searchResolutionSnapshot(), + collector.routeExecution()), "SUCCESS", elapsedMillis(startedAt), scope.callCount()); } catch (RuntimeException exception) { if (hasCause(exception, ToolCallLimitExceededException.class)) { @@ -119,13 +125,21 @@ private GatewayResult invoke(String systemPrompt, List hist "TOOL_LIMIT", elapsedMillis(startedAt), scope.callCount()); } + if (collector.hasTextRouteIntent() && !collector.hasRouteExecution()) { + try { + ensureTextRouteExecution(requestTools, collector); + } catch (RuntimeException ignored) { + // Preserve the original provider/tool failure and its stable mapping. + } + } List resolvedLocations = collector.snapshot(); String stage = resolvedLocations.isEmpty() ? "MODEL_TOOL_LOOP" : "POST_TOOL_EXECUTION"; - if (collector.hasNavigateIntent()) { - logFailure("NAVIGATE_ROUTE_FAIL_SAFE", stage, exception, false); + if (collector.hasNavigateIntent() || collector.hasRouteExecution()) { + logFailure(collector.hasNavigateIntent() ? "NAVIGATE_ROUTE_FAIL_SAFE" : "TEXT_ROUTE_FAIL_SAFE", + stage, exception, false); return new GatewayResult(new LlmGateway.LlmResult(null, resolvedLocations, LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION, - collector.searchResolutionSnapshot()), + collector.searchResolutionSnapshot(), collector.routeExecution()), "NAVIGATE_ROUTE_FAIL_SAFE", elapsedMillis(startedAt), scope.callCount()); } throw new StagedLlmInvocationException(stage, exception); @@ -133,6 +147,17 @@ private GatewayResult invoke(String systemPrompt, List hist } } + private void ensureTextRouteExecution(CampusChatbotTools requestTools, + ResolvedLocationCollector collector) { + if (!collector.hasTextRouteIntent() || collector.hasRouteExecution()) { + return; + } + var routeRequest = collector.currentTextRouteRequest(); + if (routeRequest != null) { + requestTools.findRoute(routeRequest); + } + } + private void logEmptyCompletionDiagnostics(ChatResponse response, ChatbotToolCallLimiter.Scope scope, ResolvedLocationCollector collector) { if (!diagnosticsEnabled) { @@ -417,6 +442,55 @@ private List toSpringMessages(List history, String return List.copyOf(messages); } + private void logRequestMessageDiagnostics(List messages) { + if (!diagnosticsEnabled) { + return; + } + for (int index = 0; index < messages.size(); index++) { + final int messageIndex = index; + Message message = messages.get(index); + String toolNames = "[]"; + boolean toolResponse = message instanceof ToolResponseMessage; + if (message instanceof AssistantMessage assistant && assistant.getToolCalls() != null) { + toolNames = assistant.getToolCalls().stream().filter(java.util.Objects::nonNull) + .map(AssistantMessage.ToolCall::name).filter(java.util.Objects::nonNull).toList().toString(); + } + String content = message.getText(); + log.debug("chatbot_llm request_message index={} class={} messageType={} toolResponse={} " + + "toolNames={} contentLength={} contentPrefix={}", index, + message.getClass().getName(), message.getMessageType(), toolResponse, toolNames, + content == null ? 0 : content.length(), safePrefix(content)); + if (message instanceof ToolResponseMessage responseMessage) { + responseMessage.getResponses().forEach(response -> { + String data = response.responseData(); + log.debug("chatbot_llm tool_response index={} toolName={} responseDataLength={} " + + "responseDataJson={} responseDataPrefix={}", messageIndex, response.name(), + data == null ? 0 : data.length(), isJson(data), safePrefix(data)); + }); + } + } + } + + private boolean isJson(String value) { + if (value == null || value.isBlank()) { + return false; + } + try { + new com.fasterxml.jackson.databind.ObjectMapper().readTree(value); + return true; + } catch (com.fasterxml.jackson.core.JsonProcessingException exception) { + return false; + } + } + + private String safePrefix(String value) { + if (value == null || value.isBlank()) { + return ""; + } + String compact = value.replaceAll("[\\r\\n\\t]", " "); + return compact.length() <= 40 ? compact : compact.substring(0, 40) + "..."; + } + private boolean hasCause(Throwable exception, Class type) { Throwable current = exception; while (current != null) { diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java index 9b02a8da..52c8d75a 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java @@ -8,6 +8,7 @@ import devkor.com.teamcback.domain.chatbot.dto.PendingLocationRef; import devkor.com.teamcback.domain.chatbot.dto.PendingRouteState; import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation; +import devkor.com.teamcback.domain.chatbot.dto.RouteExecutionTrace; import devkor.com.teamcback.domain.chatbot.dto.SearchResolutionTrace; import devkor.com.teamcback.domain.chatbot.dto.request.ChatMessageReq; import devkor.com.teamcback.domain.chatbot.dto.request.CurrentLocationReq; @@ -97,6 +98,11 @@ public ChatMessageRes sendMessage(ChatMessageReq request, ChatCaller caller) { boolean completionFailed = result.completionStatus() == LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION || result.reply() == null || result.reply().isBlank(); if (completionFailed && action == null) { + String routeFallback = deterministicTextRouteReply(result); + if (routeFallback != null) { + memoryService.save(sessionId, caller, request.message(), routeFallback); + return new ChatMessageRes(sessionId, routeFallback, null); + } String clarification = deterministicClarification(result.searchResolutions()); if (clarification != null) { memoryService.save(sessionId, caller, request.message(), clarification); @@ -109,6 +115,21 @@ public ChatMessageRes sendMessage(ChatMessageReq request, ChatCaller caller) { return new ChatMessageRes(sessionId, reply, action); } + private String deterministicTextRouteReply(LlmGateway.LlmResult result) { + RouteExecutionTrace trace = result.routeExecution(); + if (trace == null || !trace.successful() || trace.start() == null || trace.end() == null + || trace.route() == null || trace.start().intent() != ResolvedLocation.RouteIntent.TEXT_ROUTE + || trace.end().intent() != ResolvedLocation.RouteIntent.TEXT_ROUTE) { + return null; + } + StringBuilder reply = new StringBuilder(trace.start().name()).append("에서 ") + .append(trace.end().name()).append("까지 경로를 찾았습니다."); + if (trace.route().estimatedDurationSeconds() != null) { + reply.append(" 예상 소요 시간은 ").append(trace.route().estimatedDurationSeconds()).append("초입니다."); + } + return reply.toString(); + } + private PendingRouteState loadPending(UUID sessionId, ChatCaller caller) { return pendingRouteStateService == null ? null : pendingRouteStateService.load(sessionId, caller).orElse(null); diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ResolvedLocationCollector.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ResolvedLocationCollector.java index 6aa99313..ade32abd 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ResolvedLocationCollector.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ResolvedLocationCollector.java @@ -3,9 +3,14 @@ import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation; import devkor.com.teamcback.domain.chatbot.dto.PendingLocationRef; import devkor.com.teamcback.domain.chatbot.dto.SearchResolutionTrace; +import devkor.com.teamcback.domain.chatbot.dto.RouteExecutionTrace; import devkor.com.teamcback.domain.chatbot.tool.dto.RouteEndpointType; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteCondition; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteEndpoint; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -16,6 +21,7 @@ public final class ResolvedLocationCollector { private final Map locations = Collections.synchronizedMap(new LinkedHashMap<>()); private final List searchResolutions = Collections.synchronizedList(new ArrayList<>()); + private RouteExecutionTrace routeExecution; public void record(SearchCampusToolRequest request, SearchCampusToolResult result) { if (request == null || request.role() == null || request.intent() == null || result == null) { @@ -70,9 +76,77 @@ public boolean hasNavigateIntent() { } } + public boolean hasTextRouteIntent() { + synchronized (searchResolutions) { + return searchResolutions.stream().anyMatch(trace -> trace.intent() + == devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusIntent.TEXT_ROUTE); + } + } + + public boolean hasRouteExecution() { + return routeExecution != null; + } + + public RouteExecutionTrace routeExecution() { + return routeExecution; + } + + /** Builds a route request only from unique current-request search results. */ + public synchronized FindRouteToolRequest currentTextRouteRequest() { + if (!hasTextRouteIntent()) { + return null; + } + ResolvedLocation start = unique(devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation.EndpointRole.START, + devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation.RouteIntent.TEXT_ROUTE); + ResolvedLocation end = unique(devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation.EndpointRole.END, + devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation.RouteIntent.TEXT_ROUTE); + if (!validEndpoint(start) || !validEndpoint(end) + || (start.type() == end.type() && start.id().equals(end.id()))) { + return null; + } + java.util.LinkedHashSet conditions = new java.util.LinkedHashSet<>(); + conditions.addAll(start.conditions()); + conditions.addAll(end.conditions()); + return new FindRouteToolRequest( + new RouteEndpoint(start.type(), start.id(), null, null), + new RouteEndpoint(end.type(), end.id(), null, null), + conditions.stream().toList()); + } + + public synchronized void recordRouteExecution(FindRouteToolRequest request, FindRouteToolResult result) { + FindRouteToolRequest expected = currentTextRouteRequest(); + if (expected == null || request == null || result == null || !sameEndpoint(expected.start(), request.start()) + || !sameEndpoint(expected.end(), request.end())) { + return; + } + ResolvedLocation start = unique(ResolvedLocation.EndpointRole.START, ResolvedLocation.RouteIntent.TEXT_ROUTE); + ResolvedLocation end = unique(ResolvedLocation.EndpointRole.END, ResolvedLocation.RouteIntent.TEXT_ROUTE); + boolean successful = result.error() == null && result.route() != null; + routeExecution = new RouteExecutionTrace(start, end, expected.conditions(), result.route(), successful); + } + public List searchResolutionSnapshot() { synchronized (searchResolutions) { return List.copyOf(searchResolutions); } } + + private ResolvedLocation unique(ResolvedLocation.EndpointRole role, ResolvedLocation.RouteIntent intent) { + List matches; + synchronized (locations) { + matches = locations.values().stream().filter(location -> location.role() == role + && location.intent() == intent).toList(); + } + return matches.size() == 1 ? matches.get(0) : null; + } + + private boolean validEndpoint(ResolvedLocation location) { + return location != null && location.id() != null && location.id() > 0 + && (location.type() == RouteEndpointType.BUILDING || location.type() == RouteEndpointType.PLACE); + } + + private boolean sameEndpoint(RouteEndpoint expected, RouteEndpoint actual) { + return expected != null && actual != null && expected.type() == actual.type() + && java.util.Objects.equals(expected.locationId(), actual.locationId()); + } } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java index 9ec8bad7..542cf3c4 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java @@ -109,7 +109,11 @@ public FindRouteToolResult findRoute( devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolError.of( devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.UNSUPPORTED)); } - return adapter.findRoute(request); + FindRouteToolResult result = adapter.findRoute(request); + if (resolvedLocationCollector != null) { + resolvedLocationCollector.recordRouteExecution(request, result); + } + return result; } @Tool(name = "getCafeteriaMenu", description = "Get a cafeteria meal menu for a resolved PLACE ID and date range. " diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/config/ChatbotToolExecutionConfigTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/config/ChatbotToolExecutionConfigTest.java new file mode 100644 index 00000000..d20767ed --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/config/ChatbotToolExecutionConfigTest.java @@ -0,0 +1,24 @@ +package devkor.com.teamcback.domain.chatbot.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.execution.ToolExecutionException; + +class ChatbotToolExecutionConfigTest { + @Test + void convertsToolExecutionFailureToValidJsonObject() throws Exception { + ToolDefinition definition = ToolDefinition.builder() + .name("searchCampus").description("test").inputSchema("{}").build(); + ToolExecutionException exception = new ToolExecutionException(definition, + new IllegalArgumentException("Cannot bind tool arguments")); + + String response = new ChatbotToolExecutionConfig() + .chatbotToolExecutionExceptionProcessor().process(exception); + + assertThat(response).isEqualTo("{\"success\":false,\"error\":\"TOOL_EXECUTION_FAILED\"}"); + assertThat(com.fasterxml.jackson.databind.json.JsonMapper.builder().build() + .readTree(response).get("success").asBoolean()).isFalse(); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java index 98f35a72..c313c792 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java @@ -21,6 +21,11 @@ import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType; +import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolData; +import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteStep; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteEndpointType; +import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation; import devkor.com.teamcback.global.exception.exception.GlobalException; import java.util.List; import java.util.concurrent.ExecutorService; @@ -145,6 +150,51 @@ void preservesNavigateTraceWhenFinalModelFollowUpFails() { .containsExactly(11L, 22L); } + @Test + void executesTextRouteWhenModelStopsAfterResolvingBothEndpoints() { + ChatClient chatClient = mock(ChatClient.class); + ChatClient.Builder builder = mock(ChatClient.Builder.class); + ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class); + ChatClient.CallResponseSpec responseSpec = mock(ChatClient.CallResponseSpec.class); + CampusToolAdapter adapter = mock(CampusToolAdapter.class); + ChatbotProperties properties = properties(); + ChatbotToolCallLimiter limiter = new ChatbotToolCallLimiter(properties); + CampusChatbotTools baseTools = new CampusChatbotTools(adapter, limiter); + AtomicReference requestTools = new AtomicReference<>(); + SearchCampusToolRequest startRequest = new SearchCampusToolRequest("start", 1, + SearchCampusRole.START, SearchCampusIntent.TEXT_ROUTE, List.of()); + SearchCampusToolRequest endRequest = new SearchCampusToolRequest("end", 1, + SearchCampusRole.END, SearchCampusIntent.TEXT_ROUTE, List.of()); + when(builder.build()).thenReturn(chatClient); + when(chatClient.prompt()).thenReturn(requestSpec); + when(requestSpec.system("system")).thenReturn(requestSpec); + when(requestSpec.messages(anyList())).thenReturn(requestSpec); + when(requestSpec.tools(any(CampusChatbotTools.class))).thenAnswer(invocation -> { + requestTools.set(invocation.getArgument(0)); + return requestSpec; + }); + when(requestSpec.call()).thenReturn(responseSpec); + when(adapter.searchCampus(startRequest)).thenReturn(resolved(11L, "start")); + when(adapter.searchCampus(endRequest)).thenReturn(resolved(22L, "end")); + when(adapter.findRoute(any())).thenReturn(new FindRouteToolResult( + new FindRouteToolData(120L, List.of()), null)); + when(responseSpec.chatClientResponse()).thenAnswer(invocation -> { + requestTools.get().searchCampus(startRequest); + requestTools.get().searchCampus(endRequest); + return new ChatClientResponse( + new ChatResponse(List.of(new Generation(new AssistantMessage("")))), java.util.Map.of()); + }); + SpringAiLlmGateway gateway = new SpringAiLlmGateway(builder, properties, executor, baseTools, limiter); + + LlmGateway.LlmResult result = gateway.generate("system", List.of(), "text route"); + + assertThat(result.completionStatus()).isEqualTo(LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION); + assertThat(result.routeExecution()).isNotNull(); + assertThat(result.routeExecution().successful()).isTrue(); + assertThat(result.routeExecution().route().estimatedDurationSeconds()).isEqualTo(120L); + verify(adapter).findRoute(any()); + } + private SearchCampusToolResult resolved(long id, String name) { return new SearchCampusToolResult(List.of(new SearchCampusItem( id, ToolLocationType.BUILDING, name, id, name, null, null, null, diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java index 5fc49bf6..e77c4e7a 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java @@ -12,6 +12,8 @@ import devkor.com.teamcback.domain.chatbot.dto.PendingRouteState; import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation; import devkor.com.teamcback.domain.chatbot.dto.SearchResolutionTrace; +import devkor.com.teamcback.domain.chatbot.dto.RouteExecutionTrace; +import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolData; import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation.EndpointRole; import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation.RouteIntent; import devkor.com.teamcback.domain.chatbot.dto.request.ChatMessageReq; @@ -247,4 +249,26 @@ void keepsTextRouteCompletionFailureAsError() { assertThatThrownBy(() -> service.sendMessage(new ChatMessageReq(sessionId, "text route", null), caller)) .isInstanceOf(devkor.com.teamcback.global.exception.exception.GlobalException.class); } + + @Test + void returnsDeterministicTextRouteReplyWhenFinalCompletionIsBlank() { + ResolvedLocation start = new ResolvedLocation(EndpointRole.START, RouteEndpointType.BUILDING, 123L, + "start", RouteIntent.TEXT_ROUTE, List.of()); + ResolvedLocation end = new ResolvedLocation(EndpointRole.END, RouteEndpointType.PLACE, 456L, + "end", RouteIntent.TEXT_ROUTE, List.of()); + RouteExecutionTrace execution = new RouteExecutionTrace(start, end, List.of(), + new FindRouteToolData(120L, List.of()), true); + when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( + null, List.of(start, end), LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION, + List.of(), execution)); + ChatService service = new ChatService(llmGateway, memoryService, rateLimiter, pendingRouteStateService); + UUID sessionId = UUID.randomUUID(); + ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); + when(memoryService.load(sessionId, caller)).thenReturn(List.of()); + + var response = service.sendMessage(new ChatMessageReq(sessionId, "몇 분 걸려?", null), caller); + + assertThat(response.action()).isNull(); + assertThat(response.reply()).isEqualTo("start에서 end까지 경로를 찾았습니다. 예상 소요 시간은 120초입니다."); + } } From ce19ef36b6355bf32840652f5fe6761ab3fda7d3 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Sun, 27 Sep 2026 14:11:26 +0900 Subject: [PATCH 10/21] refactor(chatbot): separate orchestration from LLM gateway --- .../domain/chatbot/gateway/LlmGateway.java | 25 +--- .../chatbot/gateway/SpringAiLlmGateway.java | 74 +++------- .../service/ChatOrchestrationResult.java | 44 ++++++ .../chatbot/service/ChatOrchestrator.java | 67 +++++++++ .../domain/chatbot/service/ChatService.java | 40 ++--- .../service/ResolvedLocationCollector.java | 8 +- .../controller/ChatControllerTest.java | 21 +-- .../gateway/SpringAiLlmGatewayTest.java | 67 ++------- .../chatbot/service/ChatOrchestratorTest.java | 139 ++++++++++++++++++ .../service/ChatServiceActionTest.java | 51 +++---- .../service/ChatServiceArchitectureTest.java | 7 +- .../ChatServiceCurrentLocationTest.java | 18 +-- 12 files changed, 354 insertions(+), 207 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestrationResult.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestrator.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestratorTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java index ec0c8726..c3df605e 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java @@ -1,32 +1,19 @@ package devkor.com.teamcback.domain.chatbot.gateway; -import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation; -import devkor.com.teamcback.domain.chatbot.dto.RouteExecutionTrace; -import devkor.com.teamcback.domain.chatbot.dto.SearchResolutionTrace; +import devkor.com.teamcback.domain.chatbot.service.ResolvedLocationCollector; import java.util.List; public interface LlmGateway { - LlmResult generate(String systemPrompt, List history, String userMessage); + LlmResult generate(String systemPrompt, List history, String userMessage, + ResolvedLocationCollector executionState); - record LlmResult(String reply, List resolvedLocations, CompletionStatus completionStatus, - List searchResolutions, RouteExecutionTrace routeExecution) { - public LlmResult(String reply, List resolvedLocations) { - this(reply, resolvedLocations, CompletionStatus.COMPLETE, List.of(), null); - } - - public LlmResult(String reply, List resolvedLocations, CompletionStatus completionStatus) { - this(reply, resolvedLocations, completionStatus, List.of(), null); - } - - public LlmResult(String reply, List resolvedLocations, CompletionStatus completionStatus, - List searchResolutions) { - this(reply, resolvedLocations, completionStatus, searchResolutions, null); + record LlmResult(String reply, CompletionStatus completionStatus) { + public LlmResult(String reply) { + this(reply, CompletionStatus.COMPLETE); } public LlmResult { - resolvedLocations = resolvedLocations == null ? List.of() : List.copyOf(resolvedLocations); completionStatus = completionStatus == null ? CompletionStatus.COMPLETE : completionStatus; - searchResolutions = searchResolutions == null ? List.of() : List.copyOf(searchResolutions); } } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java index 987ea989..d5e7d294 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java @@ -3,7 +3,6 @@ import static devkor.com.teamcback.global.response.ResultCode.CHATBOT_TEMPORARILY_UNAVAILABLE; import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; -import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation; import devkor.com.teamcback.domain.chatbot.service.ResolvedLocationCollector; import devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter; import devkor.com.teamcback.domain.chatbot.service.ToolCallLimitExceededException; @@ -56,10 +55,10 @@ public SpringAiLlmGateway(ChatClient.Builder chatClientBuilder, ChatbotPropertie } @Override - public LlmResult generate(String systemPrompt, List history, String userMessage) { - ResolvedLocationCollector collector = new ResolvedLocationCollector(); + public LlmResult generate(String systemPrompt, List history, String userMessage, + ResolvedLocationCollector executionState) { Future response = chatbotLlmExecutor.submit( - () -> invoke(systemPrompt, history, userMessage, collector)); + () -> invoke(systemPrompt, history, userMessage, executionState)); try { GatewayResult result = response.get(properties.llm().timeout().toMillis(), TimeUnit.MILLISECONDS); log.info("chatbot_llm outcome={} provider={} model={} latencyMs={} toolCalls={} inputTokens=unavailable outputTokens=unavailable", @@ -73,12 +72,9 @@ public LlmResult generate(String systemPrompt, List history throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); } catch (TimeoutException exception) { response.cancel(true); - if (collector.hasNavigateIntent() || collector.hasRouteExecution()) { - logFailure(collector.hasNavigateIntent() ? "NAVIGATE_ROUTE_FAIL_SAFE" : "TEXT_ROUTE_FAIL_SAFE", - "ASYNC_WAIT_AFTER_TOOL_EXECUTION", exception, true); - return new LlmResult(null, collector.snapshot(), - LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION, - collector.searchResolutionSnapshot(), collector.routeExecution()); + if (executionState.hasRecordedToolActivity()) { + logFailure("TOOL_PROGRESS_FAIL_SAFE", "ASYNC_WAIT_AFTER_TOOL_EXECUTION", exception, true); + return new LlmResult(null, LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION); } logFailure("TEMPORARILY_UNAVAILABLE", "ASYNC_WAIT", exception, true); throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); @@ -87,7 +83,7 @@ public LlmResult generate(String systemPrompt, List history if (hasCause(exception, ToolCallLimitExceededException.class)) { log.info("chatbot_llm outcome=TOOL_LIMIT provider={} model={}", properties.llm().provider(), properties.llm().model()); - return new LlmResult(TOOL_LIMIT_FALLBACK, List.of()); + return new LlmResult(TOOL_LIMIT_FALLBACK); } logFailure("TEMPORARILY_UNAVAILABLE", failureStage(exception), exception, false); throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); @@ -109,55 +105,31 @@ private GatewayResult invoke(String systemPrompt, List hist .call(); ChatClientResponse clientResponse = callResponse == null ? null : callResponse.chatClientResponse(); ChatResponse chatResponse = clientResponse == null ? null : clientResponse.chatResponse(); - ensureTextRouteExecution(requestTools, collector); String content = extractContent(chatResponse); if (content == null || content.isBlank()) { logEmptyCompletionDiagnostics(chatResponse, scope, collector); throw new EmptyLlmCompletionException(); } - return new GatewayResult(new LlmGateway.LlmResult(content, collector.snapshot(), - LlmGateway.CompletionStatus.COMPLETE, collector.searchResolutionSnapshot(), - collector.routeExecution()), "SUCCESS", + return new GatewayResult(new LlmGateway.LlmResult(content), "SUCCESS", elapsedMillis(startedAt), scope.callCount()); } catch (RuntimeException exception) { if (hasCause(exception, ToolCallLimitExceededException.class)) { - return new GatewayResult(new LlmGateway.LlmResult(TOOL_LIMIT_FALLBACK, java.util.List.of()), + return new GatewayResult(new LlmGateway.LlmResult(TOOL_LIMIT_FALLBACK), "TOOL_LIMIT", elapsedMillis(startedAt), scope.callCount()); } - if (collector.hasTextRouteIntent() && !collector.hasRouteExecution()) { - try { - ensureTextRouteExecution(requestTools, collector); - } catch (RuntimeException ignored) { - // Preserve the original provider/tool failure and its stable mapping. - } - } - List resolvedLocations = collector.snapshot(); - String stage = resolvedLocations.isEmpty() ? "MODEL_TOOL_LOOP" : "POST_TOOL_EXECUTION"; - if (collector.hasNavigateIntent() || collector.hasRouteExecution()) { - logFailure(collector.hasNavigateIntent() ? "NAVIGATE_ROUTE_FAIL_SAFE" : "TEXT_ROUTE_FAIL_SAFE", - stage, exception, false); - return new GatewayResult(new LlmGateway.LlmResult(null, resolvedLocations, - LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION, - collector.searchResolutionSnapshot(), collector.routeExecution()), - "NAVIGATE_ROUTE_FAIL_SAFE", elapsedMillis(startedAt), scope.callCount()); + String stage = collector.hasRecordedToolActivity() ? "POST_TOOL_EXECUTION" : "MODEL_TOOL_LOOP"; + if (collector.hasRecordedToolActivity()) { + logFailure("TOOL_PROGRESS_FAIL_SAFE", stage, exception, false); + return new GatewayResult(new LlmGateway.LlmResult(null, + LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION), + "TOOL_PROGRESS_FAIL_SAFE", elapsedMillis(startedAt), scope.callCount()); } throw new StagedLlmInvocationException(stage, exception); } } } - private void ensureTextRouteExecution(CampusChatbotTools requestTools, - ResolvedLocationCollector collector) { - if (!collector.hasTextRouteIntent() || collector.hasRouteExecution()) { - return; - } - var routeRequest = collector.currentTextRouteRequest(); - if (routeRequest != null) { - requestTools.findRoute(routeRequest); - } - } - private void logEmptyCompletionDiagnostics(ChatResponse response, ChatbotToolCallLimiter.Scope scope, ResolvedLocationCollector collector) { if (!diagnosticsEnabled) { @@ -167,10 +139,8 @@ private void logEmptyCompletionDiagnostics(ChatResponse response, ChatbotToolCal log.error("chatbot_llm empty_completion responseNull=true generationCount=0 " + "toolCallCount=0 toolCallNames=[] toolCallsPresent=false " + "textState=unavailable finishReasons=[] metadataClass=unavailable " - + "toolCalls={} resolvedLocationCount={} searchResolutionCount={} " - + "navigateIntent={} ambiguousTrace={}", - scope.callCount(), collector.snapshot().size(), collector.searchResolutionSnapshot().size(), - collector.hasNavigateIntent(), hasAmbiguousTrace(collector)); + + "toolCalls={} recordedToolActivity={}", + scope.callCount(), collector.hasRecordedToolActivity()); return; } @@ -208,13 +178,11 @@ private void logEmptyCompletionDiagnostics(ChatResponse response, ChatbotToolCal log.error("chatbot_llm empty_completion responseNull=false generationCount={} outputCount={} " + "assistantCount={} blankTextCount={} toolCallCount={} toolCallNames={} " + "toolCallsPresent={} finishReasons={} responseMetadataClass={} " - + "toolCalls={} resolvedLocationCount={} searchResolutionCount={} " - + "navigateIntent={} ambiguousTrace={}", + + "toolCalls={} recordedToolActivity={}", generations.size(), outputCount, assistantCount, blankTextCount, toolNames.size(), toolNames, !toolNames.isEmpty(), finishReasons, response.getMetadata() == null ? "unavailable" : response.getMetadata().getClass().getName(), - scope.callCount(), collector.snapshot().size(), collector.searchResolutionSnapshot().size(), - collector.hasNavigateIntent(), hasAmbiguousTrace(collector)); + scope.callCount(), collector.hasRecordedToolActivity()); } /** Mirrors ChatClient.content() extraction without issuing a second ChatModel call. */ @@ -229,10 +197,6 @@ private String extractContent(ChatResponse response) { return generation.getOutput().getText(); } - private boolean hasAmbiguousTrace(ResolvedLocationCollector collector) { - return collector.searchResolutionSnapshot().stream().anyMatch(trace -> trace.ambiguous()); - } - private void logFailure(String outcome, String stage, Throwable exception, boolean timeout) { Throwable failure = unwrapExecutionException(exception); Throwable rootCause = rootCause(failure); diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestrationResult.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestrationResult.java new file mode 100644 index 00000000..0b00c52f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestrationResult.java @@ -0,0 +1,44 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation; +import devkor.com.teamcback.domain.chatbot.dto.RouteExecutionTrace; +import devkor.com.teamcback.domain.chatbot.dto.SearchResolutionTrace; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import java.util.List; + +/** Provider-neutral result of one request-local chatbot workflow. */ +public record ChatOrchestrationResult( + String reply, + LlmGateway.CompletionStatus completionStatus, + List resolvedLocations, + List searchResolutions, + RouteExecutionTrace routeExecution) { + + public ChatOrchestrationResult(String reply, List resolvedLocations) { + this(reply, LlmGateway.CompletionStatus.COMPLETE, resolvedLocations, List.of(), null); + } + + public ChatOrchestrationResult(String reply, List resolvedLocations, + LlmGateway.CompletionStatus completionStatus) { + this(reply, completionStatus, resolvedLocations, List.of(), null); + } + + public ChatOrchestrationResult(String reply, List resolvedLocations, + LlmGateway.CompletionStatus completionStatus, + List searchResolutions) { + this(reply, completionStatus, resolvedLocations, searchResolutions, null); + } + + public ChatOrchestrationResult(String reply, List resolvedLocations, + LlmGateway.CompletionStatus completionStatus, + List searchResolutions, + RouteExecutionTrace routeExecution) { + this(reply, completionStatus, resolvedLocations, searchResolutions, routeExecution); + } + + public ChatOrchestrationResult { + completionStatus = completionStatus == null ? LlmGateway.CompletionStatus.COMPLETE : completionStatus; + resolvedLocations = resolvedLocations == null ? List.of() : List.copyOf(resolvedLocations); + searchResolutions = searchResolutions == null ? List.of() : List.copyOf(searchResolutions); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestrator.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestrator.java new file mode 100644 index 00000000..f6539085 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestrator.java @@ -0,0 +1,67 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation; +import devkor.com.teamcback.domain.chatbot.dto.RouteExecutionTrace; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import devkor.com.teamcback.domain.chatbot.tool.CampusToolAdapter; +import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolResult; +import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** Owns one message's request-local state and deterministic workflow invariants. */ +@Component +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class ChatOrchestrator { + private final LlmGateway llmGateway; + private final CampusToolAdapter campusToolAdapter; + + public ChatOrchestrator(LlmGateway llmGateway, CampusToolAdapter campusToolAdapter) { + this.llmGateway = llmGateway; + this.campusToolAdapter = campusToolAdapter; + } + + public ChatOrchestrationResult execute(String systemPrompt, List history, + String userMessage) { + ResolvedLocationCollector state = new ResolvedLocationCollector(); + LlmGateway.LlmResult llmResult = llmGateway.generate(systemPrompt, history, userMessage, state); + + ensureTextRouteExecution(state); + String reply = llmResult.reply(); + if ((reply == null || reply.isBlank()) && hasSuccessfulTextRoute(state.routeExecution())) { + reply = deterministicTextRouteReply(state.routeExecution()); + } + + return new ChatOrchestrationResult(reply, llmResult.completionStatus(), state.snapshot(), + state.searchResolutionSnapshot(), state.routeExecution()); + } + + private void ensureTextRouteExecution(ResolvedLocationCollector state) { + if (!state.hasTextRouteIntent() || state.hasRouteExecution()) { + return; + } + FindRouteToolRequest request = state.currentTextRouteRequest(); + if (request == null) { + return; + } + FindRouteToolResult result = campusToolAdapter.findRoute(request); + state.recordRouteExecution(request, result); + } + + private boolean hasSuccessfulTextRoute(RouteExecutionTrace trace) { + return trace != null && trace.successful() && trace.route() != null + && trace.start() != null && trace.end() != null + && trace.start().intent() == ResolvedLocation.RouteIntent.TEXT_ROUTE + && trace.end().intent() == ResolvedLocation.RouteIntent.TEXT_ROUTE; + } + + private String deterministicTextRouteReply(RouteExecutionTrace trace) { + StringBuilder reply = new StringBuilder(trace.start().name()).append("에서 ") + .append(trace.end().name()).append("까지 경로를 찾았습니다."); + if (trace.route().estimatedDurationSeconds() != null) { + reply.append(" 예상 소요 시간은 ").append(trace.route().estimatedDurationSeconds()).append("초입니다."); + } + return reply.toString(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java index 52c8d75a..40ba7bb8 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java @@ -8,7 +8,6 @@ import devkor.com.teamcback.domain.chatbot.dto.PendingLocationRef; import devkor.com.teamcback.domain.chatbot.dto.PendingRouteState; import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation; -import devkor.com.teamcback.domain.chatbot.dto.RouteExecutionTrace; import devkor.com.teamcback.domain.chatbot.dto.SearchResolutionTrace; import devkor.com.teamcback.domain.chatbot.dto.request.ChatMessageReq; import devkor.com.teamcback.domain.chatbot.dto.request.CurrentLocationReq; @@ -63,20 +62,22 @@ NAVIGATE_ROUTE requires searchCampus(query, role=START, intent=NAVIGATE_ROUTE) a findRoute; pending END clarification -> re-search both endpoints; ambiguous END -> ask which candidate. """; - private final LlmGateway llmGateway; + private final ChatOrchestrator chatOrchestrator; private final ChatSessionMemoryService memoryService; private final ChatRateLimiter rateLimiter; private final PendingRouteStateService pendingRouteStateService; /** Compatibility constructor for focused unit tests that do not exercise pending state. */ - public ChatService(LlmGateway llmGateway, ChatSessionMemoryService memoryService, ChatRateLimiter rateLimiter) { - this(llmGateway, memoryService, rateLimiter, null); + public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService memoryService, + ChatRateLimiter rateLimiter) { + this(chatOrchestrator, memoryService, rateLimiter, null); } @Autowired - public ChatService(LlmGateway llmGateway, ChatSessionMemoryService memoryService, ChatRateLimiter rateLimiter, + public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService memoryService, + ChatRateLimiter rateLimiter, PendingRouteStateService pendingRouteStateService) { - this.llmGateway = llmGateway; + this.chatOrchestrator = chatOrchestrator; this.memoryService = memoryService; this.rateLimiter = rateLimiter; this.pendingRouteStateService = pendingRouteStateService; @@ -87,22 +88,16 @@ public ChatMessageRes sendMessage(ChatMessageReq request, ChatCaller caller) { rateLimiter.check(caller); var history = memoryService.load(sessionId, caller); PendingRouteState pending = loadPending(sessionId, caller); - LlmGateway.LlmResult result = llmGateway.generate(promptWithPendingState(pending), toGatewayHistory(history), - messageWithRequestContext(request)); + ChatOrchestrationResult result = chatOrchestrator.execute(promptWithPendingState(pending), + toGatewayHistory(history), messageWithRequestContext(request)); ClientAction action = assembleRouteAction(result.resolvedLocations()); if (action != null) { deletePending(sessionId, caller); } else { savePendingIfRouteIsIncomplete(sessionId, caller, pending, result.searchResolutions()); } - boolean completionFailed = result.completionStatus() == LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION - || result.reply() == null || result.reply().isBlank(); + boolean completionFailed = result.reply() == null || result.reply().isBlank(); if (completionFailed && action == null) { - String routeFallback = deterministicTextRouteReply(result); - if (routeFallback != null) { - memoryService.save(sessionId, caller, request.message(), routeFallback); - return new ChatMessageRes(sessionId, routeFallback, null); - } String clarification = deterministicClarification(result.searchResolutions()); if (clarification != null) { memoryService.save(sessionId, caller, request.message(), clarification); @@ -115,21 +110,6 @@ public ChatMessageRes sendMessage(ChatMessageReq request, ChatCaller caller) { return new ChatMessageRes(sessionId, reply, action); } - private String deterministicTextRouteReply(LlmGateway.LlmResult result) { - RouteExecutionTrace trace = result.routeExecution(); - if (trace == null || !trace.successful() || trace.start() == null || trace.end() == null - || trace.route() == null || trace.start().intent() != ResolvedLocation.RouteIntent.TEXT_ROUTE - || trace.end().intent() != ResolvedLocation.RouteIntent.TEXT_ROUTE) { - return null; - } - StringBuilder reply = new StringBuilder(trace.start().name()).append("에서 ") - .append(trace.end().name()).append("까지 경로를 찾았습니다."); - if (trace.route().estimatedDurationSeconds() != null) { - reply.append(" 예상 소요 시간은 ").append(trace.route().estimatedDurationSeconds()).append("초입니다."); - } - return reply.toString(); - } - private PendingRouteState loadPending(UUID sessionId, ChatCaller caller) { return pendingRouteStateService == null ? null : pendingRouteStateService.load(sessionId, caller).orElse(null); diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ResolvedLocationCollector.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ResolvedLocationCollector.java index ade32abd..a6357a49 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ResolvedLocationCollector.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ResolvedLocationCollector.java @@ -21,7 +21,7 @@ public final class ResolvedLocationCollector { private final Map locations = Collections.synchronizedMap(new LinkedHashMap<>()); private final List searchResolutions = Collections.synchronizedList(new ArrayList<>()); - private RouteExecutionTrace routeExecution; + private volatile RouteExecutionTrace routeExecution; public void record(SearchCampusToolRequest request, SearchCampusToolResult result) { if (request == null || request.role() == null || request.intent() == null || result == null) { @@ -91,6 +91,12 @@ public RouteExecutionTrace routeExecution() { return routeExecution; } + public boolean hasRecordedToolActivity() { + synchronized (searchResolutions) { + return !searchResolutions.isEmpty() || routeExecution != null; + } + } + /** Builds a route request only from unique current-request search results. */ public synchronized FindRouteToolRequest currentTextRouteRequest() { if (!hasTextRouteIntent()) { diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java index c2020bf4..27f8bdf6 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java @@ -15,7 +15,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import devkor.com.teamcback.domain.chatbot.service.ChatOrchestrationResult; +import devkor.com.teamcback.domain.chatbot.service.ChatOrchestrator; import devkor.com.teamcback.domain.chatbot.service.ChatRateLimiter; import devkor.com.teamcback.domain.chatbot.service.ChatService; import devkor.com.teamcback.domain.chatbot.service.ChatSessionMemoryService; @@ -33,14 +34,14 @@ @ExtendWith(MockitoExtension.class) class ChatControllerTest { - @Mock private LlmGateway llmGateway; + @Mock private ChatOrchestrator chatOrchestrator; @Mock private ChatSessionMemoryService memoryService; @Mock private ChatRateLimiter rateLimiter; private MockMvc mockMvc; @BeforeEach void setUp() { - ChatService chatService = new ChatService(llmGateway, memoryService, rateLimiter); + ChatService chatService = new ChatService(chatOrchestrator, memoryService, rateLimiter); lenient().when(memoryService.load(any(), any())).thenReturn(List.of()); mockMvc = MockMvcBuilders.standaloneSetup(new ChatController(chatService)) .setControllerAdvice(new GlobalExceptionHandler()).build(); @@ -51,7 +52,7 @@ void rejectsBlankMessageBeforeLlmCall() throws Exception { mockMvc.perform(post("/api/chatbot/messages").contentType(MediaType.APPLICATION_JSON) .content("{\"message\":\" \"}")) .andExpect(status().isBadRequest()); - verifyNoInteractions(llmGateway); + verifyNoInteractions(chatOrchestrator); } @Test @@ -59,7 +60,7 @@ void rejectsMessageLongerThanOneThousandCharactersBeforeLlmCall() throws Excepti mockMvc.perform(post("/api/chatbot/messages").contentType(MediaType.APPLICATION_JSON) .content("{\"message\":\"" + "a".repeat(1001) + "\"}")) .andExpect(status().isBadRequest()); - verifyNoInteractions(llmGateway); + verifyNoInteractions(chatOrchestrator); } @Test @@ -68,13 +69,13 @@ void rejectsInvalidCoordinatesBeforeLlmCall() throws Exception { .content("{\"message\":\"route\",\"context\":{\"currentLocation\":{" + "\"latitude\":91.0,\"longitude\":-181.0}}}")) .andExpect(status().isBadRequest()); - verifyNoInteractions(llmGateway); + verifyNoInteractions(chatOrchestrator); } @Test void generatesSessionIdAndReturnsGatewayReplyWithoutAction() throws Exception { - when(llmGateway.generate(anyString(), anyList(), anyString())) - .thenReturn(new LlmGateway.LlmResult("hello", List.of())); + when(chatOrchestrator.execute(anyString(), anyList(), anyString())) + .thenReturn(new ChatOrchestrationResult("hello", List.of())); mockMvc.perform(post("/api/chatbot/messages").contentType(MediaType.APPLICATION_JSON) .content("{\"message\":\"hello\"}")) .andExpect(status().isOk()) @@ -91,12 +92,12 @@ void returns429BeforeProviderCallWhenRateLimited() throws Exception { .content("{\"message\":\"hello\"}")) .andExpect(status().isTooManyRequests()) .andExpect(jsonPath("$.statusCode").value(20001)); - verifyNoInteractions(llmGateway); + verifyNoInteractions(chatOrchestrator); } @Test void hidesProviderFailureDetails() throws Exception { - when(llmGateway.generate(anyString(), anyList(), anyString())) + when(chatOrchestrator.execute(anyString(), anyList(), anyString())) .thenThrow(new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE)); mockMvc.perform(post("/api/chatbot/messages").contentType(MediaType.APPLICATION_JSON) .content("{\"message\":\"hello\"}")) diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java index c313c792..901feeb8 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java @@ -12,6 +12,7 @@ import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; import devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter; +import devkor.com.teamcback.domain.chatbot.service.ResolvedLocationCollector; import devkor.com.teamcback.domain.chatbot.tool.CampusChatbotTools; import devkor.com.teamcback.domain.chatbot.tool.CampusToolAdapter; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusIntent; @@ -21,11 +22,6 @@ import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolResult; import devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType; -import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolData; -import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolResult; -import devkor.com.teamcback.domain.chatbot.tool.dto.RouteStep; -import devkor.com.teamcback.domain.chatbot.tool.dto.RouteEndpointType; -import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation; import devkor.com.teamcback.global.exception.exception.GlobalException; import java.util.List; import java.util.concurrent.ExecutorService; @@ -62,7 +58,8 @@ void mapsProviderFailureWithoutExposingProviderDetails() { SpringAiLlmGateway gateway = new SpringAiLlmGateway(builder, properties(), executor, tools, new ChatbotToolCallLimiter(properties())); - assertThatThrownBy(() -> gateway.generate("system", List.of(), "hello")) + assertThatThrownBy(() -> gateway.generate("system", List.of(), "hello", + new ResolvedLocationCollector())) .isInstanceOfSatisfying(GlobalException.class, exception -> { assertThat(exception.getResultCode()).isEqualTo(CHATBOT_TEMPORARILY_UNAVAILABLE); assertThat(exception.getMessage()).isNull(); @@ -92,10 +89,10 @@ void sendsHistoryAsOrderedRoleMessagesAndRegistersRequestLocalTools() { LlmGateway.LlmResult result = gateway.generate("system", List.of( new LlmGateway.ConversationMessage(LlmGateway.Role.USER, "old user"), - new LlmGateway.ConversationMessage(LlmGateway.Role.ASSISTANT, "old assistant")), "current user"); + new LlmGateway.ConversationMessage(LlmGateway.Role.ASSISTANT, "old assistant")), "current user", + new ResolvedLocationCollector()); assertThat(result.reply()).isEqualTo("done"); - assertThat(result.resolvedLocations()).isEmpty(); @SuppressWarnings("unchecked") ArgumentCaptor> messages = ArgumentCaptor.forClass(List.class); verify(requestSpec).messages(messages.capture()); @@ -107,7 +104,7 @@ void sendsHistoryAsOrderedRoleMessagesAndRegistersRequestLocalTools() { } @Test - void preservesNavigateTraceWhenFinalModelFollowUpFails() { + void preservesRequestLocalToolStateWhenFinalModelFollowUpFails() { ChatClient chatClient = mock(ChatClient.class); ChatClient.Builder builder = mock(ChatClient.Builder.class); ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class); @@ -140,61 +137,17 @@ void preservesNavigateTraceWhenFinalModelFollowUpFails() { }); SpringAiLlmGateway gateway = new SpringAiLlmGateway(builder, properties, executor, baseTools, limiter); - LlmGateway.LlmResult result = gateway.generate("system", List.of(), "route request"); + ResolvedLocationCollector executionState = new ResolvedLocationCollector(); + LlmGateway.LlmResult result = gateway.generate("system", List.of(), "route request", executionState); assertThat(result.reply()).isNull(); assertThat(result.completionStatus()) .isEqualTo(LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION); - assertThat(result.resolvedLocations()).hasSize(2); - assertThat(result.resolvedLocations()).extracting(location -> location.id()) + assertThat(executionState.snapshot()).hasSize(2); + assertThat(executionState.snapshot()).extracting(location -> location.id()) .containsExactly(11L, 22L); } - @Test - void executesTextRouteWhenModelStopsAfterResolvingBothEndpoints() { - ChatClient chatClient = mock(ChatClient.class); - ChatClient.Builder builder = mock(ChatClient.Builder.class); - ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class); - ChatClient.CallResponseSpec responseSpec = mock(ChatClient.CallResponseSpec.class); - CampusToolAdapter adapter = mock(CampusToolAdapter.class); - ChatbotProperties properties = properties(); - ChatbotToolCallLimiter limiter = new ChatbotToolCallLimiter(properties); - CampusChatbotTools baseTools = new CampusChatbotTools(adapter, limiter); - AtomicReference requestTools = new AtomicReference<>(); - SearchCampusToolRequest startRequest = new SearchCampusToolRequest("start", 1, - SearchCampusRole.START, SearchCampusIntent.TEXT_ROUTE, List.of()); - SearchCampusToolRequest endRequest = new SearchCampusToolRequest("end", 1, - SearchCampusRole.END, SearchCampusIntent.TEXT_ROUTE, List.of()); - when(builder.build()).thenReturn(chatClient); - when(chatClient.prompt()).thenReturn(requestSpec); - when(requestSpec.system("system")).thenReturn(requestSpec); - when(requestSpec.messages(anyList())).thenReturn(requestSpec); - when(requestSpec.tools(any(CampusChatbotTools.class))).thenAnswer(invocation -> { - requestTools.set(invocation.getArgument(0)); - return requestSpec; - }); - when(requestSpec.call()).thenReturn(responseSpec); - when(adapter.searchCampus(startRequest)).thenReturn(resolved(11L, "start")); - when(adapter.searchCampus(endRequest)).thenReturn(resolved(22L, "end")); - when(adapter.findRoute(any())).thenReturn(new FindRouteToolResult( - new FindRouteToolData(120L, List.of()), null)); - when(responseSpec.chatClientResponse()).thenAnswer(invocation -> { - requestTools.get().searchCampus(startRequest); - requestTools.get().searchCampus(endRequest); - return new ChatClientResponse( - new ChatResponse(List.of(new Generation(new AssistantMessage("")))), java.util.Map.of()); - }); - SpringAiLlmGateway gateway = new SpringAiLlmGateway(builder, properties, executor, baseTools, limiter); - - LlmGateway.LlmResult result = gateway.generate("system", List.of(), "text route"); - - assertThat(result.completionStatus()).isEqualTo(LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION); - assertThat(result.routeExecution()).isNotNull(); - assertThat(result.routeExecution().successful()).isTrue(); - assertThat(result.routeExecution().route().estimatedDurationSeconds()).isEqualTo(120L); - verify(adapter).findRoute(any()); - } - private SearchCampusToolResult resolved(long id, String name) { return new SearchCampusToolResult(List.of(new SearchCampusItem( id, ToolLocationType.BUILDING, name, id, name, null, null, null, diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestratorTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestratorTest.java new file mode 100644 index 00000000..9857bc54 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestratorTest.java @@ -0,0 +1,139 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import devkor.com.teamcback.domain.chatbot.tool.CampusToolAdapter; +import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolData; +import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteCondition; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusIntent; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusItem; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusMatchType; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusRole; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ChatOrchestratorTest { + @Mock LlmGateway llmGateway; + @Mock CampusToolAdapter campusToolAdapter; + + @Test + void keepsModelReplyAndDoesNotDuplicateExistingRouteExecution() { + when(llmGateway.generate(anyString(), anyList(), anyString(), any())).thenAnswer(invocation -> { + ResolvedLocationCollector state = invocation.getArgument(3); + recordResolvedRoute(state, SearchCampusIntent.TEXT_ROUTE); + FindRouteToolRequest request = state.currentTextRouteRequest(); + state.recordRouteExecution(request, successfulRoute(90L)); + return new LlmGateway.LlmResult("모델 경로 답변"); + }); + ChatOrchestrator orchestrator = new ChatOrchestrator(llmGateway, campusToolAdapter); + + ChatOrchestrationResult result = orchestrator.execute("system", List.of(), "route"); + + assertThat(result.reply()).isEqualTo("모델 경로 답변"); + assertThat(result.routeExecution()).isNotNull(); + verify(campusToolAdapter, never()).findRoute(any()); + } + + @Test + void executesMissingTextRouteOnceAfterBothEndpointsResolve() { + when(llmGateway.generate(anyString(), anyList(), anyString(), any())).thenAnswer(invocation -> { + recordResolvedRoute(invocation.getArgument(3), SearchCampusIntent.TEXT_ROUTE); + return new LlmGateway.LlmResult("모델 답변"); + }); + when(campusToolAdapter.findRoute(any())).thenReturn(successfulRoute(120L)); + ChatOrchestrator orchestrator = new ChatOrchestrator(llmGateway, campusToolAdapter); + + ChatOrchestrationResult result = orchestrator.execute("system", List.of(), "route"); + + assertThat(result.reply()).isEqualTo("모델 답변"); + assertThat(result.routeExecution()).isNotNull(); + assertThat(result.routeExecution().successful()).isTrue(); + verify(campusToolAdapter).findRoute(any()); + } + + @Test + void returnsDeterministicReplyFromRouteTraceWhenCompletionIsBlank() { + when(llmGateway.generate(anyString(), anyList(), anyString(), any())).thenAnswer(invocation -> { + recordResolvedRoute(invocation.getArgument(3), SearchCampusIntent.TEXT_ROUTE); + return new LlmGateway.LlmResult(null, LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION); + }); + when(campusToolAdapter.findRoute(any())).thenReturn(successfulRoute(120L)); + ChatOrchestrator orchestrator = new ChatOrchestrator(llmGateway, campusToolAdapter); + + ChatOrchestrationResult result = orchestrator.execute("system", List.of(), "route"); + + assertThat(result.reply()).isEqualTo("start에서 end까지 경로를 찾았습니다. 예상 소요 시간은 120초입니다."); + assertThat(result.completionStatus()).isEqualTo(LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION); + } + + @Test + void doesNotExecuteRouteForAmbiguousTextEndpoint() { + when(llmGateway.generate(anyString(), anyList(), anyString(), any())).thenAnswer(invocation -> { + ResolvedLocationCollector state = invocation.getArgument(3); + state.record(search("start", SearchCampusRole.START, SearchCampusIntent.TEXT_ROUTE), resolved(11L, "start")); + state.record(search("end", SearchCampusRole.END, SearchCampusIntent.TEXT_ROUTE), + new SearchCampusToolResult(List.of(item(22L, "end A"), item(23L, "end B")), true, null)); + return new LlmGateway.LlmResult(null, LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION); + }); + ChatOrchestrator orchestrator = new ChatOrchestrator(llmGateway, campusToolAdapter); + + ChatOrchestrationResult result = orchestrator.execute("system", List.of(), "route"); + + assertThat(result.reply()).isNull(); + assertThat(result.routeExecution()).isNull(); + verify(campusToolAdapter, never()).findRoute(any()); + } + + @Test + void neverExecutesRouteFallbackForNavigateRoute() { + when(llmGateway.generate(anyString(), anyList(), anyString(), any())).thenAnswer(invocation -> { + recordResolvedRoute(invocation.getArgument(3), SearchCampusIntent.NAVIGATE_ROUTE); + return new LlmGateway.LlmResult(null, LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION); + }); + ChatOrchestrator orchestrator = new ChatOrchestrator(llmGateway, campusToolAdapter); + + ChatOrchestrationResult result = orchestrator.execute("system", List.of(), "route"); + + assertThat(result.resolvedLocations()).hasSize(2); + assertThat(result.routeExecution()).isNull(); + verify(campusToolAdapter, never()).findRoute(any()); + } + + private void recordResolvedRoute(ResolvedLocationCollector state, SearchCampusIntent intent) { + state.record(search("start", SearchCampusRole.START, intent), resolved(11L, "start")); + state.record(search("end", SearchCampusRole.END, intent), resolved(22L, "end")); + } + + private SearchCampusToolRequest search(String query, SearchCampusRole role, SearchCampusIntent intent) { + return new SearchCampusToolRequest(query, 5, role, intent, List.of(RouteCondition.BARRIERFREE)); + } + + private SearchCampusToolResult resolved(long id, String name) { + return new SearchCampusToolResult(List.of(item(id, name)), false, null); + } + + private SearchCampusItem item(long id, String name) { + return new SearchCampusItem(id, ToolLocationType.BUILDING, name, id, name, + null, null, null, SearchCampusMatchType.EXACT); + } + + private FindRouteToolResult successfulRoute(long seconds) { + return new FindRouteToolResult(new FindRouteToolData(seconds, List.of()), null); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java index e77c4e7a..1c58bf75 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java @@ -31,20 +31,20 @@ @ExtendWith(MockitoExtension.class) class ChatServiceActionTest { - @Mock LlmGateway llmGateway; + @Mock ChatOrchestrator chatOrchestrator; @Mock ChatSessionMemoryService memoryService; @Mock ChatRateLimiter rateLimiter; @Mock PendingRouteStateService pendingRouteStateService; @Test void assemblesNavigateRouteOnlyFromResolvedToolReferences() { - when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( + when(chatOrchestrator.execute(anyString(), anyList(), anyString())).thenReturn(new ChatOrchestrationResult( "model invented id 999999", List.of( new ResolvedLocation(EndpointRole.START, RouteEndpointType.BUILDING, 123L, "송현스퀘어", RouteIntent.NAVIGATE_ROUTE, List.of(RouteCondition.BARRIERFREE)), new ResolvedLocation(EndpointRole.END, RouteEndpointType.PLACE, 456L, "중앙도서관", RouteIntent.NAVIGATE_ROUTE, List.of())))); - ChatService service = new ChatService(llmGateway, memoryService, rateLimiter); + ChatService service = new ChatService(chatOrchestrator, memoryService, rateLimiter); UUID sessionId = UUID.randomUUID(); ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); when(memoryService.load(sessionId, caller)).thenReturn(List.of()); @@ -60,10 +60,10 @@ void assemblesNavigateRouteOnlyFromResolvedToolReferences() { @Test void doesNotAssembleActionForAmbiguousOrTextRouteTrace() { - when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( + when(chatOrchestrator.execute(anyString(), anyList(), anyString())).thenReturn(new ChatOrchestrationResult( "text route", List.of(new ResolvedLocation(EndpointRole.START, RouteEndpointType.BUILDING, 123L, "학생회관", RouteIntent.TEXT_ROUTE, List.of())))); - ChatService service = new ChatService(llmGateway, memoryService, rateLimiter); + ChatService service = new ChatService(chatOrchestrator, memoryService, rateLimiter); UUID sessionId = UUID.randomUUID(); ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); when(memoryService.load(sessionId, caller)).thenReturn(List.of()); @@ -76,14 +76,14 @@ void doesNotAssembleActionForAmbiguousOrTextRouteTrace() { @Test void returnsValidatedActionWhenFinalModelFollowUpFailed() { - when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( + when(chatOrchestrator.execute(anyString(), anyList(), anyString())).thenReturn(new ChatOrchestrationResult( null, List.of( new ResolvedLocation(EndpointRole.START, RouteEndpointType.BUILDING, 123L, "start", RouteIntent.NAVIGATE_ROUTE, List.of()), new ResolvedLocation(EndpointRole.END, RouteEndpointType.PLACE, 456L, "end", RouteIntent.NAVIGATE_ROUTE, List.of())), LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION)); - ChatService service = new ChatService(llmGateway, memoryService, rateLimiter); + ChatService service = new ChatService(chatOrchestrator, memoryService, rateLimiter); UUID sessionId = UUID.randomUUID(); ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); when(memoryService.load(sessionId, caller)).thenReturn(List.of()); @@ -98,11 +98,11 @@ void returnsValidatedActionWhenFinalModelFollowUpFailed() { @Test void keepsProviderFailureWhenFailedFollowUpHasOnlyOneEndpoint() { - when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( + when(chatOrchestrator.execute(anyString(), anyList(), anyString())).thenReturn(new ChatOrchestrationResult( null, List.of(new ResolvedLocation(EndpointRole.START, RouteEndpointType.BUILDING, 123L, "start", RouteIntent.NAVIGATE_ROUTE, List.of())), LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION)); - ChatService service = new ChatService(llmGateway, memoryService, rateLimiter); + ChatService service = new ChatService(chatOrchestrator, memoryService, rateLimiter); UUID sessionId = UUID.randomUUID(); ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); when(memoryService.load(sessionId, caller)).thenReturn(List.of()); @@ -116,7 +116,7 @@ void keepsProviderFailureWhenFailedFollowUpHasOnlyOneEndpoint() { @Test void storesAmbiguousEndpointAsPendingRouteState() { - when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( + when(chatOrchestrator.execute(anyString(), anyList(), anyString())).thenReturn(new ChatOrchestrationResult( "어느 문과대학 서관인지 알려주세요", List.of( new ResolvedLocation(EndpointRole.START, RouteEndpointType.BUILDING, 123L, "송현스퀘어", RouteIntent.NAVIGATE_ROUTE, List.of())), LlmGateway.CompletionStatus.COMPLETE, @@ -127,7 +127,7 @@ void storesAmbiguousEndpointAsPendingRouteState() { "문과대학 서관", true, List.of(new PendingLocationRef(RouteEndpointType.BUILDING, 201L, "문과대학 서관"), new PendingLocationRef(RouteEndpointType.PLACE, 202L, "문과대학 서관 1층 라운지")), List.of())))); - ChatService service = new ChatService(llmGateway, memoryService, rateLimiter, pendingRouteStateService); + ChatService service = new ChatService(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService); UUID sessionId = UUID.randomUUID(); ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); when(memoryService.load(sessionId, caller)).thenReturn(List.of()); @@ -150,13 +150,13 @@ void createsActionOnlyFromCurrentTurnAfterPendingClarification() { SearchCampusRole.END, List.of(), List.of(new PendingLocationRef(RouteEndpointType.BUILDING, 201L, "old end"))); when(pendingRouteStateService.load(org.mockito.ArgumentMatchers.any(UUID.class), org.mockito.ArgumentMatchers.any())) .thenReturn(java.util.Optional.of(pending)); - when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( + when(chatOrchestrator.execute(anyString(), anyList(), anyString())).thenReturn(new ChatOrchestrationResult( "완료", List.of( new ResolvedLocation(EndpointRole.START, RouteEndpointType.BUILDING, 123L, "new start", RouteIntent.NAVIGATE_ROUTE, List.of()), new ResolvedLocation(EndpointRole.END, RouteEndpointType.PLACE, 456L, "new end", RouteIntent.NAVIGATE_ROUTE, List.of())))); - ChatService service = new ChatService(llmGateway, memoryService, rateLimiter, pendingRouteStateService); + ChatService service = new ChatService(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService); UUID sessionId = UUID.randomUUID(); ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); when(memoryService.load(sessionId, caller)).thenReturn(List.of()); @@ -168,21 +168,21 @@ void createsActionOnlyFromCurrentTurnAfterPendingClarification() { assertThat(response.action().payload().endId()).isEqualTo(456L); verify(pendingRouteStateService).delete(sessionId, caller); org.mockito.ArgumentCaptor prompt = org.mockito.ArgumentCaptor.forClass(String.class); - verify(llmGateway).generate(prompt.capture(), anyList(), eq("1층 라운지로 해줘")); + verify(chatOrchestrator).execute(prompt.capture(), anyList(), eq("1층 라운지로 해줘")); assertThat(prompt.getValue()).contains("PENDING_ROUTE_CONTINUATION", "interaction=NAVIGATE_ROUTE", "unresolvedRole=END", "re-search both endpoints", "never reuse an old ID"); } @Test void returnsDeterministicClarificationWhenEndIsAmbiguousAndCompletionIsBlank() { - when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( + when(chatOrchestrator.execute(anyString(), anyList(), anyString())).thenReturn(new ChatOrchestrationResult( null, List.of(new ResolvedLocation(EndpointRole.START, RouteEndpointType.BUILDING, 123L, "송현스퀘어", RouteIntent.NAVIGATE_ROUTE, List.of())), LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION, List.of(new SearchResolutionTrace(SearchCampusRole.END, SearchCampusIntent.NAVIGATE_ROUTE, "중앙도서관", true, List.of(new PendingLocationRef(RouteEndpointType.BUILDING, 201L, "중앙도서관(신관)"), new PendingLocationRef(RouteEndpointType.PLACE, 202L, "중앙도서관(대학원)")), List.of())))); - ChatService service = new ChatService(llmGateway, memoryService, rateLimiter, pendingRouteStateService); + ChatService service = new ChatService(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService); UUID sessionId = UUID.randomUUID(); ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); when(memoryService.load(sessionId, caller)).thenReturn(List.of()); @@ -198,13 +198,13 @@ void returnsDeterministicClarificationWhenEndIsAmbiguousAndCompletionIsBlank() { @Test void returnsDeterministicClarificationWhenStartIsAmbiguousAndCompletionIsBlank() { - when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( + when(chatOrchestrator.execute(anyString(), anyList(), anyString())).thenReturn(new ChatOrchestrationResult( "", List.of(new ResolvedLocation(EndpointRole.END, RouteEndpointType.BUILDING, 456L, "중앙도서관", RouteIntent.NAVIGATE_ROUTE, List.of())), LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION, List.of(new SearchResolutionTrace(SearchCampusRole.START, SearchCampusIntent.NAVIGATE_ROUTE, "학생회관", true, List.of(new PendingLocationRef(RouteEndpointType.BUILDING, 101L, "학생회관 본관")), List.of())))); - ChatService service = new ChatService(llmGateway, memoryService, rateLimiter, pendingRouteStateService); + ChatService service = new ChatService(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService); UUID sessionId = UUID.randomUUID(); ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); when(memoryService.load(sessionId, caller)).thenReturn(List.of()); @@ -218,14 +218,14 @@ void returnsDeterministicClarificationWhenStartIsAmbiguousAndCompletionIsBlank() @Test void deduplicatesSameActualCandidateInDeterministicClarification() { - when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( + when(chatOrchestrator.execute(anyString(), anyList(), anyString())).thenReturn(new ChatOrchestrationResult( null, List.of(), LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION, List.of(new SearchResolutionTrace(SearchCampusRole.START, SearchCampusIntent.NAVIGATE_ROUTE, "중앙도서관", true, List.of(new PendingLocationRef(RouteEndpointType.PLACE, 300L, "야외 중앙도서관 장애인주차장"), new PendingLocationRef(RouteEndpointType.PLACE, 300L, "야외 중앙도서관 장애인주차장"), new PendingLocationRef(RouteEndpointType.PLACE, 301L, "야외 중앙도서관 장애인주차장")), List.of())))); - ChatService service = new ChatService(llmGateway, memoryService, rateLimiter, pendingRouteStateService); + ChatService service = new ChatService(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService); UUID sessionId = UUID.randomUUID(); ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); when(memoryService.load(sessionId, caller)).thenReturn(List.of()); @@ -238,9 +238,9 @@ void deduplicatesSameActualCandidateInDeterministicClarification() { @Test void keepsTextRouteCompletionFailureAsError() { - when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( + when(chatOrchestrator.execute(anyString(), anyList(), anyString())).thenReturn(new ChatOrchestrationResult( null, List.of(), LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION)); - ChatService service = new ChatService(llmGateway, memoryService, rateLimiter, pendingRouteStateService); + ChatService service = new ChatService(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService); UUID sessionId = UUID.randomUUID(); ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); when(memoryService.load(sessionId, caller)).thenReturn(List.of()); @@ -258,10 +258,11 @@ void returnsDeterministicTextRouteReplyWhenFinalCompletionIsBlank() { "end", RouteIntent.TEXT_ROUTE, List.of()); RouteExecutionTrace execution = new RouteExecutionTrace(start, end, List.of(), new FindRouteToolData(120L, List.of()), true); - when(llmGateway.generate(anyString(), anyList(), anyString())).thenReturn(new LlmGateway.LlmResult( - null, List.of(start, end), LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION, + when(chatOrchestrator.execute(anyString(), anyList(), anyString())).thenReturn(new ChatOrchestrationResult( + "start에서 end까지 경로를 찾았습니다. 예상 소요 시간은 120초입니다.", List.of(start, end), + LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION, List.of(), execution)); - ChatService service = new ChatService(llmGateway, memoryService, rateLimiter, pendingRouteStateService); + ChatService service = new ChatService(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService); UUID sessionId = UUID.randomUUID(); ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); when(memoryService.load(sessionId, caller)).thenReturn(List.of()); diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceArchitectureTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceArchitectureTest.java index 271a5554..37cfedc4 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceArchitectureTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceArchitectureTest.java @@ -9,10 +9,15 @@ class ChatServiceArchitectureTest { @Test - void dependsOnLlmGatewayOnlyForLlmAccess() { + void delegatesLlmWorkflowThroughChatOrchestrator() { Field[] fields = ChatService.class.getDeclaredFields(); assertThat(Arrays.stream(fields).map(Field::getType)) + .contains(ChatOrchestrator.class) + .noneMatch(type -> type.getName().startsWith("org.springframework.ai") + || type.getName().startsWith("com.google")); + + assertThat(Arrays.stream(ChatOrchestrator.class.getDeclaredFields()).map(Field::getType)) .contains(LlmGateway.class) .noneMatch(type -> type.getName().startsWith("org.springframework.ai") || type.getName().startsWith("com.google")); diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceCurrentLocationTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceCurrentLocationTest.java index 5141bdcc..d902f23e 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceCurrentLocationTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceCurrentLocationTest.java @@ -20,15 +20,15 @@ @ExtendWith(MockitoExtension.class) class ChatServiceCurrentLocationTest { - @Mock LlmGateway llmGateway; + @Mock ChatOrchestrator chatOrchestrator; @Mock ChatSessionMemoryService memoryService; @Mock ChatRateLimiter rateLimiter; @Test void passesCurrentLocationOnlyInCurrentProviderRequestAndNeverSavesIt() { - when(llmGateway.generate(anyString(), anyList(), anyString())) - .thenReturn(new LlmGateway.LlmResult("route answer", List.of())); - ChatService service = new ChatService(llmGateway, memoryService, rateLimiter); + when(chatOrchestrator.execute(anyString(), anyList(), anyString())) + .thenReturn(new ChatOrchestrationResult("route answer", List.of())); + ChatService service = new ChatService(chatOrchestrator, memoryService, rateLimiter); UUID sessionId = UUID.randomUUID(); ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); when(memoryService.load(sessionId, caller)).thenReturn(List.of()); @@ -38,7 +38,7 @@ void passesCurrentLocationOnlyInCurrentProviderRequestAndNeverSavesIt() { service.sendMessage(new ChatMessageReq(sessionId, "tell me again", null), caller); ArgumentCaptor messages = ArgumentCaptor.forClass(String.class); - verify(llmGateway, org.mockito.Mockito.times(2)).generate(anyString(), anyList(), messages.capture()); + verify(chatOrchestrator, org.mockito.Mockito.times(2)).execute(anyString(), anyList(), messages.capture()); assertThat(messages.getAllValues().get(0)).contains("latitude=37.5861", "longitude=127.029"); assertThat(messages.getAllValues().get(1)).isEqualTo("tell me again") .doesNotContain("37.5861", "127.029", "currentLocation"); @@ -48,9 +48,9 @@ void passesCurrentLocationOnlyInCurrentProviderRequestAndNeverSavesIt() { @Test void preservesRecentRolesAndKeepsCurrentCorrectionAsLatestUserMessage() { - when(llmGateway.generate(anyString(), anyList(), anyString())) - .thenReturn(new LlmGateway.LlmResult("answer", List.of())); - ChatService service = new ChatService(llmGateway, memoryService, rateLimiter); + when(chatOrchestrator.execute(anyString(), anyList(), anyString())) + .thenReturn(new ChatOrchestrationResult("answer", List.of())); + ChatService service = new ChatService(chatOrchestrator, memoryService, rateLimiter); UUID sessionId = UUID.randomUUID(); ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); when(memoryService.load(sessionId, caller)).thenReturn(List.of( @@ -61,7 +61,7 @@ void preservesRecentRolesAndKeepsCurrentCorrectionAsLatestUserMessage() { @SuppressWarnings("unchecked") ArgumentCaptor> history = ArgumentCaptor.forClass(List.class); ArgumentCaptor currentMessage = ArgumentCaptor.forClass(String.class); - verify(llmGateway).generate(anyString(), history.capture(), currentMessage.capture()); + verify(chatOrchestrator).execute(anyString(), history.capture(), currentMessage.capture()); assertThat(history.getValue()).containsExactly( new LlmGateway.ConversationMessage(LlmGateway.Role.USER, "old user"), new LlmGateway.ConversationMessage(LlmGateway.Role.ASSISTANT, "old assistant")); From 4ae814aec40a97dba03a45a67aa39f8d508c5504 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Sun, 27 Sep 2026 14:37:47 +0900 Subject: [PATCH 11/21] refactor(chatbot): make route workflow deterministic --- .../domain/chatbot/dto/RoutePlan.java | 34 ++++++++++ .../domain/chatbot/gateway/LlmGateway.java | 6 ++ .../chatbot/gateway/SpringAiLlmGateway.java | 44 +++++++++++++ .../chatbot/service/ChatOrchestrator.java | 64 +++++++++++++++++++ .../domain/chatbot/service/ChatService.java | 19 ++++-- .../service/ResolvedLocationCollector.java | 10 ++- .../domain/chatbot/service/RoutePlanner.java | 35 ++++++++++ .../gateway/SpringAiLlmGatewayTest.java | 27 ++++++++ .../chatbot/service/ChatOrchestratorTest.java | 59 +++++++++++++++++ .../service/ChatOrchestratorWiringTest.java | 32 ++++++++++ .../chatbot/service/RoutePlannerTest.java | 41 ++++++++++++ 11 files changed, 361 insertions(+), 10 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/RoutePlan.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/RoutePlanner.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestratorWiringTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/service/RoutePlannerTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/RoutePlan.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/RoutePlan.java new file mode 100644 index 00000000..c0dc8c84 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/RoutePlan.java @@ -0,0 +1,34 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteCondition; +import java.util.List; + +/** Provider-neutral plan for the deterministic route workflow. */ +public record RoutePlan( + Intent intent, + String startQuery, + String endQuery, + List conditions) { + + public RoutePlan { + intent = intent == null ? Intent.NOT_ROUTE : intent; + conditions = conditions == null ? List.of() : List.copyOf(conditions); + } + + public static RoutePlan notRoute() { + return new RoutePlan(Intent.NOT_ROUTE, null, null, List.of()); + } + + public boolean isRoute() { + return intent == Intent.TEXT_ROUTE || intent == Intent.NAVIGATE_ROUTE; + } + + public boolean hasEndpointQueries() { + return startQuery != null && !startQuery.isBlank() + && endQuery != null && !endQuery.isBlank(); + } + + public enum Intent { + TEXT_ROUTE, NAVIGATE_ROUTE, NOT_ROUTE + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java index c3df605e..18883662 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java @@ -1,9 +1,15 @@ package devkor.com.teamcback.domain.chatbot.gateway; +import devkor.com.teamcback.domain.chatbot.dto.RoutePlan; import devkor.com.teamcback.domain.chatbot.service.ResolvedLocationCollector; import java.util.List; public interface LlmGateway { + /** Structured route interpretation; provider implementations must not resolve IDs or execute routes. */ + default RoutePlan planRoute(String systemPrompt, List history, String userMessage) { + return RoutePlan.notRoute(); + } + LlmResult generate(String systemPrompt, List history, String userMessage, ResolvedLocationCollector executionState); diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java index d5e7d294..c2964f9f 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java @@ -3,6 +3,7 @@ import static devkor.com.teamcback.global.response.ResultCode.CHATBOT_TEMPORARILY_UNAVAILABLE; import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import devkor.com.teamcback.domain.chatbot.dto.RoutePlan; import devkor.com.teamcback.domain.chatbot.service.ResolvedLocationCollector; import devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter; import devkor.com.teamcback.domain.chatbot.service.ToolCallLimitExceededException; @@ -24,6 +25,7 @@ import org.springframework.ai.chat.metadata.ChatGenerationMetadata; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.converter.BeanOutputConverter; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -54,6 +56,48 @@ public SpringAiLlmGateway(ChatClient.Builder chatClientBuilder, ChatbotPropertie this.toolCallLimiter = toolCallLimiter; } + @Override + public RoutePlan planRoute(String systemPrompt, List history, String userMessage) { + Future response = chatbotLlmExecutor.submit( + () -> invokeRoutePlan(systemPrompt, history, userMessage)); + try { + RoutePlan plan = response.get(properties.llm().timeout().toMillis(), TimeUnit.MILLISECONDS); + return plan == null ? RoutePlan.notRoute() : plan; + } catch (InterruptedException exception) { + response.cancel(true); + Thread.currentThread().interrupt(); + return RoutePlan.notRoute(); + } catch (ExecutionException | TimeoutException exception) { + response.cancel(true); + return RoutePlan.notRoute(); + } + } + + private RoutePlan invokeRoutePlan(String systemPrompt, List history, String userMessage) { + BeanOutputConverter converter = new BeanOutputConverter<>(RoutePlan.class); + String plannerPrompt = """ + Interpret whether the current request is a campus route request. Do not call tools and do not + invent any IDs. Return only the structured RoutePlan requested by the output format. + intent must be TEXT_ROUTE when the user wants route facts, duration, or written directions; + NAVIGATE_ROUTE when the user wants the app route screen/map; otherwise NOT_ROUTE. + For a route, preserve the user's specific natural-language startQuery and endQuery. If a pending + route clarification is present in the system context, complete that plan using the unresolved role. + Supported conditions are only BARRIERFREE, SHUTTLE, STUDENTCARD, and OPERATING. + + Existing assistant policy/context: + %s + + Output format: + %s + """.formatted(systemPrompt == null ? "" : systemPrompt, converter.getFormat()); + ChatClient.CallResponseSpec response = chatClient.prompt() + .system(plannerPrompt) + .messages(toSpringMessages(history, userMessage)) + .call(); + RoutePlan plan = response == null ? null : response.entity(converter); + return plan == null ? RoutePlan.notRoute() : plan; + } + @Override public LlmResult generate(String systemPrompt, List history, String userMessage, ResolvedLocationCollector executionState) { diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestrator.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestrator.java index f6539085..705bca38 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestrator.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestrator.java @@ -1,12 +1,19 @@ package devkor.com.teamcback.domain.chatbot.service; import devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation; +import devkor.com.teamcback.domain.chatbot.dto.RoutePlan; import devkor.com.teamcback.domain.chatbot.dto.RouteExecutionTrace; import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; import devkor.com.teamcback.domain.chatbot.tool.CampusToolAdapter; import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolRequest; import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.RouteCondition; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusIntent; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusRole; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolResult; import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -16,15 +23,27 @@ public class ChatOrchestrator { private final LlmGateway llmGateway; private final CampusToolAdapter campusToolAdapter; + private final RoutePlanner routePlanner; + /** Compatibility constructor for focused tests that exercise the legacy non-route path. */ public ChatOrchestrator(LlmGateway llmGateway, CampusToolAdapter campusToolAdapter) { + this(llmGateway, campusToolAdapter, new RoutePlanner(llmGateway)); + } + + @Autowired + public ChatOrchestrator(LlmGateway llmGateway, CampusToolAdapter campusToolAdapter, RoutePlanner routePlanner) { this.llmGateway = llmGateway; this.campusToolAdapter = campusToolAdapter; + this.routePlanner = routePlanner; } public ChatOrchestrationResult execute(String systemPrompt, List history, String userMessage) { ResolvedLocationCollector state = new ResolvedLocationCollector(); + RoutePlan routePlan = routePlanner.plan(systemPrompt, history, userMessage); + if (routePlan.isRoute()) { + return executeRoutePlan(routePlan, state); + } LlmGateway.LlmResult llmResult = llmGateway.generate(systemPrompt, history, userMessage, state); ensureTextRouteExecution(state); @@ -37,6 +56,51 @@ public ChatOrchestrationResult execute(String systemPrompt, List conditions = plan.conditions(); + resolve(state, plan.startQuery(), SearchCampusRole.START, intent, conditions); + if (!hasUnique(state, SearchCampusRole.START, intent)) { + return new ChatOrchestrationResult(null, List.of(), LlmGateway.CompletionStatus.COMPLETE, + state.searchResolutionSnapshot(), null); + } + resolve(state, plan.endQuery(), SearchCampusRole.END, intent, conditions); + if (!hasUnique(state, SearchCampusRole.END, intent)) { + return new ChatOrchestrationResult(null, state.snapshot(), LlmGateway.CompletionStatus.COMPLETE, + state.searchResolutionSnapshot(), null); + } + + if (plan.intent() == RoutePlan.Intent.TEXT_ROUTE) { + FindRouteToolRequest request = state.currentRouteRequest( + ResolvedLocation.RouteIntent.TEXT_ROUTE); + if (request != null) { + FindRouteToolResult result = campusToolAdapter.findRoute(request); + state.recordRouteExecution(request, result); + String reply = hasSuccessfulTextRoute(state.routeExecution()) + ? deterministicTextRouteReply(state.routeExecution()) : null; + return new ChatOrchestrationResult(reply, state.snapshot(), LlmGateway.CompletionStatus.COMPLETE, + state.searchResolutionSnapshot(), state.routeExecution()); + } + } + return new ChatOrchestrationResult(null, state.snapshot(), LlmGateway.CompletionStatus.COMPLETE, + state.searchResolutionSnapshot(), null); + } + + private void resolve(ResolvedLocationCollector state, String query, SearchCampusRole role, + SearchCampusIntent intent, List conditions) { + SearchCampusToolRequest request = new SearchCampusToolRequest(query, 5, role, intent, conditions); + SearchCampusToolResult result = campusToolAdapter.searchCampus(request); + state.record(request, result); + } + + private boolean hasUnique(ResolvedLocationCollector state, SearchCampusRole role, SearchCampusIntent intent) { + return state.snapshot().stream().filter(location -> location.role() == role.toEndpointRole()) + .anyMatch(location -> (intent == SearchCampusIntent.TEXT_ROUTE + ? location.intent() == ResolvedLocation.RouteIntent.TEXT_ROUTE + : location.intent() == ResolvedLocation.RouteIntent.NAVIGATE_ROUTE)); + } + private void ensureTextRouteExecution(ResolvedLocationCollector state) { if (!state.hasTextRouteIntent() || state.hasRouteExecution()) { return; diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java index 40ba7bb8..5bceb3b4 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java @@ -55,7 +55,7 @@ NAVIGATE_ROUTE requires searchCampus(query, role=START, intent=NAVIGATE_ROUTE) a Ambiguous searchCampus results must not be guessed, merged, or invented. Ask using only actual candidate names and create no Action until unique. If PENDING_ROUTE_CONTINUATION is present and the current message is related, re-search both endpoints in - this request with explicit roles and NAVIGATE_ROUTE; never copy a previous ID. Unrelated requests stay + this request with explicit roles and the pending route intent; never copy a previous ID. Unrelated requests stay unrelated. Use only supported conditions; BARRIERFREE excludes stair nodes where supported and is not a complete accessibility guarantee. Examples: UI route -> START search, END search, no findRoute; text route -> START search, END search, @@ -127,7 +127,7 @@ private String promptWithPendingState(PendingRouteState pending) { } StringBuilder context = new StringBuilder(SYSTEM_PROMPT); context.append("\n\n[PENDING_ROUTE_CONTINUATION]\n") - .append("interaction=NAVIGATE_ROUTE\n") + .append("interaction=").append(pending.interactionType()).append("\n") .append("unresolvedRole=").append(pending.unresolvedRole()).append("\n"); if (pending.resolvedStart() != null) { context.append("resolvedStartName=").append(pending.resolvedStart().name()).append("\n"); @@ -140,8 +140,9 @@ private String promptWithPendingState(PendingRouteState pending) { .append(pending.ambiguousCandidates().stream().map(PendingLocationRef::name) .collect(Collectors.joining(", "))).append("\n"); } + context.append("conditions=").append(pending.conditions()).append("\n"); context.append("Use ROUTE_BEHAVIOR for continuation: if related, re-search both endpoints in this request " - + "with explicit roles and NAVIGATE_ROUTE; never reuse an old ID. If unrelated, handle normally.\n" + + "with explicit roles and the pending route intent; never reuse an old ID. If unrelated, handle normally.\n" + "[/PENDING_ROUTE_CONTINUATION]"); return context.toString(); } @@ -152,7 +153,8 @@ private void savePendingIfRouteIsIncomplete(UUID sessionId, ChatCaller caller, P return; } List routeTraces = traces.stream() - .filter(trace -> trace.intent() == SearchCampusIntent.NAVIGATE_ROUTE).toList(); + .filter(trace -> trace.intent() == SearchCampusIntent.NAVIGATE_ROUTE + || trace.intent() == SearchCampusIntent.TEXT_ROUTE).toList(); if (routeTraces.isEmpty()) { return; } @@ -186,9 +188,11 @@ private void savePendingIfRouteIsIncomplete(UUID sessionId, ChatCaller caller, P if (conditions.isEmpty() && previous != null) { conditions = previous.conditions(); } + ResolvedLocation.RouteIntent interaction = routeTraces.stream() + .anyMatch(trace -> trace.intent() == SearchCampusIntent.TEXT_ROUTE) + ? ResolvedLocation.RouteIntent.TEXT_ROUTE : ResolvedLocation.RouteIntent.NAVIGATE_ROUTE; pendingRouteStateService.save(sessionId, caller, - new PendingRouteState(ResolvedLocation.RouteIntent.NAVIGATE_ROUTE, start, end, unresolved, - conditions, candidates)); + new PendingRouteState(interaction, start, end, unresolved, conditions, candidates)); } private String deterministicClarification(List traces) { @@ -196,7 +200,8 @@ private String deterministicClarification(List traces) { return null; } SearchResolutionTrace ambiguous = traces.stream() - .filter(trace -> trace.intent() == SearchCampusIntent.NAVIGATE_ROUTE) + .filter(trace -> trace.intent() == SearchCampusIntent.NAVIGATE_ROUTE + || trace.intent() == SearchCampusIntent.TEXT_ROUTE) .filter(SearchResolutionTrace::ambiguous) .filter(trace -> !trace.candidates().isEmpty()) .reduce((first, second) -> second) diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ResolvedLocationCollector.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ResolvedLocationCollector.java index a6357a49..7fddffa6 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ResolvedLocationCollector.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ResolvedLocationCollector.java @@ -99,13 +99,17 @@ public boolean hasRecordedToolActivity() { /** Builds a route request only from unique current-request search results. */ public synchronized FindRouteToolRequest currentTextRouteRequest() { - if (!hasTextRouteIntent()) { + return currentRouteRequest(ResolvedLocation.RouteIntent.TEXT_ROUTE); + } + + public synchronized FindRouteToolRequest currentRouteRequest(ResolvedLocation.RouteIntent intent) { + if (intent == null) { return null; } ResolvedLocation start = unique(devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation.EndpointRole.START, - devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation.RouteIntent.TEXT_ROUTE); + intent); ResolvedLocation end = unique(devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation.EndpointRole.END, - devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation.RouteIntent.TEXT_ROUTE); + intent); if (!validEndpoint(start) || !validEndpoint(end) || (start.type() == end.type() && start.id().equals(end.id()))) { return null; diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/RoutePlanner.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/RoutePlanner.java new file mode 100644 index 00000000..f626b5d7 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/RoutePlanner.java @@ -0,0 +1,35 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import devkor.com.teamcback.domain.chatbot.dto.RoutePlan; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** Requests only a provider-neutral route plan; it never resolves IDs or executes a route. */ +@Component +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class RoutePlanner { + private final LlmGateway llmGateway; + + public RoutePlanner(LlmGateway llmGateway) { + this.llmGateway = llmGateway; + } + + public RoutePlan plan(String systemPrompt, List history, String userMessage) { + try { + RoutePlan plan = llmGateway.planRoute(systemPrompt, history, userMessage); + if (plan == null || !valid(plan)) { + return RoutePlan.notRoute(); + } + return plan; + } catch (RuntimeException exception) { + // A failed planner must not prevent ordinary information Tool Calling from proceeding. + return RoutePlan.notRoute(); + } + } + + private boolean valid(RoutePlan plan) { + return !plan.isRoute() || plan.hasEndpointQueries(); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java index 901feeb8..cb5939ec 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java @@ -5,12 +5,14 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import devkor.com.teamcback.domain.chatbot.dto.RoutePlan; import devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter; import devkor.com.teamcback.domain.chatbot.service.ResolvedLocationCollector; import devkor.com.teamcback.domain.chatbot.tool.CampusChatbotTools; @@ -36,6 +38,7 @@ import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.converter.StructuredOutputConverter; import org.mockito.ArgumentCaptor; class SpringAiLlmGatewayTest { @@ -46,6 +49,30 @@ void tearDown() { executor.shutdownNow(); } + @Test + void usesStructuredOutputWithoutRegisteringCampusToolsForRoutePlanning() { + ChatClient chatClient = mock(ChatClient.class); + ChatClient.Builder builder = mock(ChatClient.Builder.class); + ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class); + ChatClient.CallResponseSpec responseSpec = mock(ChatClient.CallResponseSpec.class); + when(builder.build()).thenReturn(chatClient); + when(chatClient.prompt()).thenReturn(requestSpec); + when(requestSpec.system(anyString())).thenReturn(requestSpec); + when(requestSpec.messages(anyList())).thenReturn(requestSpec); + when(requestSpec.call()).thenReturn(responseSpec); + when(responseSpec.entity(any(StructuredOutputConverter.class))).thenReturn( + new RoutePlan(RoutePlan.Intent.TEXT_ROUTE, "start", "end", List.of())); + SpringAiLlmGateway gateway = new SpringAiLlmGateway(builder, properties(), executor, + mock(CampusChatbotTools.class), new ChatbotToolCallLimiter(properties())); + + RoutePlan plan = gateway.planRoute("system", List.of(), "route"); + + assertThat(plan.intent()).isEqualTo(RoutePlan.Intent.TEXT_ROUTE); + assertThat(plan.startQuery()).isEqualTo("start"); + verify(requestSpec).call(); + org.mockito.Mockito.verify(requestSpec, org.mockito.Mockito.never()).tools(any()); + } + @Test void mapsProviderFailureWithoutExposingProviderDetails() { ChatClient chatClient = mock(ChatClient.class, RETURNS_DEEP_STUBS); diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestratorTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestratorTest.java index 9857bc54..2052cfd0 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestratorTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestratorTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.when; import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import devkor.com.teamcback.domain.chatbot.dto.RoutePlan; import devkor.com.teamcback.domain.chatbot.tool.CampusToolAdapter; import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolData; import devkor.com.teamcback.domain.chatbot.tool.dto.FindRouteToolRequest; @@ -31,6 +32,64 @@ class ChatOrchestratorTest { @Mock LlmGateway llmGateway; @Mock CampusToolAdapter campusToolAdapter; + @Mock RoutePlanner routePlanner; + + @Test + void deterministicTextRouteResolvesBothEndpointsAndExecutesRouteWithoutModelTools() { + when(routePlanner.plan(anyString(), anyList(), anyString())).thenReturn( + new RoutePlan(RoutePlan.Intent.TEXT_ROUTE, "start place", "end place", List.of())); + when(campusToolAdapter.searchCampus(any(SearchCampusToolRequest.class))).thenAnswer(invocation -> { + SearchCampusToolRequest request = invocation.getArgument(0); + long id = request.role() == SearchCampusRole.START ? 11L : 22L; + return resolved(id, request.query()); + }); + when(campusToolAdapter.findRoute(any())).thenReturn(successfulRoute(120L)); + ChatOrchestrator orchestrator = new ChatOrchestrator(llmGateway, campusToolAdapter, routePlanner); + + ChatOrchestrationResult result = orchestrator.execute("system", List.of(), "route"); + + assertThat(result.routeExecution()).isNotNull(); + assertThat(result.reply()).contains("start place", "end place"); + verify(campusToolAdapter, org.mockito.Mockito.times(2)).searchCampus(any()); + verify(campusToolAdapter).findRoute(any()); + verify(llmGateway, never()).generate(anyString(), anyList(), anyString(), any()); + } + + @Test + void deterministicNavigateRouteResolvesBothEndpointsWithoutFindRoute() { + when(routePlanner.plan(anyString(), anyList(), anyString())).thenReturn( + new RoutePlan(RoutePlan.Intent.NAVIGATE_ROUTE, "start place", "end place", List.of())); + when(campusToolAdapter.searchCampus(any(SearchCampusToolRequest.class))).thenAnswer(invocation -> { + SearchCampusToolRequest request = invocation.getArgument(0); + long id = request.role() == SearchCampusRole.START ? 11L : 22L; + return resolved(id, request.query()); + }); + ChatOrchestrator orchestrator = new ChatOrchestrator(llmGateway, campusToolAdapter, routePlanner); + + ChatOrchestrationResult result = orchestrator.execute("system", List.of(), "route"); + + assertThat(result.resolvedLocations()).hasSize(2); + assertThat(result.routeExecution()).isNull(); + verify(campusToolAdapter, org.mockito.Mockito.times(2)).searchCampus(any()); + verify(campusToolAdapter, never()).findRoute(any()); + verify(llmGateway, never()).generate(anyString(), anyList(), anyString(), any()); + } + + @Test + void deterministicRouteStopsAfterAmbiguousStart() { + when(routePlanner.plan(anyString(), anyList(), anyString())).thenReturn( + new RoutePlan(RoutePlan.Intent.TEXT_ROUTE, "ambiguous", "end place", List.of())); + when(campusToolAdapter.searchCampus(any(SearchCampusToolRequest.class))).thenReturn( + new SearchCampusToolResult(List.of(item(11L, "start A"), item(12L, "start B")), true, null)); + ChatOrchestrator orchestrator = new ChatOrchestrator(llmGateway, campusToolAdapter, routePlanner); + + ChatOrchestrationResult result = orchestrator.execute("system", List.of(), "route"); + + assertThat(result.routeExecution()).isNull(); + assertThat(result.searchResolutions()).hasSize(1); + verify(campusToolAdapter).searchCampus(any()); + verify(campusToolAdapter, never()).findRoute(any()); + } @Test void keepsModelReplyAndDoesNotDuplicateExistingRouteExecution() { diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestratorWiringTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestratorWiringTest.java new file mode 100644 index 00000000..b172bf45 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestratorWiringTest.java @@ -0,0 +1,32 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import devkor.com.teamcback.domain.chatbot.tool.CampusToolAdapter; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +class ChatOrchestratorWiringTest { + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("chatbot.enabled=true") + .withBean(LlmGateway.class, () -> mock(LlmGateway.class)) + .withBean(CampusToolAdapter.class, () -> mock(CampusToolAdapter.class)) + .withUserConfiguration(TestConfig.class); + + @Test + void createsOrchestratorWithPlannerThroughAutowiredConstructor() { + contextRunner.run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(ChatOrchestrator.class); + assertThat(context).hasSingleBean(RoutePlanner.class); + }); + } + + @Configuration(proxyBeanMethods = false) + @Import({ChatOrchestrator.class, RoutePlanner.class}) + static class TestConfig { + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/RoutePlannerTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/RoutePlannerTest.java new file mode 100644 index 00000000..4536992a --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/RoutePlannerTest.java @@ -0,0 +1,41 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.chatbot.dto.RoutePlan; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class RoutePlannerTest { + @Mock LlmGateway llmGateway; + + @Test + void preservesStructuredRoutePlanWithoutResolvingIds() { + RoutePlan expected = new RoutePlan(RoutePlan.Intent.TEXT_ROUTE, "중앙도서관(신관)", "문과대학(서관)", List.of()); + when(llmGateway.planRoute(anyString(), anyList(), anyString())).thenReturn(expected); + + RoutePlan actual = new RoutePlanner(llmGateway).plan("system", List.of(), "route"); + + assertThat(actual).isEqualTo(expected); + assertThat(actual.startQuery()).isEqualTo("중앙도서관(신관)"); + assertThat(actual.endQuery()).isEqualTo("문과대학(서관)"); + } + + @Test + void invalidStructuredRoutePlanFallsBackToNotRoute() { + when(llmGateway.planRoute(anyString(), anyList(), anyString())) + .thenReturn(new RoutePlan(RoutePlan.Intent.NAVIGATE_ROUTE, "", "end", List.of())); + + RoutePlan actual = new RoutePlanner(llmGateway).plan("system", List.of(), "route"); + + assertThat(actual.intent()).isEqualTo(RoutePlan.Intent.NOT_ROUTE); + } +} From 0e126d0c75aeb382ee29da0b2c811415a5c312e1 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Sun, 27 Sep 2026 15:00:24 +0900 Subject: [PATCH 12/21] reafactor(chatbot): change message --- .../chatbot/service/ChatOrchestrator.java | 13 +++++-- .../chatbot/service/ChatOrchestratorTest.java | 34 ++++++++++++++++++- .../service/ChatServiceActionTest.java | 4 +-- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestrator.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestrator.java index 705bca38..ddbf09f0 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestrator.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestrator.java @@ -122,10 +122,17 @@ private boolean hasSuccessfulTextRoute(RouteExecutionTrace trace) { private String deterministicTextRouteReply(RouteExecutionTrace trace) { StringBuilder reply = new StringBuilder(trace.start().name()).append("에서 ") - .append(trace.end().name()).append("까지 경로를 찾았습니다."); - if (trace.route().estimatedDurationSeconds() != null) { - reply.append(" 예상 소요 시간은 ").append(trace.route().estimatedDurationSeconds()).append("초입니다."); + .append(trace.end().name()).append("까지 경로를 찾았어요."); + Long durationSeconds = trace.route().estimatedDurationSeconds(); + if (durationSeconds != null) { + if (durationSeconds < 60) { + reply.append(" 예상 소요 시간은 1분 이내예요."); + } else { + long minutes = Math.max(1L, Math.round(durationSeconds / 60.0)); + reply.append(" 예상 소요 시간은 약 ").append(minutes).append("분이에요."); + } } + reply.append(" 길찾기 화면으로 안내할까요?"); return reply.toString(); } } diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestratorTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestratorTest.java index 2052cfd0..b5a1ddf5 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestratorTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestratorTest.java @@ -55,6 +55,38 @@ void deterministicTextRouteResolvesBothEndpointsAndExecutesRouteWithoutModelTool verify(llmGateway, never()).generate(anyString(), anyList(), anyString(), any()); } + @Test + void formatsDurationAsApproximateMinutes() { + when(routePlanner.plan(anyString(), anyList(), anyString())).thenReturn( + new RoutePlan(RoutePlan.Intent.TEXT_ROUTE, "start", "end", List.of())); + when(campusToolAdapter.searchCampus(any(SearchCampusToolRequest.class))).thenAnswer(invocation -> { + SearchCampusToolRequest request = invocation.getArgument(0); + return resolved(request.role() == SearchCampusRole.START ? 11L : 22L, request.query()); + }); + when(campusToolAdapter.findRoute(any())).thenReturn(successfulRoute(421L)); + + ChatOrchestrationResult result = new ChatOrchestrator(llmGateway, campusToolAdapter, routePlanner) + .execute("system", List.of(), "route"); + + assertThat(result.reply()).isEqualTo("start에서 end까지 경로를 찾았어요. 예상 소요 시간은 약 7분이에요. 길찾기 화면으로 안내할까요?"); + } + + @Test + void formatsSubMinuteDurationNaturally() { + when(routePlanner.plan(anyString(), anyList(), anyString())).thenReturn( + new RoutePlan(RoutePlan.Intent.TEXT_ROUTE, "start", "end", List.of())); + when(campusToolAdapter.searchCampus(any(SearchCampusToolRequest.class))).thenAnswer(invocation -> { + SearchCampusToolRequest request = invocation.getArgument(0); + return resolved(request.role() == SearchCampusRole.START ? 11L : 22L, request.query()); + }); + when(campusToolAdapter.findRoute(any())).thenReturn(successfulRoute(45L)); + + ChatOrchestrationResult result = new ChatOrchestrator(llmGateway, campusToolAdapter, routePlanner) + .execute("system", List.of(), "route"); + + assertThat(result.reply()).isEqualTo("start에서 end까지 경로를 찾았어요. 예상 소요 시간은 1분 이내예요. 길찾기 화면으로 안내할까요?"); + } + @Test void deterministicNavigateRouteResolvesBothEndpointsWithoutFindRoute() { when(routePlanner.plan(anyString(), anyList(), anyString())).thenReturn( @@ -137,7 +169,7 @@ void returnsDeterministicReplyFromRouteTraceWhenCompletionIsBlank() { ChatOrchestrationResult result = orchestrator.execute("system", List.of(), "route"); - assertThat(result.reply()).isEqualTo("start에서 end까지 경로를 찾았습니다. 예상 소요 시간은 120초입니다."); + assertThat(result.reply()).isEqualTo("start에서 end까지 경로를 찾았어요. 예상 소요 시간은 약 2분이에요. 길찾기 화면으로 안내할까요?"); assertThat(result.completionStatus()).isEqualTo(LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION); } diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java index 1c58bf75..cd65ce9b 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java @@ -259,7 +259,7 @@ void returnsDeterministicTextRouteReplyWhenFinalCompletionIsBlank() { RouteExecutionTrace execution = new RouteExecutionTrace(start, end, List.of(), new FindRouteToolData(120L, List.of()), true); when(chatOrchestrator.execute(anyString(), anyList(), anyString())).thenReturn(new ChatOrchestrationResult( - "start에서 end까지 경로를 찾았습니다. 예상 소요 시간은 120초입니다.", List.of(start, end), + "start에서 end까지 경로를 찾았어요. 예상 소요 시간은 약 2분이에요. 길찾기 화면으로 안내할까요?", List.of(start, end), LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION, List.of(), execution)); ChatService service = new ChatService(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService); @@ -270,6 +270,6 @@ void returnsDeterministicTextRouteReplyWhenFinalCompletionIsBlank() { var response = service.sendMessage(new ChatMessageReq(sessionId, "몇 분 걸려?", null), caller); assertThat(response.action()).isNull(); - assertThat(response.reply()).isEqualTo("start에서 end까지 경로를 찾았습니다. 예상 소요 시간은 120초입니다."); + assertThat(response.reply()).isEqualTo("start에서 end까지 경로를 찾았어요. 예상 소요 시간은 약 2분이에요. 길찾기 화면으로 안내할까요?"); } } From d70b7b360d03d18f859110fc8f217f2618c3345b Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Sun, 27 Sep 2026 15:45:01 +0900 Subject: [PATCH 13/21] light search --- .../BuildingNicknameRepository.java | 11 ++ .../search/ChatbotBuildingCandidate.java | 5 + .../search/ChatbotCampusSearchService.java | 138 ++++++++++++++++++ .../chatbot/search/ChatbotPlaceCandidate.java | 8 + .../search/ChatbotSearchCandidate.java | 10 ++ .../chatbot/tool/CampusToolAdapter.java | 53 +++---- .../repository/PlaceNicknameRepository.java | 25 ++++ .../ChatbotCampusSearchServiceTest.java | 81 ++++++++++ .../chatbot/tool/CampusToolAdapterTest.java | 79 +++++----- 9 files changed, 335 insertions(+), 75 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotBuildingCandidate.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotCampusSearchService.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotPlaceCandidate.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotSearchCandidate.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/search/ChatbotCampusSearchServiceTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/building/repository/BuildingNicknameRepository.java b/src/main/java/devkor/com/teamcback/domain/building/repository/BuildingNicknameRepository.java index 429b0d8d..5ef0e76e 100644 --- a/src/main/java/devkor/com/teamcback/domain/building/repository/BuildingNicknameRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/building/repository/BuildingNicknameRepository.java @@ -7,6 +7,9 @@ import org.springframework.data.jpa.repository.EntityGraph; import java.util.List; +import devkor.com.teamcback.domain.chatbot.search.ChatbotBuildingCandidate; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface BuildingNicknameRepository extends JpaRepository { @@ -21,4 +24,12 @@ public interface BuildingNicknameRepository extends JpaRepository findByChosungIsNullOrJasoDecomposeIsNull(); List findAllByBuilding(Building building); List findAllByNicknameContaining(String blank); + + @Query("select new devkor.com.teamcback.domain.chatbot.search.ChatbotBuildingCandidate(b.id, b.name, n.nickname) " + + "from BuildingNickname n join n.building b where n.jasoDecompose like concat('%', :jaso, '%')") + List findChatbotByJaso(@Param("jaso") String jaso, Pageable pageable); + + @Query("select new devkor.com.teamcback.domain.chatbot.search.ChatbotBuildingCandidate(b.id, b.name, n.nickname) " + + "from BuildingNickname n join n.building b where n.chosung like concat('%', :chosung, '%')") + List findChatbotByChosung(@Param("chosung") String chosung, Pageable pageable); } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotBuildingCandidate.java b/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotBuildingCandidate.java new file mode 100644 index 00000000..b5fc233d --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotBuildingCandidate.java @@ -0,0 +1,5 @@ +package devkor.com.teamcback.domain.chatbot.search; + +/** Minimal building projection used only by the chatbot resolver. */ +public record ChatbotBuildingCandidate(Long id, String name, String nickname) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotCampusSearchService.java b/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotCampusSearchService.java new file mode 100644 index 00000000..3fd9b9bd --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotCampusSearchService.java @@ -0,0 +1,138 @@ +package devkor.com.teamcback.domain.chatbot.search; + +import devkor.com.teamcback.domain.building.entity.BuildingNickname; +import devkor.com.teamcback.domain.building.repository.BuildingNicknameRepository; +import devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType; +import devkor.com.teamcback.domain.place.entity.PlaceNickname; +import devkor.com.teamcback.domain.place.repository.PlaceNicknameRepository; +import devkor.com.teamcback.domain.search.util.HangeulUtils; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** Lightweight, non-personalized location resolver for searchCampus. */ +@Service +public class ChatbotCampusSearchService { + private static final int INTERNAL_CANDIDATE_LIMIT = 50; + + private final BuildingNicknameRepository buildingNicknameRepository; + private final PlaceNicknameRepository placeNicknameRepository; + + public ChatbotCampusSearchService(BuildingNicknameRepository buildingNicknameRepository, + PlaceNicknameRepository placeNicknameRepository) { + this.buildingNicknameRepository = buildingNicknameRepository; + this.placeNicknameRepository = placeNicknameRepository; + } + + @Transactional(readOnly = true) + public List search(String query) { + String trimmed = query == null ? "" : query.trim(); + if (trimmed.isEmpty()) { + return List.of(); + } + + Pageable limit = PageRequest.of(0, INTERNAL_CANDIDATE_LIMIT); + List candidates = new ArrayList<>(); + String compact = compact(trimmed); + addBuildingCandidates(candidates, findBuildings(compact, limit), 300); + addPlaceCandidates(candidates, findPlaces(compact, null, limit), 290); + + String[] words = trimmed.split("\\s+"); + if (words.length > 1) { + addCompositeCandidates(candidates, words[0], compact.substring(compact(words[0]).length()), 200, limit); + String last = words[words.length - 1]; + String lastCompact = compact(last); + String beforeLast = compact.substring(0, Math.max(0, compact.length() - lastCompact.length())); + addCompositeCandidates(candidates, last, beforeLast, 190, limit); + } + + Map unique = new LinkedHashMap<>(); + for (ChatbotSearchCandidate candidate : candidates) { + String key = candidate.locationType() + ":" + candidate.locationId(); + ChatbotSearchCandidate previous = unique.get(key); + if (previous == null || candidate.sourcePriority() > previous.sourcePriority()) { + unique.put(key, candidate); + } + } + return unique.values().stream() + .sorted(java.util.Comparator.comparingInt(ChatbotSearchCandidate::sourcePriority).reversed() + .thenComparing(ChatbotSearchCandidate::name, java.util.Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private void addCompositeCandidates(List target, String buildingWord, + String placeWord, int priority, Pageable limit) { + if (placeWord == null || placeWord.isBlank()) { + return; + } + List buildings = findBuildings(compact(buildingWord), limit); + List buildingIds = buildings.stream().map(ChatbotBuildingCandidate::id) + .filter(Objects::nonNull).distinct().toList(); + if (buildingIds.isEmpty()) { + return; + } + addBuildingCandidates(target, buildings, priority); + addPlaceCandidates(target, findPlaces(compact(placeWord), buildingIds, limit), priority - 1); + } + + private List findBuildings(String word, Pageable limit) { + String jaso = HangeulUtils.decomposeHangulString(word); + List result = new ArrayList<>( + buildingNicknameRepository.findChatbotByJaso(jaso, limit)); + if (HangeulUtils.isConsonantOnly(word)) { + result.addAll(buildingNicknameRepository.findChatbotByChosung( + HangeulUtils.extractChosung(word), limit)); + } + return result; + } + + private List findPlaces(String word, List buildingIds, Pageable limit) { + String jaso = HangeulUtils.decomposeHangulString(word); + List result = new ArrayList<>(); + if (buildingIds == null) { + result.addAll(placeNicknameRepository.findChatbotByJaso(jaso, limit)); + if (HangeulUtils.isConsonantOnly(word)) { + result.addAll(placeNicknameRepository.findChatbotByChosung( + HangeulUtils.extractChosung(word), limit)); + } + } else { + result.addAll(placeNicknameRepository.findChatbotByJasoAndBuildingIds(jaso, buildingIds, limit)); + if (HangeulUtils.isConsonantOnly(word)) { + result.addAll(placeNicknameRepository.findChatbotByChosungAndBuildingIds( + HangeulUtils.extractChosung(word), buildingIds, limit)); + } + } + return result; + } + + private void addBuildingCandidates(List target, + List source, int priority) { + for (ChatbotBuildingCandidate item : source) { + if (item.id() != null) { + target.add(new ChatbotSearchCandidate(item.id(), ToolLocationType.BUILDING, item.name(), + item.id(), item.name(), null, null, null, priority)); + } + } + } + + private void addPlaceCandidates(List target, + List source, int priority) { + for (ChatbotPlaceCandidate item : source) { + if (item.id() != null) { + target.add(new ChatbotSearchCandidate(item.id(), ToolLocationType.PLACE, item.name(), + item.buildingId(), item.buildingName(), item.floor(), item.placeType(), item.detail(), priority)); + } + } + } + + private String compact(String value) { + return value.replaceAll("\\s+", "").toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotPlaceCandidate.java b/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotPlaceCandidate.java new file mode 100644 index 00000000..1775b78f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotPlaceCandidate.java @@ -0,0 +1,8 @@ +package devkor.com.teamcback.domain.chatbot.search; + +import devkor.com.teamcback.domain.place.entity.PlaceType; + +/** Minimal place projection used only by the chatbot resolver. */ +public record ChatbotPlaceCandidate(Long id, String name, Long buildingId, String buildingName, + Double floor, PlaceType placeType, String detail, String nickname) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotSearchCandidate.java b/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotSearchCandidate.java new file mode 100644 index 00000000..5c375baa --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotSearchCandidate.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.chatbot.search; + +import devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType; +import devkor.com.teamcback.domain.place.entity.PlaceType; + +/** Internal candidate; it is converted to the existing Tool DTO by the adapter. */ +public record ChatbotSearchCandidate(Long locationId, ToolLocationType locationType, String name, + Long buildingId, String buildingName, Double floor, + PlaceType placeType, String detail, int sourcePriority) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java index fd6ea202..f316cd36 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java @@ -76,15 +76,14 @@ import devkor.com.teamcback.domain.review.dto.response.GetReviewPlaceDetailRes; import devkor.com.teamcback.domain.review.dto.response.SearchPlaceReviewRes; import devkor.com.teamcback.domain.review.service.ReviewService; -import devkor.com.teamcback.domain.search.dto.response.GlobalSearchRes; -import devkor.com.teamcback.domain.search.dto.response.GlobalSearchListRes; -import devkor.com.teamcback.domain.search.dto.response.GlobalSearchRes; import devkor.com.teamcback.domain.search.dto.response.SearchBuildingDetailRes; import devkor.com.teamcback.domain.search.dto.response.SearchFacilityRes; import devkor.com.teamcback.domain.search.dto.response.SearchPlaceDetailRes; import devkor.com.teamcback.domain.search.dto.response.SearchPlaceRes; import devkor.com.teamcback.domain.search.dto.response.SearchRoomDetailRes; import devkor.com.teamcback.domain.search.service.SearchService; +import devkor.com.teamcback.domain.chatbot.search.ChatbotCampusSearchService; +import devkor.com.teamcback.domain.chatbot.search.ChatbotSearchCandidate; import devkor.com.teamcback.domain.schoolcalendar.service.SchoolCalendarService; import devkor.com.teamcback.global.exception.exception.AdminException; import devkor.com.teamcback.global.exception.exception.GlobalException; @@ -100,6 +99,7 @@ import java.util.Map; import java.util.Objects; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -109,6 +109,7 @@ public class CampusToolAdapter { private static final int CHATBOT_SEARCH_RESULT_LIMIT = 5; private final SearchService searchService; + private final ChatbotCampusSearchService chatbotCampusSearchService; private final RouteService routeService; private final CafeteriaMenuService cafeteriaMenuService; private final CourseService courseService; @@ -117,11 +118,14 @@ public class CampusToolAdapter { private final ReviewService reviewService; private final ChatbotProperties properties; - public CampusToolAdapter(SearchService searchService, RouteService routeService, + @Autowired + public CampusToolAdapter(SearchService searchService, ChatbotCampusSearchService chatbotCampusSearchService, + RouteService routeService, CafeteriaMenuService cafeteriaMenuService, CourseService courseService, SchoolCalendarService schoolCalendarService, BLEService bleService, ReviewService reviewService, ChatbotProperties properties) { this.searchService = searchService; + this.chatbotCampusSearchService = chatbotCampusSearchService; this.routeService = routeService; this.cafeteriaMenuService = cafeteriaMenuService; this.courseService = courseService; @@ -131,6 +135,15 @@ public CampusToolAdapter(SearchService searchService, RouteService routeService, this.properties = properties; } + /** Compatibility constructor for non-search focused unit tests. */ + public CampusToolAdapter(SearchService searchService, RouteService routeService, + CafeteriaMenuService cafeteriaMenuService, CourseService courseService, + SchoolCalendarService schoolCalendarService, BLEService bleService, + ReviewService reviewService, ChatbotProperties properties) { + this(searchService, null, routeService, cafeteriaMenuService, courseService, + schoolCalendarService, bleService, reviewService, properties); + } + public SearchCampusToolResult searchCampus(SearchCampusToolRequest request) { if (request == null || request.query() == null || request.query().trim().isEmpty()) { return new SearchCampusToolResult(List.of(), false, error(INVALID_INPUT)); @@ -139,13 +152,11 @@ public SearchCampusToolResult searchCampus(SearchCampusToolRequest request) { int limit = Math.min(CHATBOT_SEARCH_RESULT_LIMIT, properties.tools().search().maxLimit()); try { String query = request.query().trim(); - GlobalSearchListRes searchResult = searchService.globalSearch(query, null); - List actualLocations = deduplicate(searchResult == null - ? List.of() : searchResult.getList()); + List actualLocations = chatbotCampusSearchService.search(query); String normalizedQuery = normalizeSearchName(query); List candidates = actualLocations.stream() .limit(limit) - .map(item -> toSearchItem(item, matchType(normalizedQuery, item.getName()))) + .map(item -> toSearchItem(item, matchType(normalizedQuery, item.name()))) .toList(); if (candidates.isEmpty()) { log.info("chatbot_search query={} limit={} durationMs={} candidateCount=0 ambiguous=false candidates=[]", @@ -169,20 +180,9 @@ query, limit, elapsedMillis(startedAt), candidates.size(), ambiguous, } } - private List deduplicate(List candidates) { - if (candidates == null || candidates.isEmpty()) { - return List.of(); - } - Map unique = new LinkedHashMap<>(); - for (GlobalSearchRes candidate : candidates) { - if (candidate == null || candidate.getLocationType() == null || candidate.getId() == null - || (candidate.getLocationType() != LocationType.BUILDING - && candidate.getLocationType() != LocationType.PLACE)) { - continue; - } - unique.putIfAbsent(candidate.getLocationType() + ":" + candidate.getId(), candidate); - } - return unique.values().stream().toList(); + private SearchCampusItem toSearchItem(ChatbotSearchCandidate item, SearchCampusMatchType matchType) { + return new SearchCampusItem(item.locationId(), item.locationType(), item.name(), item.buildingId(), + item.buildingName(), item.floor(), item.placeType(), normalizeDetail(item.detail()), matchType); } public GetLocationDetailToolResult getLocationDetail(GetLocationDetailToolRequest request) { @@ -584,15 +584,6 @@ private List flattenBuildingFacilities(FindFacilitiesToolReque .toList(); } - private SearchCampusItem toSearchItem(GlobalSearchRes item, SearchCampusMatchType matchType) { - ToolLocationType type = item.getLocationType() == LocationType.BUILDING - ? ToolLocationType.BUILDING : ToolLocationType.PLACE; - Long buildingId = type == ToolLocationType.BUILDING ? item.getId() : item.getBuildingId(); - String buildingName = type == ToolLocationType.BUILDING ? item.getName() : null; - return new SearchCampusItem(item.getId(), type, item.getName(), buildingId, buildingName, - item.getFloor(), item.getPlaceType(), normalizeDetail(item.getDetail()), matchType); - } - private SearchCampusMatchType matchType(String normalizedQuery, String candidateName) { String normalizedCandidate = normalizeSearchName(candidateName); if (normalizedQuery.equals(normalizedCandidate)) { diff --git a/src/main/java/devkor/com/teamcback/domain/place/repository/PlaceNicknameRepository.java b/src/main/java/devkor/com/teamcback/domain/place/repository/PlaceNicknameRepository.java index ae76c4f0..5d09c202 100644 --- a/src/main/java/devkor/com/teamcback/domain/place/repository/PlaceNicknameRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/place/repository/PlaceNicknameRepository.java @@ -7,6 +7,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import devkor.com.teamcback.domain.chatbot.search.ChatbotPlaceCandidate; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface PlaceNicknameRepository extends JpaRepository { @EntityGraph(attributePaths = {"place", "place.building"}) @@ -22,4 +25,26 @@ public interface PlaceNicknameRepository extends JpaRepository findByJasoDecomposeContainingAndPlaceInOrderByNickname(String jaso, List list, Pageable pageable); List findByChosungIsNullOrJasoDecomposeIsNull(); List findAllByNicknameContaining(String blank); + + @Query("select new devkor.com.teamcback.domain.chatbot.search.ChatbotPlaceCandidate(p.id, p.name, b.id, b.name, p.floor, p.type, p.detail, n.nickname) " + + "from PlaceNickname n join n.place p join p.building b where n.jasoDecompose like concat('%', :jaso, '%')") + List findChatbotByJaso(@Param("jaso") String jaso, Pageable pageable); + + @Query("select new devkor.com.teamcback.domain.chatbot.search.ChatbotPlaceCandidate(p.id, p.name, b.id, b.name, p.floor, p.type, p.detail, n.nickname) " + + "from PlaceNickname n join n.place p join p.building b where n.chosung like concat('%', :chosung, '%')") + List findChatbotByChosung(@Param("chosung") String chosung, Pageable pageable); + + @Query("select new devkor.com.teamcback.domain.chatbot.search.ChatbotPlaceCandidate(p.id, p.name, b.id, b.name, p.floor, p.type, p.detail, n.nickname) " + + "from PlaceNickname n join n.place p join p.building b where b.id in :buildingIds " + + "and n.jasoDecompose like concat('%', :jaso, '%')") + List findChatbotByJasoAndBuildingIds(@Param("jaso") String jaso, + @Param("buildingIds") List buildingIds, + Pageable pageable); + + @Query("select new devkor.com.teamcback.domain.chatbot.search.ChatbotPlaceCandidate(p.id, p.name, b.id, b.name, p.floor, p.type, p.detail, n.nickname) " + + "from PlaceNickname n join n.place p join p.building b where b.id in :buildingIds " + + "and n.chosung like concat('%', :chosung, '%')") + List findChatbotByChosungAndBuildingIds(@Param("chosung") String chosung, + @Param("buildingIds") List buildingIds, + Pageable pageable); } diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/search/ChatbotCampusSearchServiceTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/search/ChatbotCampusSearchServiceTest.java new file mode 100644 index 00000000..c4c9ec48 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/search/ChatbotCampusSearchServiceTest.java @@ -0,0 +1,81 @@ +package devkor.com.teamcback.domain.chatbot.search; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.lenient; + +import devkor.com.teamcback.domain.building.repository.BuildingNicknameRepository; +import devkor.com.teamcback.domain.place.entity.PlaceType; +import devkor.com.teamcback.domain.place.repository.PlaceNicknameRepository; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ChatbotCampusSearchServiceTest { + @Mock + private BuildingNicknameRepository buildingNicknameRepository; + @Mock + private PlaceNicknameRepository placeNicknameRepository; + + private ChatbotCampusSearchService service; + + @BeforeEach + void setUp() { + service = new ChatbotCampusSearchService(buildingNicknameRepository, placeNicknameRepository); + } + + @Test + void resolvesBuildingAndPlaceWithoutLoadingNodeOrWholeBuildingPlaces() { + when(buildingNicknameRepository.findChatbotByJaso(any(), any())) + .thenReturn(List.of(new ChatbotBuildingCandidate(1L, "Main Building", "main"))); + when(placeNicknameRepository.findChatbotByJaso(any(), any())) + .thenReturn(List.of(new ChatbotPlaceCandidate(2L, "Room 129B", 1L, + "Main Building", 1.0, PlaceType.CLASSROOM, "detail", "room"))); + + List result = service.search("main"); + + assertThat(result).extracting(ChatbotSearchCandidate::locationId) + .containsExactly(1L, 2L); + verify(buildingNicknameRepository).findChatbotByJaso(any(), any()); + verify(placeNicknameRepository).findChatbotByJaso(any(), any()); + } + + @Test + void compositeSearchUsesBuildingIdsForPlaceProjection() { + lenient().when(buildingNicknameRepository.findChatbotByJaso(any(), any())).thenReturn(List.of()); + when(buildingNicknameRepository.findChatbotByJaso(eq("building"), any())) + .thenReturn(List.of(new ChatbotBuildingCandidate(7L, "Building", "building"))); + when(buildingNicknameRepository.findChatbotByJaso(eq("room"), any())) + .thenReturn(List.of()); + when(placeNicknameRepository.findChatbotByJasoAndBuildingIds(eq("room"), eq(List.of(7L)), any())) + .thenReturn(List.of(new ChatbotPlaceCandidate(8L, "Room", 7L, + "Building", 1.0, PlaceType.CLASSROOM, null, "room"))); + + List result = service.search("building room"); + + assertThat(result).extracting(ChatbotSearchCandidate::locationId) + .containsExactly(7L, 8L); + verify(placeNicknameRepository).findChatbotByJasoAndBuildingIds(eq("room"), eq(List.of(7L)), any()); + } + + @Test + void deduplicatesSameLocationAndKeepsDistinctIds() { + when(buildingNicknameRepository.findChatbotByJaso(any(), any())).thenReturn(List.of( + new ChatbotBuildingCandidate(1L, "Building", "one"), + new ChatbotBuildingCandidate(1L, "Building", "alias"), + new ChatbotBuildingCandidate(2L, "Building Annex", "two"))); + + List result = service.search("building"); + + assertThat(result).extracting(ChatbotSearchCandidate::locationId) + .containsExactly(1L, 2L); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java index 9132638d..49b05501 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java @@ -2,19 +2,17 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import devkor.com.teamcback.domain.chatbot.search.ChatbotCampusSearchService; +import devkor.com.teamcback.domain.chatbot.search.ChatbotSearchCandidate; import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; -import devkor.com.teamcback.domain.common.LocationType; import devkor.com.teamcback.domain.place.entity.PlaceType; +import devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType; import devkor.com.teamcback.domain.routes.service.RouteService; -import devkor.com.teamcback.domain.search.dto.response.GlobalSearchListRes; -import devkor.com.teamcback.domain.search.dto.response.GlobalSearchRes; import devkor.com.teamcback.domain.search.service.SearchService; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -28,6 +26,8 @@ class CampusToolAdapterTest { @Mock private SearchService searchService; @Mock + private ChatbotCampusSearchService chatbotCampusSearchService; + @Mock private RouteService routeService; private CampusToolAdapter adapter; @@ -42,29 +42,29 @@ void setUp() { new ChatbotProperties.Limits(10, 20), 7, new ChatbotProperties.Limits(5, 10)), new ChatbotProperties.RateLimit(30, 10, 5, "Asia/Seoul")); - adapter = new CampusToolAdapter(searchService, routeService, null, null, null, null, null, properties); + adapter = new CampusToolAdapter(searchService, chatbotCampusSearchService, routeService, + null, null, null, null, null, properties); } @Test - void reusesGlobalSearchOrderAndPassesAnonymousUser() { - doReturn(new GlobalSearchListRes(List.of( - searchResult(11L, "중앙도서관(대학원)", LocationType.BUILDING, null), - searchResult(12L, "중앙도서관(신관)", LocationType.BUILDING, null)))) - .when(searchService).globalSearch("중도", null); + void keepsLightweightResolverOrderAndAmbiguity() { + when(chatbotCampusSearchService.search("중도")).thenReturn(List.of( + candidate(11L, "중앙도서관(대학원)", ToolLocationType.BUILDING), + candidate(12L, "중앙도서관(신관)", ToolLocationType.BUILDING))); var result = adapter.searchCampus(new SearchCampusToolRequest(" 중도 ", 1)); assertThat(result.ambiguous()).isTrue(); assertThat(result.candidates()).extracting("locationId").containsExactly(11L, 12L); - verify(searchService).globalSearch("중도", null); + verify(chatbotCampusSearchService).search("중도"); + verifyNoInteractions(searchService); } @Test - void doesNotReRankGlobalSearchResultsByMatchType() { - doReturn(new GlobalSearchListRes(List.of( - searchResult(21L, "문과대학(서관)", LocationType.BUILDING, null), - searchResult(29L, "문과대학(서관) 129B", LocationType.PLACE, PlaceType.CLASSROOM)))) - .when(searchService).globalSearch("문과대학 서관", null); + void preservesResolverCandidateOrder() { + when(chatbotCampusSearchService.search("문과대학 서관")).thenReturn(List.of( + candidate(21L, "문과대학(서관)", ToolLocationType.BUILDING), + candidate(29L, "문과대학(서관) 129B", ToolLocationType.PLACE))); var result = adapter.searchCampus(new SearchCampusToolRequest("문과대학 서관", null)); @@ -73,12 +73,10 @@ void doesNotReRankGlobalSearchResultsByMatchType() { } @Test - void filtersVirtualFacilitiesAndInvalidIdsWhileKeepingGlobalOrder() { - doReturn(new GlobalSearchListRes(List.of( - searchResult(null, "장애인주차장", LocationType.FACILITY, PlaceType.DISABLED_PARKING), - searchResult(31L, "중앙도서관 장애인주차장", LocationType.PLACE, PlaceType.DISABLED_PARKING), - searchResult(32L, "중앙도서관 129B", LocationType.PLACE, PlaceType.CLASSROOM)))) - .when(searchService).globalSearch("시설", null); + void keepsOnlyResolverLocations() { + when(chatbotCampusSearchService.search("시설")).thenReturn(List.of( + candidate(31L, "중앙도서관 장애인주차장", ToolLocationType.PLACE), + candidate(32L, "중앙도서관 129B", ToolLocationType.PLACE))); var result = adapter.searchCampus(new SearchCampusToolRequest("시설", 1)); @@ -87,12 +85,10 @@ void filtersVirtualFacilitiesAndInvalidIdsWhileKeepingGlobalOrder() { } @Test - void deduplicatesSameLocationWithoutMergingDifferentIds() { - GlobalSearchRes first = searchResult(11L, "중앙도서관", LocationType.BUILDING, null); - GlobalSearchRes duplicate = searchResult(11L, "중앙도서관", LocationType.BUILDING, null); - GlobalSearchRes different = searchResult(12L, "중앙도서관(신관)", LocationType.BUILDING, null); - doReturn(new GlobalSearchListRes(List.of(first, duplicate, different))) - .when(searchService).globalSearch("중도", null); + void resolverDedupeIsPreserved() { + when(chatbotCampusSearchService.search("중도")).thenReturn(List.of( + candidate(11L, "중앙도서관", ToolLocationType.BUILDING), + candidate(12L, "중앙도서관(신관)", ToolLocationType.BUILDING))); var result = adapter.searchCampus(new SearchCampusToolRequest("중도", null)); @@ -101,11 +97,11 @@ void deduplicatesSameLocationWithoutMergingDifferentIds() { } @Test - void keepsAtMostServerSearchLimitAfterGlobalRanking() { - List results = java.util.stream.IntStream.rangeClosed(1, 10) - .mapToObj(id -> searchResult((long) id, "장소" + id, LocationType.BUILDING, null)) + void keepsAtMostServerSearchLimitAfterLightweightSearch() { + List results = java.util.stream.IntStream.rangeClosed(1, 10) + .mapToObj(id -> candidate(id, "장소" + id, ToolLocationType.BUILDING)) .toList(); - doReturn(new GlobalSearchListRes(results)).when(searchService).globalSearch("장소", null); + when(chatbotCampusSearchService.search("장소")).thenReturn(results); var result = adapter.searchCampus(new SearchCampusToolRequest("장소", 1)); @@ -123,22 +119,17 @@ void blankSearchDoesNotCallSearchService() { @Test void emptyGlobalSearchMapsToNotFound() { - doReturn(new GlobalSearchListRes(List.of())).when(searchService).globalSearch("없는 곳", null); + when(chatbotCampusSearchService.search("없는 곳")).thenReturn(List.of()); var result = adapter.searchCampus(new SearchCampusToolRequest("없는 곳", null)); assertThat(result.error().code()).isEqualTo(devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.NOT_FOUND); } - private GlobalSearchRes searchResult(Long id, String name, LocationType type, PlaceType placeType) { - GlobalSearchRes result = mock(GlobalSearchRes.class); - lenient().when(result.getId()).thenReturn(id); - lenient().when(result.getName()).thenReturn(name); - lenient().when(result.getLocationType()).thenReturn(type); - lenient().when(result.getPlaceType()).thenReturn(placeType); - lenient().when(result.getBuildingId()).thenReturn(type == LocationType.PLACE ? 1L : null); - lenient().when(result.getFloor()).thenReturn(type == LocationType.PLACE ? 1.0 : null); - lenient().when(result.getDetail()).thenReturn(null); - return result; + private ChatbotSearchCandidate candidate(long id, String name, ToolLocationType type) { + return new ChatbotSearchCandidate(id, type, name, type == ToolLocationType.PLACE ? 1L : id, + type == ToolLocationType.PLACE ? "건물" : name, + type == ToolLocationType.PLACE ? 1.0 : null, + type == ToolLocationType.PLACE ? PlaceType.CLASSROOM : null, null, 100); } } From 9d80e7ae0d78af4463d65aa520de1eace33d6d4a Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Sun, 27 Sep 2026 23:56:26 +0900 Subject: [PATCH 14/21] chore(chatbot): add raw tool call diagnostics --- .../chatbot/gateway/SpringAiLlmGateway.java | 61 +++++++++++++++++-- .../search/ChatbotCampusSearchService.java | 58 +++++++++++++++--- .../search/ChatbotSearchCandidate.java | 3 +- .../ChatbotCampusSearchServiceTest.java | 29 +++++++-- .../chatbot/tool/CampusToolAdapterTest.java | 2 +- 5 files changed, 134 insertions(+), 19 deletions(-) diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java index c2964f9f..3ed4d74b 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java @@ -26,6 +26,11 @@ import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.metadata.ToolMetadata; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -142,11 +147,12 @@ private GatewayResult invoke(String systemPrompt, List hist try { List springMessages = toSpringMessages(history, userMessage); logRequestMessageDiagnostics(springMessages); - ChatClient.CallResponseSpec callResponse = chatClient.prompt() + ChatClient.ChatClientRequestSpec request = chatClient.prompt() .system(systemPrompt) - .messages(springMessages) - .tools(requestTools) - .call(); + .messages(springMessages); + ChatClient.CallResponseSpec callResponse = (diagnosticsEnabled + ? request.toolCallbacks(diagnosticToolCallbacks(requestTools)) + : request.tools(requestTools)).call(); ChatClientResponse clientResponse = callResponse == null ? null : callResponse.chatClientResponse(); ChatResponse chatResponse = clientResponse == null ? null : clientResponse.chatResponse(); String content = extractContent(chatResponse); @@ -174,6 +180,53 @@ private GatewayResult invoke(String systemPrompt, List hist } } + /** + * ToolCallbacks.from creates MethodToolCallback instances. Wrapping at the ToolCallback boundary logs the + * model-produced JSON before MethodToolCallback performs Jackson argument binding. + */ + private List diagnosticToolCallbacks(CampusChatbotTools requestTools) { + return java.util.Arrays.stream(ToolCallbacks.from(requestTools)) + .map(callback -> (ToolCallback) new DiagnosticToolCallback(callback)) + .toList(); + } + + private final class DiagnosticToolCallback implements ToolCallback { + private final ToolCallback delegate; + + private DiagnosticToolCallback(ToolCallback delegate) { + this.delegate = delegate; + } + + @Override + public ToolDefinition getToolDefinition() { + return delegate.getToolDefinition(); + } + + @Override + public ToolMetadata getToolMetadata() { + return delegate.getToolMetadata(); + } + + @Override + public String call(String toolInput) { + log.warn("chatbot_raw_tool_call toolName={} callbackType={} arguments={}", + toolName(), delegate.getClass().getName(), toolInput); + return delegate.call(toolInput); + } + + @Override + public String call(String toolInput, ToolContext toolContext) { + log.warn("chatbot_raw_tool_call toolName={} callbackType={} arguments={}", + toolName(), delegate.getClass().getName(), toolInput); + return delegate.call(toolInput, toolContext); + } + + private String toolName() { + ToolDefinition definition = delegate.getToolDefinition(); + return definition == null ? "unknown" : definition.name(); + } + } + private void logEmptyCompletionDiagnostics(ChatResponse response, ChatbotToolCallLimiter.Scope scope, ResolvedLocationCollector collector) { if (!diagnosticsEnabled) { diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotCampusSearchService.java b/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotCampusSearchService.java index 3fd9b9bd..684617bd 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotCampusSearchService.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotCampusSearchService.java @@ -1,9 +1,7 @@ package devkor.com.teamcback.domain.chatbot.search; -import devkor.com.teamcback.domain.building.entity.BuildingNickname; import devkor.com.teamcback.domain.building.repository.BuildingNicknameRepository; import devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType; -import devkor.com.teamcback.domain.place.entity.PlaceNickname; import devkor.com.teamcback.domain.place.repository.PlaceNicknameRepository; import devkor.com.teamcback.domain.search.util.HangeulUtils; import java.util.ArrayList; @@ -61,10 +59,14 @@ public List search(String query) { unique.put(key, candidate); } } - return unique.values().stream() + List ranked = unique.values().stream() .sorted(java.util.Comparator.comparingInt(ChatbotSearchCandidate::sourcePriority).reversed() .thenComparing(ChatbotSearchCandidate::name, java.util.Comparator.nullsLast(String::compareTo))) .toList(); + List strong = ranked.stream() + .filter(candidate -> stronglyMatchesWholeQuery(trimmed, candidate)) + .toList(); + return strong.isEmpty() ? ranked : strong; } private void addCompositeCandidates(List target, String buildingWord, @@ -78,8 +80,8 @@ private void addCompositeCandidates(List target, String if (buildingIds.isEmpty()) { return; } - addBuildingCandidates(target, buildings, priority); - addPlaceCandidates(target, findPlaces(compact(placeWord), buildingIds, limit), priority - 1); + addBuildingCandidates(target, buildings, priority, true); + addPlaceCandidates(target, findPlaces(compact(placeWord), buildingIds, limit), priority - 1, true); } private List findBuildings(String word, Pageable limit) { @@ -114,24 +116,66 @@ private List findPlaces(String word, List buildingI private void addBuildingCandidates(List target, List source, int priority) { + addBuildingCandidates(target, source, priority, false); + } + + private void addBuildingCandidates(List target, + List source, int priority, boolean composite) { for (ChatbotBuildingCandidate item : source) { if (item.id() != null) { target.add(new ChatbotSearchCandidate(item.id(), ToolLocationType.BUILDING, item.name(), - item.id(), item.name(), null, null, null, priority)); + item.id(), item.name(), null, null, null, priority, item.nickname(), composite)); } } } private void addPlaceCandidates(List target, List source, int priority) { + addPlaceCandidates(target, source, priority, false); + } + + private void addPlaceCandidates(List target, + List source, int priority, boolean composite) { for (ChatbotPlaceCandidate item : source) { if (item.id() != null) { target.add(new ChatbotSearchCandidate(item.id(), ToolLocationType.PLACE, item.name(), - item.buildingId(), item.buildingName(), item.floor(), item.placeType(), item.detail(), priority)); + item.buildingId(), item.buildingName(), item.floor(), item.placeType(), item.detail(), priority, + item.nickname(), composite)); } } } + private boolean stronglyMatchesWholeQuery(String query, ChatbotSearchCandidate candidate) { + String normalizedQuery = normalize(query); + if (candidate.locationType() == ToolLocationType.BUILDING) { + return normalizedQuery.equals(normalize(candidate.name())) + || normalizedQuery.equals(normalize(candidate.matchedNickname())); + } + + if (!candidate.compositeQuery()) { + return normalizedQuery.equals(normalize(candidate.name())) + || normalizedQuery.equals(normalize(candidate.matchedNickname())); + } + + String parentAndPlace = normalize((candidate.buildingName() == null ? "" : candidate.buildingName()) + + (candidate.name() == null ? "" : candidate.name())); + String parentAndNickname = normalize((candidate.buildingName() == null ? "" : candidate.buildingName()) + + (candidate.matchedNickname() == null ? "" : candidate.matchedNickname())); + return normalizedQuery.equals(parentAndPlace) || normalizedQuery.equals(parentAndNickname); + } + + private String normalize(String value) { + if (value == null) { + return ""; + } + return value.replaceAll("\\s+", "") + .replace("(", "") + .replace(")", "") + .replace("[", "") + .replace("]", "") + .toLowerCase(Locale.ROOT); + } + private String compact(String value) { return value.replaceAll("\\s+", "").toLowerCase(Locale.ROOT); } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotSearchCandidate.java b/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotSearchCandidate.java index 5c375baa..a6b5c882 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotSearchCandidate.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotSearchCandidate.java @@ -6,5 +6,6 @@ /** Internal candidate; it is converted to the existing Tool DTO by the adapter. */ public record ChatbotSearchCandidate(Long locationId, ToolLocationType locationType, String name, Long buildingId, String buildingName, Double floor, - PlaceType placeType, String detail, int sourcePriority) { + PlaceType placeType, String detail, int sourcePriority, + String matchedNickname, boolean compositeQuery) { } diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/search/ChatbotCampusSearchServiceTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/search/ChatbotCampusSearchServiceTest.java index c4c9ec48..e0484279 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/search/ChatbotCampusSearchServiceTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/search/ChatbotCampusSearchServiceTest.java @@ -43,7 +43,7 @@ void resolvesBuildingAndPlaceWithoutLoadingNodeOrWholeBuildingPlaces() { List result = service.search("main"); assertThat(result).extracting(ChatbotSearchCandidate::locationId) - .containsExactly(1L, 2L); + .containsExactly(1L); verify(buildingNicknameRepository).findChatbotByJaso(any(), any()); verify(placeNicknameRepository).findChatbotByJaso(any(), any()); } @@ -52,26 +52,43 @@ void resolvesBuildingAndPlaceWithoutLoadingNodeOrWholeBuildingPlaces() { void compositeSearchUsesBuildingIdsForPlaceProjection() { lenient().when(buildingNicknameRepository.findChatbotByJaso(any(), any())).thenReturn(List.of()); when(buildingNicknameRepository.findChatbotByJaso(eq("building"), any())) - .thenReturn(List.of(new ChatbotBuildingCandidate(7L, "Building", "building"))); + .thenReturn(List.of(new ChatbotBuildingCandidate(7L, "Building Room", "building"))); when(buildingNicknameRepository.findChatbotByJaso(eq("room"), any())) .thenReturn(List.of()); when(placeNicknameRepository.findChatbotByJasoAndBuildingIds(eq("room"), eq(List.of(7L)), any())) - .thenReturn(List.of(new ChatbotPlaceCandidate(8L, "Room", 7L, - "Building", 1.0, PlaceType.CLASSROOM, null, "room"))); + .thenReturn(List.of(new ChatbotPlaceCandidate(8L, "109C", 7L, + "Building Room", 1.0, PlaceType.CLASSROOM, null, "room"))); List result = service.search("building room"); assertThat(result).extracting(ChatbotSearchCandidate::locationId) - .containsExactly(7L, 8L); + .containsExactly(7L); verify(placeNicknameRepository).findChatbotByJasoAndBuildingIds(eq("room"), eq(List.of(7L)), any()); } + @Test + void keepsSpecificPlaceWhenWholeQueryIdentifiesParentAndPlace() { + lenient().when(buildingNicknameRepository.findChatbotByJaso(any(), any())).thenReturn(List.of()); + when(buildingNicknameRepository.findChatbotByJaso(eq("building"), any())) + .thenReturn(List.of(new ChatbotBuildingCandidate(7L, "Building Room", "building"))); + when(placeNicknameRepository.findChatbotByJasoAndBuildingIds(eq("room129b"), eq(List.of(7L)), any())) + .thenReturn(List.of(new ChatbotPlaceCandidate(8L, "129B", 7L, + "Building Room", 1.0, PlaceType.CLASSROOM, null, "129B"))); + + List result = service.search("building room 129B"); + + assertThat(result).extracting(ChatbotSearchCandidate::locationId) + .containsExactly(8L); + assertThat(result.get(0).locationType()).isEqualTo( + devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType.PLACE); + } + @Test void deduplicatesSameLocationAndKeepsDistinctIds() { when(buildingNicknameRepository.findChatbotByJaso(any(), any())).thenReturn(List.of( new ChatbotBuildingCandidate(1L, "Building", "one"), new ChatbotBuildingCandidate(1L, "Building", "alias"), - new ChatbotBuildingCandidate(2L, "Building Annex", "two"))); + new ChatbotBuildingCandidate(2L, "Building Annex", "building"))); List result = service.search("building"); diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java index 49b05501..75fe59ff 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java @@ -130,6 +130,6 @@ private ChatbotSearchCandidate candidate(long id, String name, ToolLocationType return new ChatbotSearchCandidate(id, type, name, type == ToolLocationType.PLACE ? 1L : id, type == ToolLocationType.PLACE ? "건물" : name, type == ToolLocationType.PLACE ? 1.0 : null, - type == ToolLocationType.PLACE ? PlaceType.CLASSROOM : null, null, 100); + type == ToolLocationType.PLACE ? PlaceType.CLASSROOM : null, null, 100, name, false); } } From 50d7b25d4a6c26853a7a37ebd0099ce8890e0ce7 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Mon, 28 Sep 2026 01:45:58 +0900 Subject: [PATCH 15/21] feat(chatbot): add backend-owned crowd status workflow --- build.gradle | 3 + .../ble/repository/BLEDeviceRepository.java | 15 + .../chatbot/crowd/CrowdPlaceCandidate.java | 9 + .../chatbot/crowd/CrowdTargetResolver.java | 153 +++++++ .../domain/chatbot/dto/CrowdStatusPlan.java | 21 + .../domain/chatbot/gateway/LlmGateway.java | 5 + .../chatbot/gateway/SpringAiLlmGateway.java | 42 ++ .../domain/chatbot/service/ChatService.java | 23 +- .../chatbot/service/CrowdStatusPlanner.java | 29 ++ .../chatbot/service/CrowdStatusWorkflow.java | 402 ++++++++++++++++++ .../crowd/CrowdTargetResolverTest.java | 76 ++++ .../gateway/SpringAiLlmGatewayTest.java | 25 ++ .../service/CrowdStatusWorkflowTest.java | 194 +++++++++ 13 files changed, 995 insertions(+), 2 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/crowd/CrowdPlaceCandidate.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/crowd/CrowdTargetResolver.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdStatusPlan.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusPlanner.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflow.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/crowd/CrowdTargetResolverTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflowTest.java diff --git a/build.gradle b/build.gradle index 4f945975..4b320f00 100644 --- a/build.gradle +++ b/build.gradle @@ -51,6 +51,9 @@ dependencies { // Spring AI implementation 'org.springframework.ai:spring-ai-starter-model-vertex-ai-gemini' + // LangGraph4j core is used only by the CROWD_STATUS workflow POC. + implementation 'org.bsc.langgraph4j:langgraph4j-core:1.9.2' + // h2 runtimeOnly 'com.h2database:h2' diff --git a/src/main/java/devkor/com/teamcback/domain/ble/repository/BLEDeviceRepository.java b/src/main/java/devkor/com/teamcback/domain/ble/repository/BLEDeviceRepository.java index 02c50593..aaa8d869 100644 --- a/src/main/java/devkor/com/teamcback/domain/ble/repository/BLEDeviceRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/ble/repository/BLEDeviceRepository.java @@ -2,6 +2,9 @@ import devkor.com.teamcback.domain.ble.entity.BLEDevice; import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.chatbot.crowd.CrowdPlaceCandidate; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; @@ -11,4 +14,16 @@ public interface BLEDeviceRepository extends JpaRepository { BLEDevice findByPlace(Place place); boolean existsByDeviceName(String deviceName); boolean existsByPlace(Place place); + + @Query("select new devkor.com.teamcback.domain.chatbot.crowd.CrowdPlaceCandidate(" + + "d.place.id, d.deviceName, d.place.name, d.place.floor, d.place.type, " + + "d.place.building.id, d.place.building.name) " + + "from BLEDevice d where d.place.building.id = :buildingId") + List findChatbotCrowdPlacesByBuildingId(@Param("buildingId") Long buildingId); + + @Query("select new devkor.com.teamcback.domain.chatbot.crowd.CrowdPlaceCandidate(" + + "d.place.id, d.deviceName, d.place.name, d.place.floor, d.place.type, " + + "d.place.building.id, d.place.building.name) " + + "from BLEDevice d") + List findAllChatbotCrowdPlaces(); } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/crowd/CrowdPlaceCandidate.java b/src/main/java/devkor/com/teamcback/domain/chatbot/crowd/CrowdPlaceCandidate.java new file mode 100644 index 00000000..8a002096 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/crowd/CrowdPlaceCandidate.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.chatbot.crowd; + +import devkor.com.teamcback.domain.place.entity.PlaceType; + +/** Minimal projection of a place that has a real BLE crowd device. */ +public record CrowdPlaceCandidate(Long placeId, String deviceName, String placeName, + Double floor, PlaceType placeType, Long buildingId, + String buildingName) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/crowd/CrowdTargetResolver.java b/src/main/java/devkor/com/teamcback/domain/chatbot/crowd/CrowdTargetResolver.java new file mode 100644 index 00000000..7993aae8 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/crowd/CrowdTargetResolver.java @@ -0,0 +1,153 @@ +package devkor.com.teamcback.domain.chatbot.crowd; + +import devkor.com.teamcback.domain.ble.repository.BLEDeviceRepository; +import devkor.com.teamcback.domain.chatbot.search.ChatbotSearchCandidate; +import devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** Resolves crowd targets only from places backed by an actual BLE device. */ +@Component +public class CrowdTargetResolver { + private final BLEDeviceRepository bleDeviceRepository; + + public CrowdTargetResolver(BLEDeviceRepository bleDeviceRepository) { + this.bleDeviceRepository = bleDeviceRepository; + } + + @Transactional(readOnly = true) + public Resolution resolve(String query, List locations) { + String normalized = normalize(query); + if (normalized.isBlank()) { + return Resolution.notFound(); + } + List all = bleDeviceRepository.findAllChatbotCrowdPlaces(); + List direct = all.stream() + .filter(candidate -> matches(query, candidate, true)) + .toList(); + if (!direct.isEmpty()) { + return Resolution.of(dedupe(direct)); + } + ChatbotSearchCandidate building = uniqueBuilding(locations); + if (building == null) { + return Resolution.notFound(); + } + return byBuilding(building.locationId()); + } + + @Transactional(readOnly = true) + public Resolution byBuilding(Long buildingId) { + if (buildingId == null || buildingId <= 0) { + return Resolution.notFound(); + } + return Resolution.of(dedupe(bleDeviceRepository.findChatbotCrowdPlacesByBuildingId(buildingId))); + } + + @Transactional(readOnly = true) + public Resolution resolveWithinBuilding(String query, Long buildingId) { + String normalized = normalize(query); + if (normalized.isBlank() || buildingId == null) { + return Resolution.notFound(); + } + List candidates = bleDeviceRepository.findChatbotCrowdPlacesByBuildingId(buildingId); + List matched = candidates.stream() + .filter(candidate -> matches(query, candidate, false)) + .toList(); + return Resolution.of(dedupe(matched)); + } + + private boolean matches(String query, CrowdPlaceCandidate candidate, boolean allowBuildingMatch) { + String normalizedQuery = normalize(query); + String queryWords = normalizeWords(query); + String device = normalize(candidate.deviceName()); + String place = normalize(candidate.placeName()); + String building = normalize(candidate.buildingName()); + String buildingAndPlace = normalize(building + place); + String deviceWords = normalizeWords(candidate.deviceName()); + String buildingAndPlaceWords = normalizeWords(candidate.buildingName()) + + " " + normalizeWords(candidate.placeName()); + return normalizedQuery.equals(device) + || normalizedQuery.equals(buildingAndPlace) + || normalizedQuery.equals(place) + || (!device.isBlank() && device.contains(normalizedQuery)) + || containsAllTokens(queryWords, deviceWords) + || containsAllTokens(queryWords, buildingAndPlaceWords) + || (allowBuildingMatch && !building.isBlank() && normalizedQuery.equals(building)); + } + + private boolean containsAllTokens(String query, String candidate) { + if (query.isBlank() || candidate.isBlank()) { + return false; + } + for (String token : query.split(" ")) { + if (!candidate.contains(token)) { + return false; + } + } + return true; + } + + private ChatbotSearchCandidate uniqueBuilding(List locations) { + if (locations == null) { + return null; + } + List buildings = locations.stream() + .filter(candidate -> candidate.locationType() == ToolLocationType.BUILDING) + .toList(); + if (buildings.size() != 1) { + return null; + } + return buildings.get(0); + } + + private List dedupe(List candidates) { + Map unique = new LinkedHashMap<>(); + for (CrowdPlaceCandidate candidate : candidates) { + if (candidate != null && candidate.placeId() != null) { + unique.putIfAbsent(candidate.placeId(), candidate); + } + } + return List.copyOf(unique.values()); + } + + private String normalize(String value) { + return value == null ? "" : value.replaceAll("\\s+", "") + .replace("(", "").replace(")", "") + .toLowerCase(Locale.ROOT); + } + + private String normalizeWords(String value) { + return value == null ? "" : value.replace("(", "").replace(")", "") + .toLowerCase(Locale.ROOT).trim().replaceAll("\\s+", " "); + } + + public record Resolution(Status status, List candidates) { + public Resolution { + status = status == null ? Status.NOT_FOUND : status; + candidates = candidates == null ? List.of() : List.copyOf(candidates); + } + + public static Resolution of(List candidates) { + if (candidates == null || candidates.isEmpty()) return notFound(); + return new Resolution(candidates.size() == 1 ? Status.UNIQUE : Status.AMBIGUOUS, candidates); + } + + public static Resolution notFound() { + return new Resolution(Status.NOT_FOUND, List.of()); + } + + public static Resolution ofForCompatibility(List candidates) { + return of(candidates); + } + + public static Resolution notFoundForCompatibility() { + return notFound(); + } + + public enum Status { UNIQUE, AMBIGUOUS, NOT_FOUND } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdStatusPlan.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdStatusPlan.java new file mode 100644 index 00000000..6bbc7db4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdStatusPlan.java @@ -0,0 +1,21 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +/** Provider-neutral interpretation used only by the CROWD_STATUS proof of concept. */ +public record CrowdStatusPlan(Intent intent, String locationQuery) { + public CrowdStatusPlan { + intent = intent == null ? Intent.OTHER : intent; + locationQuery = locationQuery == null ? null : locationQuery.trim(); + } + + public static CrowdStatusPlan other() { + return new CrowdStatusPlan(Intent.OTHER, null); + } + + public boolean isCrowdStatus() { + return intent == Intent.CROWD_STATUS && locationQuery != null && !locationQuery.isBlank(); + } + + public enum Intent { + CROWD_STATUS, OTHER + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java index 18883662..c585c52c 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java @@ -1,10 +1,15 @@ package devkor.com.teamcback.domain.chatbot.gateway; import devkor.com.teamcback.domain.chatbot.dto.RoutePlan; +import devkor.com.teamcback.domain.chatbot.dto.CrowdStatusPlan; import devkor.com.teamcback.domain.chatbot.service.ResolvedLocationCollector; import java.util.List; public interface LlmGateway { + /** Structured intent/slot extraction for the isolated crowd-status POC. */ + default CrowdStatusPlan planCrowd(String systemPrompt, List history, String userMessage) { + return CrowdStatusPlan.other(); + } /** Structured route interpretation; provider implementations must not resolve IDs or execute routes. */ default RoutePlan planRoute(String systemPrompt, List history, String userMessage) { return RoutePlan.notRoute(); diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java index 3ed4d74b..9f5e1aaf 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java @@ -4,6 +4,7 @@ import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; import devkor.com.teamcback.domain.chatbot.dto.RoutePlan; +import devkor.com.teamcback.domain.chatbot.dto.CrowdStatusPlan; import devkor.com.teamcback.domain.chatbot.service.ResolvedLocationCollector; import devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter; import devkor.com.teamcback.domain.chatbot.service.ToolCallLimitExceededException; @@ -61,6 +62,47 @@ public SpringAiLlmGateway(ChatClient.Builder chatClientBuilder, ChatbotPropertie this.toolCallLimiter = toolCallLimiter; } + @Override + public CrowdStatusPlan planCrowd(String systemPrompt, List history, String userMessage) { + Future response = chatbotLlmExecutor.submit( + () -> invokeCrowdPlan(systemPrompt, history, userMessage)); + try { + CrowdStatusPlan plan = response.get(properties.llm().timeout().toMillis(), TimeUnit.MILLISECONDS); + return plan == null ? CrowdStatusPlan.other() : plan; + } catch (InterruptedException exception) { + response.cancel(true); + Thread.currentThread().interrupt(); + return CrowdStatusPlan.other(); + } catch (ExecutionException | TimeoutException exception) { + response.cancel(true); + return CrowdStatusPlan.other(); + } + } + + private CrowdStatusPlan invokeCrowdPlan(String systemPrompt, List history, + String userMessage) { + BeanOutputConverter converter = new BeanOutputConverter<>(CrowdStatusPlan.class); + String plannerPrompt = """ + Determine whether the current user request asks for current campus crowd status. + Do not call tools and do not invent IDs, types, roles, conditions, or tool arguments. + Return only the structured CrowdStatusPlan requested by the output format. + Use CROWD_STATUS only for a crowd/congestion question; otherwise use OTHER. + For CROWD_STATUS, locationQuery must be only the natural-language campus location phrase. + Preserve the user's location wording and leave locationQuery null for OTHER. + Existing context: + %s + + Output format: + %s + """.formatted(systemPrompt == null ? "" : systemPrompt, converter.getFormat()); + ChatClient.CallResponseSpec response = chatClient.prompt() + .system(plannerPrompt) + .messages(toSpringMessages(history, userMessage)) + .call(); + CrowdStatusPlan plan = response == null ? null : response.entity(converter); + return plan == null ? CrowdStatusPlan.other() : plan; + } + @Override public RoutePlan planRoute(String systemPrompt, List history, String userMessage) { Future response = chatbotLlmExecutor.submit( diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java index 5bceb3b4..c2bc495c 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java @@ -66,27 +66,46 @@ NAVIGATE_ROUTE requires searchCampus(query, role=START, intent=NAVIGATE_ROUTE) a private final ChatSessionMemoryService memoryService; private final ChatRateLimiter rateLimiter; private final PendingRouteStateService pendingRouteStateService; + private final CrowdStatusWorkflow crowdStatusWorkflow; /** Compatibility constructor for focused unit tests that do not exercise pending state. */ public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService memoryService, ChatRateLimiter rateLimiter) { - this(chatOrchestrator, memoryService, rateLimiter, null); + this(chatOrchestrator, memoryService, rateLimiter, null, null); + } + + /** Compatibility constructor for tests that provide pending-route state only. */ + public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService memoryService, + ChatRateLimiter rateLimiter, PendingRouteStateService pendingRouteStateService) { + this(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService, null); } @Autowired public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService memoryService, ChatRateLimiter rateLimiter, - PendingRouteStateService pendingRouteStateService) { + PendingRouteStateService pendingRouteStateService, + CrowdStatusWorkflow crowdStatusWorkflow) { this.chatOrchestrator = chatOrchestrator; this.memoryService = memoryService; this.rateLimiter = rateLimiter; this.pendingRouteStateService = pendingRouteStateService; + this.crowdStatusWorkflow = crowdStatusWorkflow; } public ChatMessageRes sendMessage(ChatMessageReq request, ChatCaller caller) { UUID sessionId = request.sessionId() == null ? UUID.randomUUID() : request.sessionId(); rateLimiter.check(caller); var history = memoryService.load(sessionId, caller); + if (crowdStatusWorkflow != null) { + CrowdStatusWorkflow.CrowdWorkflowResult crowd = crowdStatusWorkflow.handle( + sessionId, caller, toGatewayHistory(history), request.message()); + if (crowd.handled()) { + String reply = crowd.reply() == null || crowd.reply().isBlank() + ? "혼잡도 요청을 처리하지 못했어요." : crowd.reply(); + memoryService.save(sessionId, caller, request.message(), reply); + return new ChatMessageRes(sessionId, reply, null); + } + } PendingRouteState pending = loadPending(sessionId, caller); ChatOrchestrationResult result = chatOrchestrator.execute(promptWithPendingState(pending), toGatewayHistory(history), messageWithRequestContext(request)); diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusPlanner.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusPlanner.java new file mode 100644 index 00000000..0aff1cc0 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusPlanner.java @@ -0,0 +1,29 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import devkor.com.teamcback.domain.chatbot.dto.CrowdStatusPlan; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** Keeps crowd intent/slot extraction separate from crowd capability execution. */ +@Component +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class CrowdStatusPlanner { + private static final String PROMPT = """ + Interpret only whether the user asks for current campus crowd/congestion status. + Return CROWD_STATUS with the natural-language locationQuery for a crowd question; + otherwise return OTHER. Do not resolve IDs, types, roles, conditions, or tool arguments. + """; + + private final LlmGateway gateway; + + public CrowdStatusPlanner(LlmGateway gateway) { + this.gateway = gateway; + } + + public CrowdStatusPlan plan(List history, String userMessage) { + CrowdStatusPlan result = gateway.planCrowd(PROMPT, history == null ? List.of() : history, userMessage); + return result == null ? CrowdStatusPlan.other() : result; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflow.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflow.java new file mode 100644 index 00000000..cd29bb37 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflow.java @@ -0,0 +1,402 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.bsc.langgraph4j.StateGraph.END; +import static org.bsc.langgraph4j.StateGraph.START; + +import devkor.com.teamcback.domain.chatbot.dto.CrowdStatusPlan; +import devkor.com.teamcback.domain.chatbot.crowd.CrowdPlaceCandidate; +import devkor.com.teamcback.domain.chatbot.crowd.CrowdTargetResolver; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import devkor.com.teamcback.domain.chatbot.search.ChatbotCampusSearchService; +import devkor.com.teamcback.domain.chatbot.search.ChatbotSearchCandidate; +import devkor.com.teamcback.domain.chatbot.tool.CampusToolAdapter; +import devkor.com.teamcback.domain.chatbot.tool.dto.CrowdStatusToolData; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetCrowdStatusToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetCrowdStatusToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.bsc.langgraph4j.CompileConfig; +import org.bsc.langgraph4j.CompiledGraph; +import org.bsc.langgraph4j.GraphInput; +import org.bsc.langgraph4j.GraphStateException; +import org.bsc.langgraph4j.RunnableConfig; +import org.bsc.langgraph4j.StateGraph; +import org.bsc.langgraph4j.action.AsyncNodeAction; +import org.bsc.langgraph4j.action.AsyncEdgeAction; +import org.bsc.langgraph4j.action.EdgeAction; +import org.bsc.langgraph4j.action.NodeAction; +import org.bsc.langgraph4j.checkpoint.MemorySaver; +import org.bsc.langgraph4j.state.AgentState; +import org.bsc.langgraph4j.state.Channel; +import org.bsc.langgraph4j.state.Channels; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * Backend-owned CROWD_STATUS proof of concept. It deliberately does not expose any campus tool to the model. + * MemorySaver is intentionally used only for this POC; production persistence belongs in a dedicated saver. + */ +@Component +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class CrowdStatusWorkflow { + private static final String RESOLVE = "resolve_location"; + private static final String ASK = "ask_confirmation"; + private static final String CONFIRM = "confirmation_check"; + private static final String TARGET = "resolve_crowd_target"; + private static final String LOAD = "load_crowd"; + private static final String FORMAT = "format_reply"; + private static final String OWNER = "owner"; + private static final String QUERY = "locationQuery"; + private static final String CANDIDATES = "candidates"; + private static final String SELECTED_ID = "selectedId"; + private static final String SELECTED_TYPE = "selectedType"; + private static final String SELECTED_NAME = "selectedName"; + private static final String REPLY = "reply"; + private static final String CONFIRMATION = "confirmation"; + private static final String CROWD_LEVEL = "crowdLevel"; + private static final String PEOPLE = "people"; + private static final String CAPACITY = "capacity"; + private static final String STALE = "stale"; + private static final String BUILDING_ID = "buildingId"; + private static final String AWAITING = "awaiting"; + private static final String BUILDING_CONFIRMATION = "BUILDING_CONFIRMATION"; + private static final String PLACE_SELECTION = "PLACE_SELECTION"; + + private final CrowdStatusPlanner planner; + private final ChatbotCampusSearchService searchService; + private final CampusToolAdapter campusToolAdapter; + private final CrowdTargetResolver targetResolver; + private final MemorySaver saver; + private final CompiledGraph graph; + + CrowdStatusWorkflow(CrowdStatusPlanner planner, ChatbotCampusSearchService searchService, + CampusToolAdapter campusToolAdapter) { + this(planner, searchService, campusToolAdapter, null); + } + + @Autowired + public CrowdStatusWorkflow(CrowdStatusPlanner planner, ChatbotCampusSearchService searchService, + CampusToolAdapter campusToolAdapter, CrowdTargetResolver targetResolver) { + this.planner = planner; + this.searchService = searchService; + this.campusToolAdapter = campusToolAdapter; + this.targetResolver = targetResolver; + try { + this.saver = new MemorySaver(); + StateGraph stateGraph = new StateGraph<>(CrowdGraphState.SCHEMA, + CrowdGraphState::new); + stateGraph.addNode(RESOLVE, AsyncNodeAction.node_async((NodeAction) this::resolveLocation)); + stateGraph.addNode(ASK, AsyncNodeAction.node_async((NodeAction) this::askConfirmation)); + stateGraph.addNode(CONFIRM, AsyncNodeAction.node_async((NodeAction) this::checkConfirmation)); + stateGraph.addNode(TARGET, AsyncNodeAction.node_async((NodeAction) this::resolveCrowdTarget)); + stateGraph.addNode(LOAD, AsyncNodeAction.node_async((NodeAction) this::loadCrowd)); + stateGraph.addNode(FORMAT, AsyncNodeAction.node_async((NodeAction) this::formatReply)); + stateGraph.addEdge(START, RESOLVE); + stateGraph.addConditionalEdges(RESOLVE, AsyncEdgeAction.edge_async((EdgeAction) this::nextAfterResolve), Map.of( + ASK, ASK, TARGET, TARGET, END, END)); + stateGraph.addEdge(ASK, CONFIRM); + stateGraph.addConditionalEdges(CONFIRM, + AsyncEdgeAction.edge_async((EdgeAction) this::nextAfterConfirmation), + Map.of(TARGET, TARGET, ASK, ASK, END, END)); + stateGraph.addConditionalEdges(TARGET, + AsyncEdgeAction.edge_async((EdgeAction) this::nextAfterTarget), + Map.of(LOAD, LOAD, FORMAT, FORMAT, TARGET, TARGET, ASK, ASK, END, END)); + stateGraph.addEdge(LOAD, FORMAT); + stateGraph.addEdge(FORMAT, END); + this.graph = stateGraph.compile(CompileConfig.builder() + .checkpointSaver(this.saver) + .interruptAfter(ASK) + .releaseThread(true) + .build()); + } catch (GraphStateException exception) { + throw new IllegalStateException("Unable to compile CROWD_STATUS workflow", exception); + } + } + + public CrowdWorkflowResult handle(UUID sessionId, ChatCaller caller, + List history, String userMessage) { + RunnableConfig config = RunnableConfig.builder().threadId(sessionId.toString()).build(); + var checkpoint = graph.lastStateOf(config); + if (checkpoint.isPresent()) { + CrowdGraphState state = checkpoint.get().state(); + if (!caller.key().equals(state.value(OWNER, ""))) { + throw new GlobalException(devkor.com.teamcback.global.response.ResultCode.CHATBOT_SESSION_FORBIDDEN); + } + CrowdWorkflowResult resumed = result(run(GraphInput.resume(Map.of(CONFIRMATION, userMessage)), config)); + releaseIfCompleted(resumed, config); + return resumed; + } + + CrowdStatusPlan plan = planner.plan(history, userMessage); + if (!plan.isCrowdStatus()) { + return CrowdWorkflowResult.notHandled(); + } + Map input = Map.of( + OWNER, caller.key(), + QUERY, plan.locationQuery()); + CrowdWorkflowResult started = result(run(GraphInput.args(input), config)); + releaseIfCompleted(started, config); + return started; + } + + private void releaseIfCompleted(CrowdWorkflowResult result, RunnableConfig config) { + if (!result.waiting()) { + try { + saver.release(config); + } catch (Exception exception) { + throw new IllegalStateException("Unable to release completed CROWD_STATUS workflow", exception); + } + } + } + + private CrowdGraphState run(GraphInput input, RunnableConfig config) { + try { + var state = graph.invoke(input, config); + if (state.isPresent()) { + return state.get(); + } + return graph.lastStateOf(config).map(snapshot -> snapshot.state()) + .orElseGet(() -> new CrowdGraphState(Map.of())); + } catch (Exception exception) { + throw new IllegalStateException("CROWD_STATUS workflow failed", exception); + } + } + + private CrowdWorkflowResult result(CrowdGraphState state) { + String reply = state.value(REPLY, ""); + boolean waiting = !state.value(AWAITING, "").isBlank() + || (!state.value(CANDIDATES, List.>of()).isEmpty() + && state.value(SELECTED_ID, "").isBlank()); + return new CrowdWorkflowResult(true, reply, waiting); + } + + private Map resolveLocation(CrowdGraphState state) { + List found = searchService.search(state.value(QUERY, "")); + CrowdTargetResolver.Resolution resolution = targetResolver == null + ? legacyResolution(found) : targetResolver.resolve(state.value(QUERY, ""), found); + ChatbotSearchCandidate building = uniqueBuilding(found); + boolean hasPlaceSearchCandidate = found.stream() + .anyMatch(candidate -> candidate.locationType() == ToolLocationType.PLACE); + if (building != null && resolution.status() == CrowdTargetResolver.Resolution.Status.NOT_FOUND + && !isPartialBuilding(building)) { + return Map.of(REPLY, building.name() + "에서 확인 가능한 혼잡도 장소가 없어요.", + SELECTED_ID, "", AWAITING, ""); + } + if (building != null && isPartialBuilding(building) + && !(hasPlaceSearchCandidate && resolution.status() == CrowdTargetResolver.Resolution.Status.UNIQUE)) { + return Map.of(CANDIDATES, List.of(locationMap(building.locationId(), ToolLocationType.BUILDING, + building.name(), "PARTIAL")), + SELECTED_ID, String.valueOf(building.locationId()), + SELECTED_TYPE, ToolLocationType.BUILDING.name(), + SELECTED_NAME, building.name(), + BUILDING_ID, String.valueOf(building.locationId()), + AWAITING, BUILDING_CONFIRMATION); + } + List> candidates = resolution.candidates().stream() + .limit(5).map(this::crowdMap).toList(); + if (candidates.size() == 1) { + Map candidate = candidates.get(0); + return Map.of(CANDIDATES, candidates, + SELECTED_ID, String.valueOf(candidate.get("id")), + SELECTED_TYPE, candidate.get("type"), + SELECTED_NAME, candidate.get("name"), + AWAITING, ""); + } + return Map.of(CANDIDATES, candidates, + BUILDING_ID, building == null ? "" : String.valueOf(building.locationId()), + AWAITING, candidates.size() > 1 ? PLACE_SELECTION : ""); + } + + private CrowdTargetResolver.Resolution legacyResolution(List found) { + List candidates = found == null ? List.of() : found.stream() + .filter(candidate -> candidate.locationType() == ToolLocationType.PLACE) + .map(candidate -> new CrowdPlaceCandidate(candidate.locationId(), candidate.name(), candidate.name(), + candidate.floor(), candidate.placeType(), candidate.buildingId(), candidate.buildingName())) + .toList(); + return candidates.isEmpty() ? CrowdTargetResolver.Resolution.notFoundForCompatibility() + : CrowdTargetResolver.Resolution.ofForCompatibility(candidates); + } + + private ChatbotSearchCandidate uniqueBuilding(List found) { + if (found == null) return null; + List buildings = found.stream() + .filter(candidate -> candidate.locationType() == ToolLocationType.BUILDING).toList(); + return buildings.size() == 1 ? buildings.get(0) : null; + } + + private boolean isPartialBuilding(ChatbotSearchCandidate candidate) { + return candidate.sourcePriority() < 300; + } + + private Map crowdMap(CrowdPlaceCandidate candidate) { + return locationMap(candidate.placeId(), ToolLocationType.PLACE, + candidate.deviceName() == null || candidate.deviceName().isBlank() + ? candidate.placeName() : candidate.deviceName(), "EXACT"); + } + + private Map locationMap(Long id, ToolLocationType type, String name, String matchType) { + Map value = new HashMap<>(); + value.put("id", id); + value.put("type", type.name()); + value.put("name", name); + value.put("matchType", matchType); + return value; + } + + private Map askConfirmation(CrowdGraphState state) { + List> candidates = state.value(CANDIDATES, List.of()); + if (PLACE_SELECTION.equals(state.value(AWAITING, ""))) { + String names = candidates.stream().map(item -> String.valueOf(item.get("name"))).distinct() + .reduce((left, right) -> left + ", " + right).orElse(""); + return Map.of(REPLY, "혼잡도를 확인할 장소를 하나 골라 주세요: " + names); + } + String name = candidates.size() == 1 ? String.valueOf(candidates.get(0).get("name")) + : candidates.stream().map(item -> String.valueOf(item.get("name"))).distinct() + .reduce((left, right) -> left + ", " + right).orElse(""); + String reply = candidates.size() == 1 + ? name + "을(를) 말씀하시나요?" + : "어느 장소의 혼잡도를 확인할까요? " + name; + return Map.of(REPLY, reply); + } + + private Map checkConfirmation(CrowdGraphState state) { + String confirmation = state.value(CONFIRMATION, "").trim(); + if (PLACE_SELECTION.equals(state.value(AWAITING, ""))) { + CrowdTargetResolver.Resolution selection = targetResolver == null + ? CrowdTargetResolver.Resolution.notFound() + : targetResolver.resolveWithinBuilding(confirmation, + parseLong(state.value(BUILDING_ID, ""))); + if (selection.status() == CrowdTargetResolver.Resolution.Status.UNIQUE) { + Map candidate = crowdMap(selection.candidates().get(0)); + return Map.of(SELECTED_ID, String.valueOf(candidate.get("id")), + SELECTED_TYPE, candidate.get("type"), SELECTED_NAME, candidate.get("name"), + AWAITING, "", REPLY, "", CONFIRMATION, ""); + } + return Map.of(REPLY, selection.status() == CrowdTargetResolver.Resolution.Status.AMBIGUOUS + ? "후보 중 하나를 더 구체적으로 골라 주세요." + : "확인할 장소를 후보 이름으로 알려주세요.", + SELECTED_ID, "", AWAITING, PLACE_SELECTION, CONFIRMATION, ""); + } + if (!isYes(confirmation)) { + return Map.of(REPLY, "확인할 장소를 다시 알려주세요.", SELECTED_ID, ""); + } + List> candidates = state.value(CANDIDATES, List.of()); + if (candidates.size() != 1) { + return Map.of(REPLY, "후보 중 하나를 장소 이름으로 알려주세요.", SELECTED_ID, ""); + } + Map candidate = candidates.get(0); + return Map.of(SELECTED_ID, String.valueOf(candidate.get("id")), + SELECTED_TYPE, candidate.get("type"), SELECTED_NAME, candidate.get("name"), + REPLY, "", CONFIRMATION, "", AWAITING, ""); + } + + private Map loadCrowd(CrowdGraphState state) { + Long placeId = Long.valueOf(state.value(SELECTED_ID, "0")); + GetCrowdStatusToolResult result = campusToolAdapter.getCrowdStatus( + new GetCrowdStatusToolRequest(placeId, false)); + if (result == null || result.error() != null || result.crowd() == null) { + return Map.of(REPLY, "해당 장소의 혼잡도 데이터를 확인할 수 없어요."); + } + CrowdStatusToolData crowd = result.crowd(); + Map updates = new HashMap<>(); + updates.put(PEOPLE, String.valueOf(crowd.estimatedPeople())); + updates.put(CAPACITY, String.valueOf(crowd.capacity())); + updates.put(CROWD_LEVEL, crowd.level() == null ? "" : crowd.level().name()); + updates.put(STALE, String.valueOf(crowd.stale())); + return updates; + } + + private Map resolveCrowdTarget(CrowdGraphState state) { + if (!ToolLocationType.PLACE.name().equals(state.value(SELECTED_TYPE, ""))) { + CrowdTargetResolver.Resolution resolution = targetResolver == null + ? CrowdTargetResolver.Resolution.notFound() + : targetResolver.byBuilding(parseLong(state.value(BUILDING_ID, state.value(SELECTED_ID, "")))); + List> candidates = resolution.candidates().stream() + .limit(5).map(this::crowdMap).toList(); + if (candidates.size() == 1) { + Map candidate = candidates.get(0); + return Map.of(CANDIDATES, candidates, SELECTED_ID, String.valueOf(candidate.get("id")), + SELECTED_TYPE, candidate.get("type"), SELECTED_NAME, candidate.get("name"), + AWAITING, ""); + } + if (candidates.size() > 1) { + return Map.of(CANDIDATES, candidates, SELECTED_ID, "", AWAITING, PLACE_SELECTION); + } + return Map.of(REPLY, state.value(SELECTED_NAME, "장소") + + "에서 확인 가능한 혼잡도 장소가 없어요.", SELECTED_ID, "", AWAITING, ""); + } + return Map.of(); + } + + private Map formatReply(CrowdGraphState state) { + if (!state.value(REPLY, "").isBlank()) { + return Map.of(); + } + String level = state.value(CROWD_LEVEL, ""); + String name = state.value(SELECTED_NAME, "해당 장소"); + return Map.of(REPLY, "현재 " + name + "의 혼잡도는 " + level + "이에요."); + } + + private String nextAfterResolve(CrowdGraphState state) { + List> candidates = state.value(CANDIDATES, List.of()); + if (candidates.isEmpty()) return END; + if (candidates.size() == 1 + && ToolLocationType.PLACE.name().equals(state.value(SELECTED_TYPE, "")) + && "EXACT".equals(candidates.get(0).get("matchType"))) { + return TARGET; + } + return ASK; + } + + private String nextAfterConfirmation(CrowdGraphState state) { + if (state.value(SELECTED_ID, "").isBlank() + && PLACE_SELECTION.equals(state.value(AWAITING, ""))) return ASK; + return state.value(SELECTED_ID, "").isBlank() ? END : TARGET; + } + + private String nextAfterTarget(CrowdGraphState state) { + if (PLACE_SELECTION.equals(state.value(AWAITING, ""))) return ASK; + return ToolLocationType.PLACE.name().equals(state.value(SELECTED_TYPE, "")) ? LOAD : FORMAT; + } + + private Long parseLong(String value) { + try { + return value == null || value.isBlank() ? null : Long.valueOf(value); + } catch (NumberFormatException exception) { + return null; + } + } + + private boolean isYes(String value) { + return switch (value) { + case "네", "예", "응", "맞아", "맞아요", "맞습니다", "그래", "그거야" -> true; + default -> false; + }; + } + + public record CrowdWorkflowResult(boolean handled, String reply, boolean waiting) { + public static CrowdWorkflowResult notHandled() { + return new CrowdWorkflowResult(false, null, false); + } + } + + public static final class CrowdGraphState extends AgentState { + private static Channel text() { return Channels.base(() -> ""); } + private static Channel>> list() { return Channels.base(ArrayList::new); } + public static final Map> SCHEMA = Map.ofEntries( + Map.entry(OWNER, text()), Map.entry(QUERY, text()), Map.entry(CANDIDATES, list()), + Map.entry(SELECTED_ID, text()), Map.entry(SELECTED_TYPE, text()), + Map.entry(SELECTED_NAME, text()), Map.entry(REPLY, text()), + Map.entry(CONFIRMATION, text()), Map.entry(CROWD_LEVEL, text()), + Map.entry(PEOPLE, text()), Map.entry(CAPACITY, text()), Map.entry(STALE, text()), + Map.entry(BUILDING_ID, text()), Map.entry(AWAITING, text())); + + public CrowdGraphState(Map data) { super(data); } + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/crowd/CrowdTargetResolverTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/crowd/CrowdTargetResolverTest.java new file mode 100644 index 00000000..88674ec6 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/crowd/CrowdTargetResolverTest.java @@ -0,0 +1,76 @@ +package devkor.com.teamcback.domain.chatbot.crowd; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.ble.repository.BLEDeviceRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CrowdTargetResolverTest { + @Mock BLEDeviceRepository repository; + + @Test + void matchesDeviceNameToTheActualPlaceId() { + when(repository.findAllChatbotCrowdPlaces()).thenReturn(List.of( + candidate(4429L, "SK미래관 3층 라운지", 33L), + candidate(4443L, "SK미래관 블루포트", 33L))); + + CrowdTargetResolver.Resolution lounge = new CrowdTargetResolver(repository) + .resolve("SK미래관 3층 라운지", List.of()); + CrowdTargetResolver.Resolution cafe = new CrowdTargetResolver(repository) + .resolve("SK미래관 블루포트", List.of()); + + assertThat(lounge.status()).isEqualTo(CrowdTargetResolver.Resolution.Status.UNIQUE); + assertThat(lounge.candidates()).extracting(CrowdPlaceCandidate::placeId).containsExactly(4429L); + assertThat(cafe.candidates()).extracting(CrowdPlaceCandidate::placeId).containsExactly(4443L); + } + + @Test + void buildingResolutionReturnsDistinctCrowdPlaces() { + when(repository.findChatbotCrowdPlacesByBuildingId(33L)).thenReturn(List.of( + candidate(4385L, "SK미래관 B1층 라운지", 33L), + candidate(4385L, "SK미래관 B1층 라운지", 33L), + candidate(4479L, "SK미래관 라운지 517호", 33L))); + + CrowdTargetResolver.Resolution resolution = new CrowdTargetResolver(repository).byBuilding(33L); + + assertThat(resolution.status()).isEqualTo(CrowdTargetResolver.Resolution.Status.AMBIGUOUS); + assertThat(resolution.candidates()).extracting(CrowdPlaceCandidate::placeId) + .containsExactly(4385L, 4479L); + } + + @Test + void resolvesSelectionsWithinBuildingFromCanonicalCrowdNames() { + when(repository.findChatbotCrowdPlacesByBuildingId(33L)).thenReturn(List.of( + candidate(4385L, "SK\uBBF8\uB798\uAD00 B1\uCE35 \uB77C\uC6B4\uC9C0", 33L), + candidate(4429L, "SK\uBBF8\uB798\uAD00 3\uCE35 \uB77C\uC6B4\uC9C0", 33L), + candidate(4443L, "SK\uBBF8\uB798\uAD00 \uBE14\uB8E8\uD3EC\uD2B8", 33L), + candidate(4479L, "SK\uBBF8\uB798\uAD00 \uB77C\uC6B4\uC9C0 517\uD638", 33L))); + + CrowdTargetResolver resolver = new CrowdTargetResolver(repository); + + assertThat(resolver.resolveWithinBuilding("\uBE14\uB8E8\uD3EC\uD2B8", 33L).status()) + .isEqualTo(CrowdTargetResolver.Resolution.Status.UNIQUE); + assertThat(resolver.resolveWithinBuilding("\uBE14\uB8E8\uD3EC\uD2B8", 33L).candidates()) + .extracting(CrowdPlaceCandidate::placeId).containsExactly(4443L); + assertThat(resolver.resolveWithinBuilding("SK\uBBF8\uB798\uAD00 \uBE14\uB8E8\uD3EC\uD2B8", 33L).candidates()) + .extracting(CrowdPlaceCandidate::placeId).containsExactly(4443L); + assertThat(resolver.resolveWithinBuilding("B1\uCE35 \uB77C\uC6B4\uC9C0", 33L).candidates()) + .extracting(CrowdPlaceCandidate::placeId).containsExactly(4385L); + assertThat(resolver.resolveWithinBuilding("SK\uBBF8\uB798\uAD00 B1\uCE35 \uB77C\uC6B4\uC9C0", 33L).candidates()) + .extracting(CrowdPlaceCandidate::placeId).containsExactly(4385L); + assertThat(resolver.resolveWithinBuilding("3\uCE35 \uB77C\uC6B4\uC9C0", 33L).candidates()) + .extracting(CrowdPlaceCandidate::placeId).containsExactly(4429L); + assertThat(resolver.resolveWithinBuilding("517\uD638 \uB77C\uC6B4\uC9C0", 33L).candidates()) + .extracting(CrowdPlaceCandidate::placeId).containsExactly(4479L); + } + + private CrowdPlaceCandidate candidate(Long placeId, String deviceName, Long buildingId) { + return new CrowdPlaceCandidate(placeId, deviceName, "라운지", 1D, null, buildingId, "SK미래관"); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java index cb5939ec..4d562f93 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java @@ -13,6 +13,7 @@ import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; import devkor.com.teamcback.domain.chatbot.dto.RoutePlan; +import devkor.com.teamcback.domain.chatbot.dto.CrowdStatusPlan; import devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter; import devkor.com.teamcback.domain.chatbot.service.ResolvedLocationCollector; import devkor.com.teamcback.domain.chatbot.tool.CampusChatbotTools; @@ -73,6 +74,30 @@ void usesStructuredOutputWithoutRegisteringCampusToolsForRoutePlanning() { org.mockito.Mockito.verify(requestSpec, org.mockito.Mockito.never()).tools(any()); } + @Test + void usesStructuredCrowdPlannerWithoutRegisteringCampusTools() { + ChatClient chatClient = mock(ChatClient.class); + ChatClient.Builder builder = mock(ChatClient.Builder.class); + ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class); + ChatClient.CallResponseSpec responseSpec = mock(ChatClient.CallResponseSpec.class); + when(builder.build()).thenReturn(chatClient); + when(chatClient.prompt()).thenReturn(requestSpec); + when(requestSpec.system(anyString())).thenReturn(requestSpec); + when(requestSpec.messages(anyList())).thenReturn(requestSpec); + when(requestSpec.call()).thenReturn(responseSpec); + when(responseSpec.entity(any(StructuredOutputConverter.class))).thenReturn( + new CrowdStatusPlan(CrowdStatusPlan.Intent.CROWD_STATUS, "미래관")); + SpringAiLlmGateway gateway = new SpringAiLlmGateway(builder, properties(), executor, + mock(CampusChatbotTools.class), new ChatbotToolCallLimiter(properties())); + + CrowdStatusPlan plan = gateway.planCrowd("system", List.of(), "미래관 혼잡도"); + + assertThat(plan.isCrowdStatus()).isTrue(); + assertThat(plan.locationQuery()).isEqualTo("미래관"); + verify(requestSpec).call(); + org.mockito.Mockito.verify(requestSpec, org.mockito.Mockito.never()).tools(any()); + } + @Test void mapsProviderFailureWithoutExposingProviderDetails() { ChatClient chatClient = mock(ChatClient.class, RETURNS_DEEP_STUBS); diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflowTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflowTest.java new file mode 100644 index 00000000..ebbb17bd --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflowTest.java @@ -0,0 +1,194 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.chatbot.dto.CrowdStatusPlan; +import devkor.com.teamcback.domain.chatbot.crowd.CrowdPlaceCandidate; +import devkor.com.teamcback.domain.chatbot.crowd.CrowdTargetResolver; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import devkor.com.teamcback.domain.chatbot.search.ChatbotCampusSearchService; +import devkor.com.teamcback.domain.chatbot.search.ChatbotSearchCandidate; +import devkor.com.teamcback.domain.chatbot.tool.CampusToolAdapter; +import devkor.com.teamcback.domain.chatbot.tool.dto.CrowdLevel; +import devkor.com.teamcback.domain.chatbot.tool.dto.CrowdStatusToolData; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetCrowdStatusToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetCrowdStatusToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CrowdStatusWorkflowTest { + @Mock CrowdStatusPlanner planner; + @Mock ChatbotCampusSearchService searchService; + @Mock CampusToolAdapter campusToolAdapter; + @Mock CrowdTargetResolver targetResolver; + + @Test + void uniquePlaceIsLoadedByBackendWithoutToolCalling() { + UUID sessionId = UUID.randomUUID(); + when(planner.plan(any(), any())).thenReturn(new CrowdStatusPlan( + CrowdStatusPlan.Intent.CROWD_STATUS, "열람실")); + when(searchService.search("열람실")).thenReturn(List.of( + new ChatbotSearchCandidate(77L, ToolLocationType.PLACE, "중앙도서관 열람실", + 1L, "중앙도서관", 2D, null, null, 300, "열람실", false))); + when(campusToolAdapter.getCrowdStatus(any())).thenReturn(new GetCrowdStatusToolResult( + new CrowdStatusToolData(77L, 10, 100, CrowdLevel.AVAILABLE, null, false, null), null)); + + CrowdStatusWorkflow workflow = new CrowdStatusWorkflow(planner, searchService, campusToolAdapter); + CrowdStatusWorkflow.CrowdWorkflowResult result = workflow.handle(sessionId, + new ChatCaller("user:1", true), List.of(), "열람실 혼잡도"); + + assertThat(result.handled()).isTrue(); + assertThat(result.reply()).contains("중앙도서관 열람실").contains("AVAILABLE"); + verify(campusToolAdapter).getCrowdStatus(new GetCrowdStatusToolRequest(77L, false)); + } + + @Test + void buildingCandidateIsConfirmedButNeverUsedAsPlaceId() { + UUID sessionId = UUID.randomUUID(); + when(planner.plan(any(), any())).thenReturn(new CrowdStatusPlan( + CrowdStatusPlan.Intent.CROWD_STATUS, "미래관")); + when(searchService.search("미래관")).thenReturn(List.of( + new ChatbotSearchCandidate(33L, ToolLocationType.BUILDING, "SK미래관", + 33L, "SK미래관", null, null, null, 200, "미래관", false))); + + CrowdStatusWorkflow workflow = new CrowdStatusWorkflow(planner, searchService, campusToolAdapter); + ChatCaller caller = new ChatCaller("user:1", true); + CrowdStatusWorkflow.CrowdWorkflowResult first = workflow.handle(sessionId, caller, List.of(), "미래관 혼잡도"); + CrowdStatusWorkflow.CrowdWorkflowResult second = workflow.handle(sessionId, caller, List.of(), "맞아"); + + assertThat(first.reply()).isEqualTo("SK미래관을(를) 말씀하시나요?"); + assertThat(second.reply()).contains("확인 가능한 혼잡도 장소"); + verify(campusToolAdapter, never()).getCrowdStatus(any()); + } + + @Test + void nonCrowdRequestLeavesGeneralChatPathAvailable() { + when(planner.plan(any(), any())).thenReturn(CrowdStatusPlan.other()); + + CrowdStatusWorkflow workflow = new CrowdStatusWorkflow(planner, searchService, campusToolAdapter); + CrowdStatusWorkflow.CrowdWorkflowResult result = workflow.handle(UUID.randomUUID(), + new ChatCaller("user:1", true), List.of(), "오늘 학식 알려줘"); + + assertThat(result.handled()).isFalse(); + verify(searchService, never()).search(any()); + verify(campusToolAdapter, never()).getCrowdStatus(any()); + } + + @Test + void ambiguousCandidatesAreNotSelectedByTheGraph() { + UUID sessionId = UUID.randomUUID(); + ChatCaller caller = new ChatCaller("user:1", true); + when(planner.plan(any(), any())).thenReturn(new CrowdStatusPlan( + CrowdStatusPlan.Intent.CROWD_STATUS, "중도")); + when(searchService.search("중도")).thenReturn(List.of( + new ChatbotSearchCandidate(1L, ToolLocationType.PLACE, "중앙도서관(신관)", + 1L, "중앙도서관(신관)", null, null, null, 290, "중도", false), + new ChatbotSearchCandidate(2L, ToolLocationType.PLACE, "중앙도서관(대학원)", + 2L, "중앙도서관(대학원)", null, null, null, 290, "중도", false))); + + CrowdStatusWorkflow workflow = new CrowdStatusWorkflow(planner, searchService, campusToolAdapter); + CrowdStatusWorkflow.CrowdWorkflowResult first = workflow.handle(sessionId, caller, List.of(), "중도 혼잡도"); + CrowdStatusWorkflow.CrowdWorkflowResult second = workflow.handle(sessionId, caller, List.of(), "맞아"); + + assertThat(first.reply()).contains("중앙도서관(신관)").contains("중앙도서관(대학원)"); + assertThat(second.reply()).isNotBlank(); + verify(campusToolAdapter, never()).getCrowdStatus(any()); + } + + @Test + void concreteCrowdPlaceBeatsPartialBuilding() { + UUID sessionId = UUID.randomUUID(); + when(planner.plan(any(), any())).thenReturn(new CrowdStatusPlan( + CrowdStatusPlan.Intent.CROWD_STATUS, "SK미래관 B1층 라운지")); + when(searchService.search("SK미래관 B1층 라운지")).thenReturn(List.of( + new ChatbotSearchCandidate(33L, ToolLocationType.BUILDING, "SK미래관", + 33L, "SK미래관", null, null, null, 200, "미래관", true), + new ChatbotSearchCandidate(4385L, ToolLocationType.PLACE, "라운지", + 33L, "SK미래관", -1D, null, null, 190, "라운지", true))); + CrowdPlaceCandidate place = new CrowdPlaceCandidate(4385L, "SK미래관 B1층 라운지", "라운지", + -1D, null, 33L, "SK미래관"); + when(targetResolver.resolve(any(), any())).thenReturn(CrowdTargetResolver.Resolution.of(List.of(place))); + when(campusToolAdapter.getCrowdStatus(any())).thenReturn(new GetCrowdStatusToolResult( + new CrowdStatusToolData(4385L, 3, 20, CrowdLevel.AVAILABLE, null, false, null), null)); + + CrowdStatusWorkflow workflow = new CrowdStatusWorkflow(planner, searchService, campusToolAdapter, targetResolver); + CrowdStatusWorkflow.CrowdWorkflowResult result = workflow.handle(sessionId, + new ChatCaller("user:1", true), List.of(), "SK미래관 B1층 라운지 혼잡도"); + + assertThat(result.reply()).contains("SK미래관 B1층 라운지"); + verify(campusToolAdapter).getCrowdStatus(new GetCrowdStatusToolRequest(4385L, false)); + } + + @Test + void buildingConfirmationMovesToCrowdPlaceSelection() { + UUID sessionId = UUID.randomUUID(); + ChatCaller caller = new ChatCaller("user:1", true); + when(planner.plan(any(), any())).thenReturn(new CrowdStatusPlan( + CrowdStatusPlan.Intent.CROWD_STATUS, "미래관")); + when(searchService.search("미래관")).thenReturn(List.of( + new ChatbotSearchCandidate(33L, ToolLocationType.BUILDING, "SK미래관", + 33L, "SK미래관", null, null, null, 200, "미래관", false))); + List places = List.of( + new CrowdPlaceCandidate(4385L, "SK미래관 B1층 라운지", "라운지", -1D, null, 33L, "SK미래관"), + new CrowdPlaceCandidate(4429L, "SK미래관 3층 라운지", "라운지", 3D, null, 33L, "SK미래관")); + when(targetResolver.resolve(any(), any())).thenReturn(CrowdTargetResolver.Resolution.of(places)); + when(targetResolver.byBuilding(33L)).thenReturn(CrowdTargetResolver.Resolution.of(places)); + when(targetResolver.resolveWithinBuilding("B1층 라운지", 33L)) + .thenReturn(CrowdTargetResolver.Resolution.of(List.of(places.get(0)))); + when(targetResolver.resolveWithinBuilding("응", 33L)) + .thenReturn(CrowdTargetResolver.Resolution.notFound()); + when(campusToolAdapter.getCrowdStatus(any())).thenReturn(new GetCrowdStatusToolResult( + new CrowdStatusToolData(4385L, 3, 20, CrowdLevel.AVAILABLE, null, false, null), null)); + + CrowdStatusWorkflow workflow = new CrowdStatusWorkflow(planner, searchService, campusToolAdapter, targetResolver); + assertThat(workflow.handle(sessionId, caller, List.of(), "미래관 혼잡도").reply()) + .contains("SK미래관"); + assertThat(workflow.handle(sessionId, caller, List.of(), "응").reply()) + .contains("B1층 라운지").contains("3층 라운지"); + assertThat(workflow.handle(sessionId, caller, List.of(), "응").reply()).isNotBlank(); + verify(campusToolAdapter, never()).getCrowdStatus(any()); + assertThat(workflow.handle(sessionId, caller, List.of(), "B1층 라운지").reply()) + .contains("SK미래관 B1층 라운지"); + verify(campusToolAdapter).getCrowdStatus(new GetCrowdStatusToolRequest(4385L, false)); + } + + @Test + void placeSelectionClearsPreviousPromptAndLoadsSelectedCrowd() { + UUID sessionId = UUID.randomUUID(); + ChatCaller caller = new ChatCaller("user:1", true); + when(planner.plan(any(), any())).thenReturn(new CrowdStatusPlan( + CrowdStatusPlan.Intent.CROWD_STATUS, "Mirae Building")); + when(searchService.search("Mirae Building")).thenReturn(List.of( + new ChatbotSearchCandidate(33L, ToolLocationType.BUILDING, "Mirae Building", + 33L, "Mirae Building", null, null, null, 300, "Mirae", false))); + List places = List.of( + new CrowdPlaceCandidate(4385L, "Mirae Building B1 Lounge", "Lounge", -1D, + null, 33L, "Mirae Building"), + new CrowdPlaceCandidate(4443L, "Mirae Building Blue Port", "Blue Port", 3D, + null, 33L, "Mirae Building")); + when(targetResolver.resolve(any(), any())).thenReturn(CrowdTargetResolver.Resolution.of(places)); + when(targetResolver.resolveWithinBuilding("Mirae Building Blue Port", 33L)) + .thenReturn(CrowdTargetResolver.Resolution.of(List.of(places.get(1)))); + when(campusToolAdapter.getCrowdStatus(any())).thenReturn(new GetCrowdStatusToolResult( + new CrowdStatusToolData(4443L, 10, 100, CrowdLevel.AVAILABLE, null, false, null), null)); + + CrowdStatusWorkflow workflow = new CrowdStatusWorkflow(planner, searchService, campusToolAdapter, targetResolver); + CrowdStatusWorkflow.CrowdWorkflowResult first = workflow.handle(sessionId, caller, List.of(), "Mirae Building"); + CrowdStatusWorkflow.CrowdWorkflowResult second = workflow.handle(sessionId, caller, List.of(), "Mirae Building Blue Port"); + + assertThat(first.waiting()).isTrue(); + assertThat(second.reply()).contains("AVAILABLE"); + verify(targetResolver).resolveWithinBuilding("Mirae Building Blue Port", 33L); + verify(campusToolAdapter).getCrowdStatus(new GetCrowdStatusToolRequest(4443L, false)); + } +} From bfd4993f199b126efebe59b1c79b0d01e6f2f31f Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Mon, 28 Sep 2026 01:55:02 +0900 Subject: [PATCH 16/21] refactor(chatbot): use LLM semantic selection in crowd workflow --- .../chatbot/dto/CrowdCandidateSelection.java | 18 ++++++ .../chatbot/dto/CrowdCandidateView.java | 10 ++++ .../domain/chatbot/gateway/LlmGateway.java | 9 +++ .../chatbot/gateway/SpringAiLlmGateway.java | 39 +++++++++++++ .../service/CrowdCandidateSelector.java | 58 +++++++++++++++++++ .../chatbot/service/CrowdStatusWorkflow.java | 39 ++++++++++++- .../gateway/SpringAiLlmGatewayTest.java | 27 +++++++++ .../service/CrowdCandidateSelectorTest.java | 46 +++++++++++++++ .../service/CrowdStatusWorkflowTest.java | 31 ++++++++++ 9 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdCandidateSelection.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdCandidateView.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdCandidateSelector.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/service/CrowdCandidateSelectorTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdCandidateSelection.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdCandidateSelection.java new file mode 100644 index 00000000..f8903329 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdCandidateSelection.java @@ -0,0 +1,18 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +/** Structured, provider-neutral interpretation of a crowd place selection. */ +public record CrowdCandidateSelection(Status status, Integer candidateIndex) { + public CrowdCandidateSelection { + status = status == null ? Status.NONE : status; + } + + public static CrowdCandidateSelection none() { + return new CrowdCandidateSelection(Status.NONE, null); + } + + public enum Status { + SELECTED, + AMBIGUOUS, + NONE + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdCandidateView.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdCandidateView.java new file mode 100644 index 00000000..59889b07 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdCandidateView.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +/** Candidate information safe to expose to the semantic selector; it contains no database identifier. */ +public record CrowdCandidateView(int index, String name, String floor, String placeType) { + public CrowdCandidateView { + name = name == null ? "" : name; + floor = floor == null ? "" : floor; + placeType = placeType == null ? "" : placeType; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java index c585c52c..5f6aebdf 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java @@ -2,6 +2,8 @@ import devkor.com.teamcback.domain.chatbot.dto.RoutePlan; import devkor.com.teamcback.domain.chatbot.dto.CrowdStatusPlan; +import devkor.com.teamcback.domain.chatbot.dto.CrowdCandidateSelection; +import devkor.com.teamcback.domain.chatbot.dto.CrowdCandidateView; import devkor.com.teamcback.domain.chatbot.service.ResolvedLocationCollector; import java.util.List; @@ -10,6 +12,13 @@ public interface LlmGateway { default CrowdStatusPlan planCrowd(String systemPrompt, List history, String userMessage) { return CrowdStatusPlan.other(); } + /** Structured semantic selection over backend-owned candidates; implementations must not execute tools. */ + default CrowdCandidateSelection selectCrowdCandidate(String systemPrompt, + List history, + String userMessage, + List candidates) { + return CrowdCandidateSelection.none(); + } /** Structured route interpretation; provider implementations must not resolve IDs or execute routes. */ default RoutePlan planRoute(String systemPrompt, List history, String userMessage) { return RoutePlan.notRoute(); diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java index 9f5e1aaf..0e23d74d 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java @@ -5,6 +5,8 @@ import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; import devkor.com.teamcback.domain.chatbot.dto.RoutePlan; import devkor.com.teamcback.domain.chatbot.dto.CrowdStatusPlan; +import devkor.com.teamcback.domain.chatbot.dto.CrowdCandidateSelection; +import devkor.com.teamcback.domain.chatbot.dto.CrowdCandidateView; import devkor.com.teamcback.domain.chatbot.service.ResolvedLocationCollector; import devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter; import devkor.com.teamcback.domain.chatbot.service.ToolCallLimitExceededException; @@ -103,6 +105,43 @@ private CrowdStatusPlan invokeCrowdPlan(String systemPrompt, List history, + String userMessage, List candidates) { + Future response = chatbotLlmExecutor.submit( + () -> invokeCrowdCandidateSelection(systemPrompt, history, userMessage, candidates)); + try { + CrowdCandidateSelection selection = response.get(properties.llm().timeout().toMillis(), TimeUnit.MILLISECONDS); + return selection == null ? CrowdCandidateSelection.none() : selection; + } catch (InterruptedException exception) { + response.cancel(true); + Thread.currentThread().interrupt(); + return CrowdCandidateSelection.none(); + } catch (ExecutionException | TimeoutException exception) { + response.cancel(true); + return CrowdCandidateSelection.none(); + } + } + + private CrowdCandidateSelection invokeCrowdCandidateSelection(String systemPrompt, + List history, + String userMessage, + List candidates) { + BeanOutputConverter converter = new BeanOutputConverter<>(CrowdCandidateSelection.class); + String selectorPrompt = """ + %s + + Output format: + %s + """.formatted(systemPrompt == null ? "" : systemPrompt, converter.getFormat()); + ChatClient.CallResponseSpec response = chatClient.prompt() + .system(selectorPrompt) + .messages(toSpringMessages(history, userMessage)) + .call(); + CrowdCandidateSelection selection = response == null ? null : response.entity(converter); + return selection == null ? CrowdCandidateSelection.none() : selection; + } + @Override public RoutePlan planRoute(String systemPrompt, List history, String userMessage) { Future response = chatbotLlmExecutor.submit( diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdCandidateSelector.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdCandidateSelector.java new file mode 100644 index 00000000..cd4444c7 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdCandidateSelector.java @@ -0,0 +1,58 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import devkor.com.teamcback.domain.chatbot.dto.CrowdCandidateSelection; +import devkor.com.teamcback.domain.chatbot.dto.CrowdCandidateView; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** Interprets a PLACE_SELECTION reply without receiving tools or database identifiers. */ +@Component +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class CrowdCandidateSelector { + private static final String PROMPT = """ + Select a crowd location only from the supplied candidates. + Return SELECTED only when the user's reply clearly identifies exactly one candidate. + Return AMBIGUOUS when multiple candidates could match or when the user only confirms with words + such as yes, okay, or 맞아. Return NONE when no candidate matches. + Never invent an index. Return only the structured CrowdCandidateSelection format. + Candidates are numbered by index: + %s + """; + + private final LlmGateway gateway; + + public CrowdCandidateSelector(LlmGateway gateway) { + this.gateway = gateway; + } + + public CrowdCandidateSelection select(String userMessage, List candidates) { + List safeCandidates = candidates == null ? List.of() : List.copyOf(candidates); + if (safeCandidates.isEmpty()) { + return CrowdCandidateSelection.none(); + } + if (isBareConfirmation(userMessage)) { + return new CrowdCandidateSelection(CrowdCandidateSelection.Status.AMBIGUOUS, null); + } + String candidateText = safeCandidates.stream() + .map(candidate -> "%d: %s%s%s".formatted(candidate.index(), candidate.name(), + candidate.floor().isBlank() ? "" : " (floor=" + candidate.floor() + ")", + candidate.placeType().isBlank() ? "" : " (type=" + candidate.placeType() + ")")) + .reduce((left, right) -> left + "\n" + right) + .orElse(""); + CrowdCandidateSelection selection = gateway.selectCrowdCandidate( + PROMPT.formatted(candidateText), List.of(), userMessage, safeCandidates); + return selection == null ? CrowdCandidateSelection.none() : selection; + } + + private boolean isBareConfirmation(String userMessage) { + if (userMessage == null) { + return false; + } + return Set.of("\uC751", "\uB124", "\uC608", "\uB9DE\uC544", "\uB9DE\uC544\uC694", + "\uADF8\uB798", "yes", "okay", "ok").contains(userMessage.trim().toLowerCase(Locale.ROOT)); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflow.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflow.java index cd29bb37..c53f1e86 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflow.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflow.java @@ -4,6 +4,8 @@ import static org.bsc.langgraph4j.StateGraph.START; import devkor.com.teamcback.domain.chatbot.dto.CrowdStatusPlan; +import devkor.com.teamcback.domain.chatbot.dto.CrowdCandidateSelection; +import devkor.com.teamcback.domain.chatbot.dto.CrowdCandidateView; import devkor.com.teamcback.domain.chatbot.crowd.CrowdPlaceCandidate; import devkor.com.teamcback.domain.chatbot.crowd.CrowdTargetResolver; import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; @@ -72,21 +74,29 @@ public class CrowdStatusWorkflow { private final ChatbotCampusSearchService searchService; private final CampusToolAdapter campusToolAdapter; private final CrowdTargetResolver targetResolver; + private final CrowdCandidateSelector candidateSelector; private final MemorySaver saver; private final CompiledGraph graph; CrowdStatusWorkflow(CrowdStatusPlanner planner, ChatbotCampusSearchService searchService, CampusToolAdapter campusToolAdapter) { - this(planner, searchService, campusToolAdapter, null); + this(planner, searchService, campusToolAdapter, null, null); } - @Autowired public CrowdStatusWorkflow(CrowdStatusPlanner planner, ChatbotCampusSearchService searchService, CampusToolAdapter campusToolAdapter, CrowdTargetResolver targetResolver) { + this(planner, searchService, campusToolAdapter, targetResolver, null); + } + + @Autowired + public CrowdStatusWorkflow(CrowdStatusPlanner planner, ChatbotCampusSearchService searchService, + CampusToolAdapter campusToolAdapter, CrowdTargetResolver targetResolver, + CrowdCandidateSelector candidateSelector) { this.planner = planner; this.searchService = searchService; this.campusToolAdapter = campusToolAdapter; this.targetResolver = targetResolver; + this.candidateSelector = candidateSelector; try { this.saver = new MemorySaver(); StateGraph stateGraph = new StateGraph<>(CrowdGraphState.SCHEMA, @@ -235,9 +245,12 @@ private boolean isPartialBuilding(ChatbotSearchCandidate candidate) { } private Map crowdMap(CrowdPlaceCandidate candidate) { - return locationMap(candidate.placeId(), ToolLocationType.PLACE, + Map value = locationMap(candidate.placeId(), ToolLocationType.PLACE, candidate.deviceName() == null || candidate.deviceName().isBlank() ? candidate.placeName() : candidate.deviceName(), "EXACT"); + value.put("floor", candidate.floor() == null ? "" : String.valueOf(candidate.floor())); + value.put("placeType", candidate.placeType() == null ? "" : candidate.placeType().name()); + return value; } private Map locationMap(Long id, ToolLocationType type, String name, String matchType) { @@ -268,6 +281,26 @@ private Map askConfirmation(CrowdGraphState state) { private Map checkConfirmation(CrowdGraphState state) { String confirmation = state.value(CONFIRMATION, "").trim(); if (PLACE_SELECTION.equals(state.value(AWAITING, ""))) { + if (candidateSelector != null) { + List> candidates = state.value(CANDIDATES, List.of()); + List views = new ArrayList<>(); + for (int index = 0; index < candidates.size(); index++) { + Map candidate = candidates.get(index); + views.add(new CrowdCandidateView(index, String.valueOf(candidate.get("name")), + String.valueOf(candidate.getOrDefault("floor", "")), + String.valueOf(candidate.getOrDefault("placeType", "")))); + } + CrowdCandidateSelection selection = candidateSelector.select(confirmation, views); + Integer index = selection == null ? null : selection.candidateIndex(); + if (selection != null && selection.status() == CrowdCandidateSelection.Status.SELECTED + && index != null && index >= 0 && index < candidates.size()) { + Map candidate = candidates.get(index); + return Map.of(SELECTED_ID, String.valueOf(candidate.get("id")), + SELECTED_TYPE, candidate.get("type"), SELECTED_NAME, candidate.get("name"), + AWAITING, "", REPLY, "", CONFIRMATION, ""); + } + return Map.of(REPLY, "", SELECTED_ID, "", AWAITING, PLACE_SELECTION, CONFIRMATION, ""); + } CrowdTargetResolver.Resolution selection = targetResolver == null ? CrowdTargetResolver.Resolution.notFound() : targetResolver.resolveWithinBuilding(confirmation, diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java index 4d562f93..2b961319 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java @@ -14,6 +14,8 @@ import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; import devkor.com.teamcback.domain.chatbot.dto.RoutePlan; import devkor.com.teamcback.domain.chatbot.dto.CrowdStatusPlan; +import devkor.com.teamcback.domain.chatbot.dto.CrowdCandidateSelection; +import devkor.com.teamcback.domain.chatbot.dto.CrowdCandidateView; import devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter; import devkor.com.teamcback.domain.chatbot.service.ResolvedLocationCollector; import devkor.com.teamcback.domain.chatbot.tool.CampusChatbotTools; @@ -98,6 +100,31 @@ void usesStructuredCrowdPlannerWithoutRegisteringCampusTools() { org.mockito.Mockito.verify(requestSpec, org.mockito.Mockito.never()).tools(any()); } + @Test + void usesStructuredCrowdCandidateSelectionWithoutRegisteringCampusTools() { + ChatClient chatClient = mock(ChatClient.class); + ChatClient.Builder builder = mock(ChatClient.Builder.class); + ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class); + ChatClient.CallResponseSpec responseSpec = mock(ChatClient.CallResponseSpec.class); + when(builder.build()).thenReturn(chatClient); + when(chatClient.prompt()).thenReturn(requestSpec); + when(requestSpec.system(anyString())).thenReturn(requestSpec); + when(requestSpec.messages(anyList())).thenReturn(requestSpec); + when(requestSpec.call()).thenReturn(responseSpec); + when(responseSpec.entity(any(StructuredOutputConverter.class))).thenReturn( + new CrowdCandidateSelection(CrowdCandidateSelection.Status.SELECTED, 2)); + SpringAiLlmGateway gateway = new SpringAiLlmGateway(builder, properties(), executor, + mock(CampusChatbotTools.class), new ChatbotToolCallLimiter(properties())); + + CrowdCandidateSelection selection = gateway.selectCrowdCandidate("candidate prompt", List.of(), + "커피 파는 데", List.of(new CrowdCandidateView(2, "블루포트", "3", "CAFE"))); + + assertThat(selection.status()).isEqualTo(CrowdCandidateSelection.Status.SELECTED); + assertThat(selection.candidateIndex()).isEqualTo(2); + verify(requestSpec).call(); + org.mockito.Mockito.verify(requestSpec, org.mockito.Mockito.never()).tools(any()); + } + @Test void mapsProviderFailureWithoutExposingProviderDetails() { ChatClient chatClient = mock(ChatClient.class, RETURNS_DEEP_STUBS); diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/CrowdCandidateSelectorTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/CrowdCandidateSelectorTest.java new file mode 100644 index 00000000..b4362f95 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/CrowdCandidateSelectorTest.java @@ -0,0 +1,46 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.chatbot.dto.CrowdCandidateSelection; +import devkor.com.teamcback.domain.chatbot.dto.CrowdCandidateView; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CrowdCandidateSelectorTest { + @Mock LlmGateway gateway; + + @Test + void returnsProviderSelectedIndexWithoutExposingAnId() { + when(gateway.selectCrowdCandidate(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyList(), org.mockito.ArgumentMatchers.eq("커피 파는 데"), + org.mockito.ArgumentMatchers.anyList())) + .thenReturn(new CrowdCandidateSelection(CrowdCandidateSelection.Status.SELECTED, 2)); + + CrowdCandidateSelection result = new CrowdCandidateSelector(gateway).select("커피 파는 데", List.of( + new CrowdCandidateView(0, "B1층 라운지", "-1", "LOUNGE"), + new CrowdCandidateView(1, "3층 라운지", "3", "LOUNGE"), + new CrowdCandidateView(2, "블루포트", "3", "CAFE"))); + + assertThat(result.status()).isEqualTo(CrowdCandidateSelection.Status.SELECTED); + assertThat(result.candidateIndex()).isEqualTo(2); + } + + @Test + void neverLetsBareConfirmationSelectTheFirstPlace() { + CrowdCandidateSelection result = new CrowdCandidateSelector(gateway).select("응", List.of( + new CrowdCandidateView(0, "B1층 라운지", "-1", "LOUNGE"), + new CrowdCandidateView(1, "블루포트", "3", "CAFE"))); + + assertThat(result.status()).isEqualTo(CrowdCandidateSelection.Status.AMBIGUOUS); + assertThat(result.candidateIndex()).isNull(); + verifyNoInteractions(gateway); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflowTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflowTest.java index ebbb17bd..bcd98de0 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflowTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflowTest.java @@ -7,6 +7,7 @@ import static org.mockito.Mockito.when; import devkor.com.teamcback.domain.chatbot.dto.CrowdStatusPlan; +import devkor.com.teamcback.domain.chatbot.dto.CrowdCandidateSelection; import devkor.com.teamcback.domain.chatbot.crowd.CrowdPlaceCandidate; import devkor.com.teamcback.domain.chatbot.crowd.CrowdTargetResolver; import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; @@ -31,6 +32,7 @@ class CrowdStatusWorkflowTest { @Mock ChatbotCampusSearchService searchService; @Mock CampusToolAdapter campusToolAdapter; @Mock CrowdTargetResolver targetResolver; + @Mock CrowdCandidateSelector candidateSelector; @Test void uniquePlaceIsLoadedByBackendWithoutToolCalling() { @@ -191,4 +193,33 @@ void placeSelectionClearsPreviousPromptAndLoadsSelectedCrowd() { verify(targetResolver).resolveWithinBuilding("Mirae Building Blue Port", 33L); verify(campusToolAdapter).getCrowdStatus(new GetCrowdStatusToolRequest(4443L, false)); } + + @Test + void semanticPlaceSelectionUsesExistingStateCandidateId() { + UUID sessionId = UUID.randomUUID(); + ChatCaller caller = new ChatCaller("user:1", true); + when(planner.plan(any(), any())).thenReturn(new CrowdStatusPlan( + CrowdStatusPlan.Intent.CROWD_STATUS, "Mirae Building")); + when(searchService.search("Mirae Building")).thenReturn(List.of( + new ChatbotSearchCandidate(33L, ToolLocationType.BUILDING, "Mirae Building", + 33L, "Mirae Building", null, null, null, 300, "Mirae", false))); + CrowdPlaceCandidate first = new CrowdPlaceCandidate(4385L, "Mirae Building B1 Lounge", "Lounge", + -1D, null, 33L, "Mirae Building"); + CrowdPlaceCandidate second = new CrowdPlaceCandidate(4443L, "Mirae Building Blue Port", "Blue Port", + 3D, null, 33L, "Mirae Building"); + when(targetResolver.resolve(any(), any())).thenReturn(CrowdTargetResolver.Resolution.of(List.of(first, second))); + when(candidateSelector.select(any(), any())).thenReturn( + new CrowdCandidateSelection(CrowdCandidateSelection.Status.SELECTED, 1)); + when(campusToolAdapter.getCrowdStatus(any())).thenReturn(new GetCrowdStatusToolResult( + new CrowdStatusToolData(4443L, 10, 100, CrowdLevel.AVAILABLE, null, false, null), null)); + + CrowdStatusWorkflow workflow = new CrowdStatusWorkflow(planner, searchService, campusToolAdapter, + targetResolver, candidateSelector); + workflow.handle(sessionId, caller, List.of(), "Mirae Building"); + CrowdStatusWorkflow.CrowdWorkflowResult result = workflow.handle(sessionId, caller, List.of(), "커피 파는 데"); + + assertThat(result.reply()).contains("AVAILABLE"); + verify(campusToolAdapter).getCrowdStatus(new GetCrowdStatusToolRequest(4443L, false)); + verify(targetResolver, never()).resolveWithinBuilding(any(), any()); + } } From f088d9276973c585c2967d131529dbe864b9ed68 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Tue, 29 Sep 2026 01:01:52 +0900 Subject: [PATCH 17/21] feat(chatbot): add top-level routing and pending workflow handling --- .../chatbot/dto/CandidateSelection.java | 14 ++ .../domain/chatbot/dto/CandidateView.java | 8 + .../chatbot/dto/CrowdCandidateSelection.java | 7 +- .../chatbot/dto/CrowdCandidateView.java | 2 +- .../chatbot/service/ChatRequestRouter.java | 160 ++++++++++++++++++ .../domain/chatbot/service/ChatService.java | 54 +++++- .../service/CrowdCandidateSelector.java | 3 +- .../chatbot/service/CrowdStatusWorkflow.java | 34 +++- .../service/SemanticCandidateSelector.java | 10 ++ .../service/ChatRequestRouterTest.java | 61 +++++++ .../service/ChatServiceRoutingTest.java | 109 ++++++++++++ .../service/CrowdStatusWorkflowTest.java | 26 +++ 12 files changed, 474 insertions(+), 14 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/CandidateSelection.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/CandidateView.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/SemanticCandidateSelector.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouterTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceRoutingTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CandidateSelection.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CandidateSelection.java new file mode 100644 index 00000000..80a55141 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CandidateSelection.java @@ -0,0 +1,14 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +/** Provider-neutral semantic selection of one backend-owned candidate. */ +public interface CandidateSelection { + Status status(); + + Integer candidateIndex(); + + enum Status { + SELECTED, + AMBIGUOUS, + NONE + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CandidateView.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CandidateView.java new file mode 100644 index 00000000..a46495f3 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CandidateView.java @@ -0,0 +1,8 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +/** Candidate fields safe for semantic selection; database identifiers are deliberately absent. */ +public interface CandidateView { + int index(); + + String name(); +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdCandidateSelection.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdCandidateSelection.java index f8903329..3bdc35fa 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdCandidateSelection.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdCandidateSelection.java @@ -1,7 +1,7 @@ package devkor.com.teamcback.domain.chatbot.dto; /** Structured, provider-neutral interpretation of a crowd place selection. */ -public record CrowdCandidateSelection(Status status, Integer candidateIndex) { +public record CrowdCandidateSelection(Status status, Integer candidateIndex) implements CandidateSelection { public CrowdCandidateSelection { status = status == null ? Status.NONE : status; } @@ -10,9 +10,4 @@ public static CrowdCandidateSelection none() { return new CrowdCandidateSelection(Status.NONE, null); } - public enum Status { - SELECTED, - AMBIGUOUS, - NONE - } } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdCandidateView.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdCandidateView.java index 59889b07..80584de6 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdCandidateView.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdCandidateView.java @@ -1,7 +1,7 @@ package devkor.com.teamcback.domain.chatbot.dto; /** Candidate information safe to expose to the semantic selector; it contains no database identifier. */ -public record CrowdCandidateView(int index, String name, String floor, String placeType) { +public record CrowdCandidateView(int index, String name, String floor, String placeType) implements CandidateView { public CrowdCandidateView { name = name == null ? "" : name; floor = floor == null ? "" : floor; diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java new file mode 100644 index 00000000..e804f36a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java @@ -0,0 +1,160 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import java.util.Locale; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * Small application boundary that decides whether a pending interaction owns the current message. + * It intentionally does not call an LLM or inspect domain identifiers. + */ +@Component +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class ChatRequestRouter { + public RoutingDecision route(PendingWorkflow pendingWorkflow, String userMessage) { + Intent workflowIntent = detectObviousIntent(userMessage); + + if (pendingWorkflow != null && pendingWorkflow != PendingWorkflow.NONE) { + if (isGeneralConversation(userMessage)) { + return RoutingDecision.generalChat(); + } + if (workflowIntent != Intent.NONE && !matchesPending(pendingWorkflow, workflowIntent)) { + return RoutingDecision.newIntent(workflowIntent); + } + return RoutingDecision.continuePending(pendingWorkflow.toWorkflowType()); + } + + if (workflowIntent == Intent.NONE) { + return RoutingDecision.generalChat(); + } + return RoutingDecision.newIntent(workflowIntent); + } + + private boolean matchesPending(PendingWorkflow pendingWorkflow, Intent intent) { + return (pendingWorkflow == PendingWorkflow.CROWD && intent == Intent.CROWD) + || (pendingWorkflow == PendingWorkflow.ROUTE && intent == Intent.ROUTE); + } + + private Intent detectObviousIntent(String message) { + String normalized = normalize(message); + if (normalized.isBlank()) { + return Intent.NONE; + } + if (containsAny(normalized, "길찾", "가는길", "경로", "어디로가")) { + return Intent.ROUTE; + } + if (containsAny(normalized, "학식", "식단")) { + return Intent.MENU; + } + if (containsAny(normalized, "붐비", "혼잡", "사람많", "crowd")) { + return Intent.CROWD; + } + if (containsAny(normalized, "리뷰", "후기", "평어때", "평가")) { + return Intent.REVIEW; + } + if (containsAny(normalized, "수업", "강의", "시간표")) { + return Intent.ROOM_COURSE; + } + if (containsAny(normalized, "화장실", "프린터", "시설", "자판기", "정수기")) { + return Intent.FACILITY; + } + if (containsAny(normalized, "방학", "학기", "고연전")) { + return Intent.CAMPUS_STATUS; + } + if (containsAny(normalized, "어디야", "어디있", "열려", "운영시간")) { + return Intent.LOCATION_DETAIL; + } + return Intent.NONE; + } + + private boolean isGeneralConversation(String message) { + String normalized = normalize(message); + return containsAny(normalized, "안녕", "hello", "hi", "고마워", "감사", "잘자"); + } + + private boolean containsAny(String value, String... candidates) { + for (String candidate : candidates) { + if (value.contains(candidate)) { + return true; + } + } + return false; + } + + private String normalize(String message) { + return message == null ? "" : message.trim().toLowerCase(Locale.ROOT).replaceAll("\\s+", ""); + } + + public enum PendingWorkflow { + NONE, + ROUTE, + CROWD; + + private WorkflowType toWorkflowType() { + return this == CROWD ? WorkflowType.CROWD : WorkflowType.ROUTE; + } + } + + public enum Route { + CONTINUE_PENDING, + NEW_INTENT, + GENERAL_CHAT + } + + public enum WorkflowType { + ROUTE, + CROWD, + MENU, + ROOM_COURSE, + REVIEW, + FACILITY, + LOCATION_DETAIL, + CAMPUS_STATUS, + GENERAL_CHAT + } + + public enum Intent { + ROUTE, + CROWD, + MENU, + ROOM_COURSE, + REVIEW, + FACILITY, + LOCATION_DETAIL, + CAMPUS_STATUS, + NONE; + + private WorkflowType toWorkflowType() { + return switch (this) { + case ROUTE -> WorkflowType.ROUTE; + case CROWD -> WorkflowType.CROWD; + case MENU -> WorkflowType.MENU; + case ROOM_COURSE -> WorkflowType.ROOM_COURSE; + case REVIEW -> WorkflowType.REVIEW; + case FACILITY -> WorkflowType.FACILITY; + case LOCATION_DETAIL -> WorkflowType.LOCATION_DETAIL; + case CAMPUS_STATUS -> WorkflowType.CAMPUS_STATUS; + case NONE -> WorkflowType.GENERAL_CHAT; + }; + } + } + + public record RoutingDecision(Route route, WorkflowType workflowType) { + public RoutingDecision { + route = route == null ? Route.GENERAL_CHAT : route; + workflowType = workflowType == null ? WorkflowType.GENERAL_CHAT : workflowType; + } + + public static RoutingDecision continuePending(WorkflowType workflowType) { + return new RoutingDecision(Route.CONTINUE_PENDING, workflowType); + } + + public static RoutingDecision newIntent(Intent intent) { + return new RoutingDecision(Route.NEW_INTENT, intent.toWorkflowType()); + } + + public static RoutingDecision generalChat() { + return new RoutingDecision(Route.GENERAL_CHAT, WorkflowType.GENERAL_CHAT); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java index c2bc495c..04c29cef 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java @@ -67,36 +67,56 @@ NAVIGATE_ROUTE requires searchCampus(query, role=START, intent=NAVIGATE_ROUTE) a private final ChatRateLimiter rateLimiter; private final PendingRouteStateService pendingRouteStateService; private final CrowdStatusWorkflow crowdStatusWorkflow; + private final ChatRequestRouter requestRouter; /** Compatibility constructor for focused unit tests that do not exercise pending state. */ public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService memoryService, ChatRateLimiter rateLimiter) { - this(chatOrchestrator, memoryService, rateLimiter, null, null); + this(chatOrchestrator, memoryService, rateLimiter, null, null, new ChatRequestRouter()); } /** Compatibility constructor for tests that provide pending-route state only. */ public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService memoryService, ChatRateLimiter rateLimiter, PendingRouteStateService pendingRouteStateService) { - this(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService, null); + this(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService, null, new ChatRequestRouter()); + } + + /** Compatibility constructor for focused tests that provide the existing Crowd workflow. */ + public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService memoryService, + ChatRateLimiter rateLimiter, PendingRouteStateService pendingRouteStateService, + CrowdStatusWorkflow crowdStatusWorkflow) { + this(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService, + crowdStatusWorkflow, new ChatRequestRouter()); } @Autowired public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService memoryService, ChatRateLimiter rateLimiter, PendingRouteStateService pendingRouteStateService, - CrowdStatusWorkflow crowdStatusWorkflow) { + CrowdStatusWorkflow crowdStatusWorkflow, + ChatRequestRouter requestRouter) { this.chatOrchestrator = chatOrchestrator; this.memoryService = memoryService; this.rateLimiter = rateLimiter; this.pendingRouteStateService = pendingRouteStateService; this.crowdStatusWorkflow = crowdStatusWorkflow; + this.requestRouter = requestRouter; } public ChatMessageRes sendMessage(ChatMessageReq request, ChatCaller caller) { UUID sessionId = request.sessionId() == null ? UUID.randomUUID() : request.sessionId(); rateLimiter.check(caller); var history = memoryService.load(sessionId, caller); - if (crowdStatusWorkflow != null) { + PendingRouteState pending = loadPending(sessionId, caller); + ChatRequestRouter.PendingWorkflow pendingWorkflow = activePendingWorkflow(sessionId, caller, pending); + ChatRequestRouter.RoutingDecision routing = requestRouter.route(pendingWorkflow, request.message()); + if (routing.route() != ChatRequestRouter.Route.CONTINUE_PENDING) { + releaseSupersededWorkflows(sessionId, caller, pending); + } + if (crowdStatusWorkflow != null + && routing.workflowType() == ChatRequestRouter.WorkflowType.CROWD + && (routing.route() == ChatRequestRouter.Route.CONTINUE_PENDING + || routing.route() == ChatRequestRouter.Route.NEW_INTENT)) { CrowdStatusWorkflow.CrowdWorkflowResult crowd = crowdStatusWorkflow.handle( sessionId, caller, toGatewayHistory(history), request.message()); if (crowd.handled()) { @@ -106,7 +126,12 @@ public ChatMessageRes sendMessage(ChatMessageReq request, ChatCaller caller) { return new ChatMessageRes(sessionId, reply, null); } } - PendingRouteState pending = loadPending(sessionId, caller); + if (routing.route() == ChatRequestRouter.Route.CONTINUE_PENDING + && routing.workflowType() == ChatRequestRouter.WorkflowType.ROUTE) { + pending = loadPending(sessionId, caller); + } else { + pending = null; + } ChatOrchestrationResult result = chatOrchestrator.execute(promptWithPendingState(pending), toGatewayHistory(history), messageWithRequestContext(request)); ClientAction action = assembleRouteAction(result.resolvedLocations()); @@ -129,6 +154,25 @@ public ChatMessageRes sendMessage(ChatMessageReq request, ChatCaller caller) { return new ChatMessageRes(sessionId, reply, action); } + private ChatRequestRouter.PendingWorkflow activePendingWorkflow(UUID sessionId, ChatCaller caller, + PendingRouteState pendingRoute) { + if (crowdStatusWorkflow != null && crowdStatusWorkflow.hasPending(sessionId, caller)) { + return ChatRequestRouter.PendingWorkflow.CROWD; + } + return pendingRoute == null + ? ChatRequestRouter.PendingWorkflow.NONE : ChatRequestRouter.PendingWorkflow.ROUTE; + } + + private void releaseSupersededWorkflows(UUID sessionId, ChatCaller caller, + PendingRouteState pendingRoute) { + if (crowdStatusWorkflow != null && crowdStatusWorkflow.hasPending(sessionId, caller)) { + crowdStatusWorkflow.cancel(sessionId, caller); + } + if (pendingRoute != null) { + deletePending(sessionId, caller); + } + } + private PendingRouteState loadPending(UUID sessionId, ChatCaller caller) { return pendingRouteStateService == null ? null : pendingRouteStateService.load(sessionId, caller).orElse(null); diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdCandidateSelector.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdCandidateSelector.java index cd4444c7..58f5721f 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdCandidateSelector.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdCandidateSelector.java @@ -12,7 +12,7 @@ /** Interprets a PLACE_SELECTION reply without receiving tools or database identifiers. */ @Component @ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") -public class CrowdCandidateSelector { +public class CrowdCandidateSelector implements SemanticCandidateSelector { private static final String PROMPT = """ Select a crowd location only from the supplied candidates. Return SELECTED only when the user's reply clearly identifies exactly one candidate. @@ -29,6 +29,7 @@ public CrowdCandidateSelector(LlmGateway gateway) { this.gateway = gateway; } + @Override public CrowdCandidateSelection select(String userMessage, List candidates) { List safeCandidates = candidates == null ? List.of() : List.copyOf(candidates); if (safeCandidates.isEmpty()) { diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflow.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflow.java index c53f1e86..b066705e 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflow.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflow.java @@ -130,7 +130,7 @@ public CrowdStatusWorkflow(CrowdStatusPlanner planner, ChatbotCampusSearchServic } public CrowdWorkflowResult handle(UUID sessionId, ChatCaller caller, - List history, String userMessage) { + List history, String userMessage) { RunnableConfig config = RunnableConfig.builder().threadId(sessionId.toString()).build(); var checkpoint = graph.lastStateOf(config); if (checkpoint.isPresent()) { @@ -155,6 +155,38 @@ public CrowdWorkflowResult handle(UUID sessionId, ChatCaller caller, return started; } + /** Returns whether this session currently owns an interrupted Crowd interaction. */ + public boolean hasPending(UUID sessionId, ChatCaller caller) { + RunnableConfig config = RunnableConfig.builder().threadId(sessionId.toString()).build(); + var checkpoint = graph.lastStateOf(config); + if (checkpoint.isEmpty()) { + return false; + } + assertOwner(checkpoint.get().state(), caller); + return true; + } + + /** Releases a superseded Crowd interaction without changing its graph node structure. */ + public void cancel(UUID sessionId, ChatCaller caller) { + RunnableConfig config = RunnableConfig.builder().threadId(sessionId.toString()).build(); + var checkpoint = graph.lastStateOf(config); + if (checkpoint.isEmpty()) { + return; + } + assertOwner(checkpoint.get().state(), caller); + try { + saver.release(config); + } catch (Exception exception) { + throw new IllegalStateException("Unable to release superseded CROWD_STATUS workflow", exception); + } + } + + private void assertOwner(CrowdGraphState state, ChatCaller caller) { + if (!caller.key().equals(state.value(OWNER, ""))) { + throw new GlobalException(devkor.com.teamcback.global.response.ResultCode.CHATBOT_SESSION_FORBIDDEN); + } + } + private void releaseIfCompleted(CrowdWorkflowResult result, RunnableConfig config) { if (!result.waiting()) { try { diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/SemanticCandidateSelector.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/SemanticCandidateSelector.java new file mode 100644 index 00000000..45044ea0 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/SemanticCandidateSelector.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import devkor.com.teamcback.domain.chatbot.dto.CandidateSelection; +import devkor.com.teamcback.domain.chatbot.dto.CandidateView; +import java.util.List; + +/** Small boundary for selectors that interpret user language over backend-owned candidate indexes. */ +public interface SemanticCandidateSelector { + CandidateSelection select(String userMessage, List candidates); +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouterTest.java new file mode 100644 index 00000000..a0a220ec --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouterTest.java @@ -0,0 +1,61 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import devkor.com.teamcback.domain.chatbot.service.ChatRequestRouter.PendingWorkflow; +import devkor.com.teamcback.domain.chatbot.service.ChatRequestRouter.Route; +import devkor.com.teamcback.domain.chatbot.service.ChatRequestRouter.WorkflowType; +import org.junit.jupiter.api.Test; + +class ChatRequestRouterTest { + private final ChatRequestRouter router = new ChatRequestRouter(); + + @Test + void continuesCrowdForCandidateClarification() { + var decision = router.route(PendingWorkflow.CROWD, "커피 파는 데"); + + assertThat(decision.route()).isEqualTo(Route.CONTINUE_PENDING); + assertThat(decision.workflowType()).isEqualTo(WorkflowType.CROWD); + } + + @Test + void doesNotResumeCrowdForMenuIntent() { + var decision = router.route(PendingWorkflow.CROWD, "오늘 학식 뭐야"); + + assertThat(decision.route()).isEqualTo(Route.NEW_INTENT); + assertThat(decision.workflowType()).isEqualTo(WorkflowType.MENU); + } + + @Test + void doesNotResumeCrowdForRouteIntent() { + var decision = router.route(PendingWorkflow.CROWD, "중앙도서관에서 서관까지 가는 길"); + + assertThat(decision.route()).isEqualTo(Route.NEW_INTENT); + assertThat(decision.workflowType()).isEqualTo(WorkflowType.ROUTE); + } + + @Test + void continuesRouteForClarificationButSwitchesToCrowdForCrowdIntent() { + assertThat(router.route(PendingWorkflow.ROUTE, "1층 라운지").route()) + .isEqualTo(Route.CONTINUE_PENDING); + var crowd = router.route(PendingWorkflow.ROUTE, "SK미래관 혼잡도 알려줘"); + assertThat(crowd.route()).isEqualTo(Route.NEW_INTENT); + assertThat(crowd.workflowType()).isEqualTo(WorkflowType.CROWD); + } + + @Test + void routesGreetingWithoutPendingToGeneralChat() { + var decision = router.route(PendingWorkflow.NONE, "안녕"); + + assertThat(decision.route()).isEqualTo(Route.GENERAL_CHAT); + assertThat(decision.workflowType()).isEqualTo(WorkflowType.GENERAL_CHAT); + } + + @Test + void startsExistingCrowdWorkflowForNewCrowdIntent() { + var decision = router.route(PendingWorkflow.NONE, "지금 과학도서관 혼잡도 알려줘"); + + assertThat(decision.route()).isEqualTo(Route.NEW_INTENT); + assertThat(decision.workflowType()).isEqualTo(WorkflowType.CROWD); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceRoutingTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceRoutingTest.java new file mode 100644 index 00000000..6553c48b --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceRoutingTest.java @@ -0,0 +1,109 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.chatbot.dto.request.ChatMessageReq; +import devkor.com.teamcback.domain.chatbot.dto.PendingRouteState; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ChatServiceRoutingTest { + @Mock ChatOrchestrator chatOrchestrator; + @Mock ChatSessionMemoryService memoryService; + @Mock ChatRateLimiter rateLimiter; + @Mock PendingRouteStateService pendingRouteStateService; + @Mock CrowdStatusWorkflow crowdStatusWorkflow; + + private final UUID sessionId = UUID.randomUUID(); + private final ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); + + @BeforeEach + void setUp() { + when(memoryService.load(sessionId, caller)).thenReturn(List.of()); + when(pendingRouteStateService.load(sessionId, caller)).thenReturn(Optional.empty()); + lenient().when(chatOrchestrator.execute(anyString(), anyList(), anyString())) + .thenReturn(new ChatOrchestrationResult("general", List.of())); + } + + @Test + void crowdPendingCandidateReplyContinuesCrowd() { + when(crowdStatusWorkflow.hasPending(sessionId, caller)).thenReturn(true); + when(crowdStatusWorkflow.handle(any(), any(), anyList(), anyString())) + .thenReturn(new CrowdStatusWorkflow.CrowdWorkflowResult(true, "crowd", true)); + + newService().sendMessage(new ChatMessageReq(sessionId, "커피 파는 데", null), caller); + + verify(crowdStatusWorkflow).handle(sessionId, caller, List.of(), "커피 파는 데"); + verify(crowdStatusWorkflow, never()).cancel(any(), any()); + verify(chatOrchestrator, never()).execute(anyString(), anyList(), anyString()); + } + + @Test + void crowdPendingMenuMessageCancelsCrowdAndUsesGeneralPath() { + when(crowdStatusWorkflow.hasPending(sessionId, caller)).thenReturn(true); + + newService().sendMessage(new ChatMessageReq(sessionId, "오늘 학식 뭐야", null), caller); + + verify(crowdStatusWorkflow).cancel(sessionId, caller); + verify(crowdStatusWorkflow, never()).handle(any(), any(), anyList(), anyString()); + verify(chatOrchestrator).execute(anyString(), anyList(), anyString()); + } + + @Test + void crowdPendingRouteMessageCancelsCrowdAndUsesRoutePath() { + when(crowdStatusWorkflow.hasPending(sessionId, caller)).thenReturn(true); + + newService().sendMessage(new ChatMessageReq(sessionId, + "중앙도서관에서 서관까지 가는 길", null), caller); + + verify(crowdStatusWorkflow).cancel(sessionId, caller); + verify(crowdStatusWorkflow, never()).handle(any(), any(), anyList(), anyString()); + verify(chatOrchestrator).execute(anyString(), anyList(), anyString()); + } + + @Test + void routePendingCrowdMessageDeletesRoutePendingAndDoesNotResumeRoute() { + when(pendingRouteStateService.load(sessionId, caller)).thenReturn(Optional.of(new PendingRouteState( + devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation.RouteIntent.NAVIGATE_ROUTE, + null, null, devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusRole.END, + List.of(), List.of()))); + when(crowdStatusWorkflow.hasPending(sessionId, caller)).thenReturn(false); + when(crowdStatusWorkflow.handle(any(), any(), anyList(), anyString())) + .thenReturn(new CrowdStatusWorkflow.CrowdWorkflowResult(true, "crowd", false)); + + newService().sendMessage(new ChatMessageReq(sessionId, "SK미래관 혼잡도 알려줘", null), caller); + + verify(pendingRouteStateService).delete(sessionId, caller); + verify(crowdStatusWorkflow).handle(sessionId, caller, List.of(), "SK미래관 혼잡도 알려줘"); + verify(chatOrchestrator, never()).execute(anyString(), anyList(), anyString()); + } + + @Test + void noPendingGreetingUsesGeneralPath() { + when(crowdStatusWorkflow.hasPending(sessionId, caller)).thenReturn(false); + + newService().sendMessage(new ChatMessageReq(sessionId, "안녕", null), caller); + + verify(crowdStatusWorkflow, never()).handle(any(), any(), anyList(), anyString()); + verify(chatOrchestrator).execute(anyString(), anyList(), anyString()); + } + + private ChatService newService() { + return new ChatService(chatOrchestrator, memoryService, rateLimiter, + pendingRouteStateService, crowdStatusWorkflow, new ChatRequestRouter()); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflowTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflowTest.java index bcd98de0..5a7260e3 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflowTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflowTest.java @@ -222,4 +222,30 @@ void semanticPlaceSelectionUsesExistingStateCandidateId() { verify(campusToolAdapter).getCrowdStatus(new GetCrowdStatusToolRequest(4443L, false)); verify(targetResolver, never()).resolveWithinBuilding(any(), any()); } + + @Test + void invalidSemanticCandidateIndexCannotSelectOrLoadCrowd() { + UUID sessionId = UUID.randomUUID(); + ChatCaller caller = new ChatCaller("user:1", true); + when(planner.plan(any(), any())).thenReturn(new CrowdStatusPlan( + CrowdStatusPlan.Intent.CROWD_STATUS, "Mirae Building")); + when(searchService.search("Mirae Building")).thenReturn(List.of( + new ChatbotSearchCandidate(33L, ToolLocationType.BUILDING, "Mirae Building", + 33L, "Mirae Building", null, null, null, 300, "Mirae", false))); + CrowdPlaceCandidate first = new CrowdPlaceCandidate(4385L, "Mirae Building B1 Lounge", "Lounge", + -1D, null, 33L, "Mirae Building"); + CrowdPlaceCandidate second = new CrowdPlaceCandidate(4443L, "Mirae Building Blue Port", "Blue Port", + 3D, null, 33L, "Mirae Building"); + when(targetResolver.resolve(any(), any())).thenReturn(CrowdTargetResolver.Resolution.of(List.of(first, second))); + when(candidateSelector.select(any(), any())).thenReturn( + new CrowdCandidateSelection(CrowdCandidateSelection.Status.SELECTED, 99)); + + CrowdStatusWorkflow workflow = new CrowdStatusWorkflow(planner, searchService, campusToolAdapter, + targetResolver, candidateSelector); + workflow.handle(sessionId, caller, List.of(), "Mirae Building"); + CrowdStatusWorkflow.CrowdWorkflowResult result = workflow.handle(sessionId, caller, List.of(), "unknown"); + + assertThat(result.waiting()).isTrue(); + verify(campusToolAdapter, never()).getCrowdStatus(any()); + } } From f9585d01d43e041e9872ebc4e272c02ee00bd559 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Tue, 29 Sep 2026 01:26:15 +0900 Subject: [PATCH 18/21] feat(chatbot): add backend-owned campus info workflows --- .../dto/LocationCandidateSelection.java | 11 + .../chatbot/dto/LocationCandidateView.java | 14 + .../chatbot/dto/LocationDetailPlan.java | 15 ++ .../domain/chatbot/dto/PlaceReviewsPlan.java | 15 ++ .../domain/chatbot/gateway/LlmGateway.java | 18 ++ .../chatbot/gateway/SpringAiLlmGateway.java | 62 +++++ .../chatbot/service/CampusStatusWorkflow.java | 45 ++++ .../chatbot/service/ChatRequestRouter.java | 18 +- .../domain/chatbot/service/ChatService.java | 62 ++++- .../service/LocationCandidateSelector.java | 36 +++ .../service/LocationDetailPlanner.java | 19 ++ .../service/LocationDetailWorkflow.java | 255 ++++++++++++++++++ .../chatbot/service/PlaceReviewsPlanner.java | 19 ++ .../chatbot/service/PlaceReviewsWorkflow.java | 121 +++++++++ .../chatbot/service/BackendWorkflowTest.java | 130 +++++++++ 15 files changed, 832 insertions(+), 8 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/LocationCandidateSelection.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/LocationCandidateView.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/LocationDetailPlan.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/PlaceReviewsPlan.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/CampusStatusWorkflow.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/LocationCandidateSelector.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/LocationDetailPlanner.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/LocationDetailWorkflow.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/PlaceReviewsPlanner.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/PlaceReviewsWorkflow.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/service/BackendWorkflowTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/LocationCandidateSelection.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/LocationCandidateSelection.java new file mode 100644 index 00000000..ee0c54a9 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/LocationCandidateSelection.java @@ -0,0 +1,11 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +public record LocationCandidateSelection(Status status, Integer candidateIndex) implements CandidateSelection { + public LocationCandidateSelection { + status = status == null ? Status.NONE : status; + } + + public static LocationCandidateSelection none() { + return new LocationCandidateSelection(Status.NONE, null); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/LocationCandidateView.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/LocationCandidateView.java new file mode 100644 index 00000000..a9c3f662 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/LocationCandidateView.java @@ -0,0 +1,14 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +import devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType; + +/** Safe semantic view of a location candidate. Backend identifiers are intentionally absent. */ +public record LocationCandidateView(int index, String name, ToolLocationType locationType, + String buildingName, String floor, String placeType) implements CandidateView { + public LocationCandidateView { + name = name == null ? "" : name; + buildingName = buildingName == null ? "" : buildingName; + floor = floor == null ? "" : floor; + placeType = placeType == null ? "" : placeType; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/LocationDetailPlan.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/LocationDetailPlan.java new file mode 100644 index 00000000..cc2edb5b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/LocationDetailPlan.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +public record LocationDetailPlan(Intent intent, String locationQuery) { + public enum Intent { LOCATION_DETAIL, OTHER } + + public LocationDetailPlan { + intent = intent == null ? Intent.OTHER : intent; + } + + public boolean isLocationDetail() { + return intent == Intent.LOCATION_DETAIL && locationQuery != null && !locationQuery.isBlank(); + } + + public static LocationDetailPlan other() { return new LocationDetailPlan(Intent.OTHER, null); } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/PlaceReviewsPlan.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/PlaceReviewsPlan.java new file mode 100644 index 00000000..2060e67b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/PlaceReviewsPlan.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +public record PlaceReviewsPlan(Intent intent, String locationQuery) { + public enum Intent { PLACE_REVIEWS, OTHER } + + public PlaceReviewsPlan { + intent = intent == null ? Intent.OTHER : intent; + } + + public boolean isPlaceReviews() { + return intent == Intent.PLACE_REVIEWS && locationQuery != null && !locationQuery.isBlank(); + } + + public static PlaceReviewsPlan other() { return new PlaceReviewsPlan(Intent.OTHER, null); } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java index 5f6aebdf..b46459d2 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java @@ -4,6 +4,10 @@ import devkor.com.teamcback.domain.chatbot.dto.CrowdStatusPlan; import devkor.com.teamcback.domain.chatbot.dto.CrowdCandidateSelection; import devkor.com.teamcback.domain.chatbot.dto.CrowdCandidateView; +import devkor.com.teamcback.domain.chatbot.dto.LocationCandidateSelection; +import devkor.com.teamcback.domain.chatbot.dto.LocationCandidateView; +import devkor.com.teamcback.domain.chatbot.dto.LocationDetailPlan; +import devkor.com.teamcback.domain.chatbot.dto.PlaceReviewsPlan; import devkor.com.teamcback.domain.chatbot.service.ResolvedLocationCollector; import java.util.List; @@ -23,6 +27,20 @@ default CrowdCandidateSelection selectCrowdCandidate(String systemPrompt, default RoutePlan planRoute(String systemPrompt, List history, String userMessage) { return RoutePlan.notRoute(); } + default LocationDetailPlan planLocationDetail(String systemPrompt, List history, + String userMessage) { + return LocationDetailPlan.other(); + } + default PlaceReviewsPlan planPlaceReviews(String systemPrompt, List history, + String userMessage) { + return PlaceReviewsPlan.other(); + } + default LocationCandidateSelection selectLocationCandidate(String systemPrompt, + List history, + String userMessage, + List candidates) { + return LocationCandidateSelection.none(); + } LlmResult generate(String systemPrompt, List history, String userMessage, ResolvedLocationCollector executionState); diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java index 0e23d74d..71314f96 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java @@ -7,6 +7,10 @@ import devkor.com.teamcback.domain.chatbot.dto.CrowdStatusPlan; import devkor.com.teamcback.domain.chatbot.dto.CrowdCandidateSelection; import devkor.com.teamcback.domain.chatbot.dto.CrowdCandidateView; +import devkor.com.teamcback.domain.chatbot.dto.LocationCandidateSelection; +import devkor.com.teamcback.domain.chatbot.dto.LocationCandidateView; +import devkor.com.teamcback.domain.chatbot.dto.LocationDetailPlan; +import devkor.com.teamcback.domain.chatbot.dto.PlaceReviewsPlan; import devkor.com.teamcback.domain.chatbot.service.ResolvedLocationCollector; import devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter; import devkor.com.teamcback.domain.chatbot.service.ToolCallLimitExceededException; @@ -184,6 +188,64 @@ private RoutePlan invokeRoutePlan(String systemPrompt, List return plan == null ? RoutePlan.notRoute() : plan; } + @Override + public LocationDetailPlan planLocationDetail(String systemPrompt, List history, + String userMessage) { + return structured(() -> { + BeanOutputConverter converter = new BeanOutputConverter<>(LocationDetailPlan.class); + String prompt = "Determine whether this is a campus location detail/operating-hours request. " + + "Do not call tools or invent IDs. Preserve only the natural-language location phrase.\n" + + (systemPrompt == null ? "" : systemPrompt) + "\n" + converter.getFormat(); + var response = chatClient.prompt().system(prompt).messages(toSpringMessages(history, userMessage)).call(); + LocationDetailPlan plan = response == null ? null : response.entity(converter); + return plan == null ? LocationDetailPlan.other() : plan; + }, LocationDetailPlan.other()); + } + + @Override + public PlaceReviewsPlan planPlaceReviews(String systemPrompt, List history, + String userMessage) { + return structured(() -> { + BeanOutputConverter converter = new BeanOutputConverter<>(PlaceReviewsPlan.class); + String prompt = "Determine whether this is a campus place review request. Do not call tools or invent IDs. " + + "Preserve only the natural-language place phrase.\n" + + (systemPrompt == null ? "" : systemPrompt) + "\n" + converter.getFormat(); + var response = chatClient.prompt().system(prompt).messages(toSpringMessages(history, userMessage)).call(); + PlaceReviewsPlan plan = response == null ? null : response.entity(converter); + return plan == null ? PlaceReviewsPlan.other() : plan; + }, PlaceReviewsPlan.other()); + } + + @Override + public LocationCandidateSelection selectLocationCandidate(String systemPrompt, + List history, + String userMessage, + List candidates) { + return structured(() -> { + BeanOutputConverter converter = + new BeanOutputConverter<>(LocationCandidateSelection.class); + var response = chatClient.prompt().system(systemPrompt + "\n" + converter.getFormat()) + .messages(toSpringMessages(history, userMessage)).call(); + LocationCandidateSelection selection = response == null ? null : response.entity(converter); + return selection == null ? LocationCandidateSelection.none() : selection; + }, LocationCandidateSelection.none()); + } + + private T structured(java.util.concurrent.Callable callable, T fallback) { + Future response = chatbotLlmExecutor.submit(callable); + try { + T value = response.get(properties.llm().timeout().toMillis(), TimeUnit.MILLISECONDS); + return value == null ? fallback : value; + } catch (InterruptedException exception) { + response.cancel(true); + Thread.currentThread().interrupt(); + return fallback; + } catch (ExecutionException | TimeoutException exception) { + response.cancel(true); + return fallback; + } + } + @Override public LlmResult generate(String systemPrompt, List history, String userMessage, ResolvedLocationCollector executionState) { diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/CampusStatusWorkflow.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/CampusStatusWorkflow.java new file mode 100644 index 00000000..e263526f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/CampusStatusWorkflow.java @@ -0,0 +1,45 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.bsc.langgraph4j.StateGraph.END; +import static org.bsc.langgraph4j.StateGraph.START; + +import devkor.com.teamcback.domain.chatbot.tool.CampusToolAdapter; +import devkor.com.teamcback.domain.chatbot.tool.dto.CampusStatusToolResult; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import java.util.Map; +import java.util.UUID; +import org.bsc.langgraph4j.CompiledGraph; +import org.bsc.langgraph4j.GraphInput; +import org.bsc.langgraph4j.GraphStateException; +import org.bsc.langgraph4j.StateGraph; +import org.bsc.langgraph4j.action.AsyncNodeAction; +import org.bsc.langgraph4j.action.NodeAction; +import org.bsc.langgraph4j.state.AgentState; +import org.bsc.langgraph4j.state.Channel; +import org.bsc.langgraph4j.state.Channels; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** Small deterministic graph for current academic/campus status. */ +@Component +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class CampusStatusWorkflow { + private static final String LOAD="get_campus_status", FORMAT="format_response", REPLY="reply"; + private final CampusToolAdapter adapter; + private final CompiledGraph graph; + + @Autowired + public CampusStatusWorkflow(CampusToolAdapter adapter) { + this.adapter=adapter; + try { StateGraph s=new StateGraph<>(StatusGraphState.SCHEMA,StatusGraphState::new); + s.addNode(LOAD, AsyncNodeAction.node_async((NodeAction)this::load)); + s.addNode(FORMAT, AsyncNodeAction.node_async((NodeAction)this::format)); s.addEdge(START,LOAD);s.addEdge(LOAD,FORMAT);s.addEdge(FORMAT,END);graph=s.compile(); + } catch(GraphStateException e){throw new IllegalStateException("Unable to compile CAMPUS_STATUS workflow",e);} + } + public WorkflowResult handle(UUID sessionId, ChatCaller caller, String message) { try { var output=graph.invoke(GraphInput.args(Map.of()), org.bsc.langgraph4j.RunnableConfig.builder().threadId("campus-status:"+sessionId).build()); StatusGraphState state=output.orElseThrow(); return new WorkflowResult(true,state.value(REPLY,"")); } catch(Exception e){throw new IllegalStateException("CAMPUS_STATUS workflow failed",e);} } + private Map load(StatusGraphState s){CampusStatusToolResult r=adapter.getCampusStatus();return Map.of("term",r==null?"":String.valueOf(r.term()),"vacation",r!=null&&r.vacation(),"koyeon",r!=null&&r.koyeonPeriod(),"error",r!=null&&r.error()!=null?r.error().message():"");} + private Map format(StatusGraphState s){String error=s.value("error","");if(!error.isBlank())return Map.of(REPLY,error);StringBuilder b=new StringBuilder();b.append(s.value("vacation",false)?"현재 방학 기간이에요.":"현재 학기 중이에요.");if(!s.value("term","").isBlank())b.append("\n학기: ").append(s.value("term",""));if(s.value("koyeon",false))b.append("\n고연전 기간이에요.");return Map.of(REPLY,b.toString());} + public record WorkflowResult(boolean handled,String reply){} + public static final class StatusGraphState extends AgentState {private static Channel text(){return Channels.base(()->"");}private static Channel bool(){return Channels.base(()->false);}public static final Map> SCHEMA=Map.of("term",text(),"error",text(),REPLY,text(),"vacation",bool(),"koyeon",bool());public StatusGraphState(Mapd){super(d);}} +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java index e804f36a..d160d161 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java @@ -32,7 +32,9 @@ public RoutingDecision route(PendingWorkflow pendingWorkflow, String userMessage private boolean matchesPending(PendingWorkflow pendingWorkflow, Intent intent) { return (pendingWorkflow == PendingWorkflow.CROWD && intent == Intent.CROWD) - || (pendingWorkflow == PendingWorkflow.ROUTE && intent == Intent.ROUTE); + || (pendingWorkflow == PendingWorkflow.ROUTE && intent == Intent.ROUTE) + || (pendingWorkflow == PendingWorkflow.LOCATION_DETAIL && intent == Intent.LOCATION_DETAIL) + || (pendingWorkflow == PendingWorkflow.REVIEW && intent == Intent.REVIEW); } private Intent detectObviousIntent(String message) { @@ -61,7 +63,7 @@ private Intent detectObviousIntent(String message) { if (containsAny(normalized, "방학", "학기", "고연전")) { return Intent.CAMPUS_STATUS; } - if (containsAny(normalized, "어디야", "어디있", "열려", "운영시간")) { + if (containsAny(normalized, "어디야", "어디있", "열려", "운영시간", "몇시", "닫", "정보")) { return Intent.LOCATION_DETAIL; } return Intent.NONE; @@ -88,10 +90,18 @@ private String normalize(String message) { public enum PendingWorkflow { NONE, ROUTE, - CROWD; + CROWD, + LOCATION_DETAIL, + REVIEW; private WorkflowType toWorkflowType() { - return this == CROWD ? WorkflowType.CROWD : WorkflowType.ROUTE; + return switch (this) { + case CROWD -> WorkflowType.CROWD; + case LOCATION_DETAIL -> WorkflowType.LOCATION_DETAIL; + case REVIEW -> WorkflowType.REVIEW; + case ROUTE -> WorkflowType.ROUTE; + case NONE -> WorkflowType.GENERAL_CHAT; + }; } } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java index 04c29cef..303d65e1 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java @@ -68,17 +68,20 @@ NAVIGATE_ROUTE requires searchCampus(query, role=START, intent=NAVIGATE_ROUTE) a private final PendingRouteStateService pendingRouteStateService; private final CrowdStatusWorkflow crowdStatusWorkflow; private final ChatRequestRouter requestRouter; + private final LocationDetailWorkflow locationDetailWorkflow; + private final PlaceReviewsWorkflow placeReviewsWorkflow; + private final CampusStatusWorkflow campusStatusWorkflow; /** Compatibility constructor for focused unit tests that do not exercise pending state. */ public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService memoryService, ChatRateLimiter rateLimiter) { - this(chatOrchestrator, memoryService, rateLimiter, null, null, new ChatRequestRouter()); + this(chatOrchestrator, memoryService, rateLimiter, null, null, new ChatRequestRouter(), null, null, null); } /** Compatibility constructor for tests that provide pending-route state only. */ public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService memoryService, ChatRateLimiter rateLimiter, PendingRouteStateService pendingRouteStateService) { - this(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService, null, new ChatRequestRouter()); + this(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService, null, new ChatRequestRouter(), null, null, null); } /** Compatibility constructor for focused tests that provide the existing Crowd workflow. */ @@ -86,7 +89,7 @@ public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService m ChatRateLimiter rateLimiter, PendingRouteStateService pendingRouteStateService, CrowdStatusWorkflow crowdStatusWorkflow) { this(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService, - crowdStatusWorkflow, new ChatRequestRouter()); + crowdStatusWorkflow, new ChatRequestRouter(), null, null, null); } @Autowired @@ -94,13 +97,27 @@ public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService m ChatRateLimiter rateLimiter, PendingRouteStateService pendingRouteStateService, CrowdStatusWorkflow crowdStatusWorkflow, - ChatRequestRouter requestRouter) { + ChatRequestRouter requestRouter, + LocationDetailWorkflow locationDetailWorkflow, + PlaceReviewsWorkflow placeReviewsWorkflow, + CampusStatusWorkflow campusStatusWorkflow) { this.chatOrchestrator = chatOrchestrator; this.memoryService = memoryService; this.rateLimiter = rateLimiter; this.pendingRouteStateService = pendingRouteStateService; this.crowdStatusWorkflow = crowdStatusWorkflow; this.requestRouter = requestRouter; + this.locationDetailWorkflow = locationDetailWorkflow; + this.placeReviewsWorkflow = placeReviewsWorkflow; + this.campusStatusWorkflow = campusStatusWorkflow; + } + + /** Compatibility constructor for callers that only wire the PR1 routing boundary. */ + public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService memoryService, + ChatRateLimiter rateLimiter, PendingRouteStateService pendingRouteStateService, + CrowdStatusWorkflow crowdStatusWorkflow, ChatRequestRouter requestRouter) { + this(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService, crowdStatusWorkflow, + requestRouter, null, null, null); } public ChatMessageRes sendMessage(ChatMessageReq request, ChatCaller caller) { @@ -126,6 +143,25 @@ public ChatMessageRes sendMessage(ChatMessageReq request, ChatCaller caller) { return new ChatMessageRes(sessionId, reply, null); } } + if (locationDetailWorkflow != null && routing.workflowType() == ChatRequestRouter.WorkflowType.LOCATION_DETAIL + && (routing.route() == ChatRequestRouter.Route.CONTINUE_PENDING + || routing.route() == ChatRequestRouter.Route.NEW_INTENT)) { + LocationDetailWorkflow.WorkflowResult location = locationDetailWorkflow.handle( + sessionId, caller, toGatewayHistory(history), request.message()); + if (location.handled()) return saveWorkflowReply(sessionId, caller, request.message(), location.reply()); + } + if (placeReviewsWorkflow != null && routing.workflowType() == ChatRequestRouter.WorkflowType.REVIEW + && (routing.route() == ChatRequestRouter.Route.CONTINUE_PENDING + || routing.route() == ChatRequestRouter.Route.NEW_INTENT)) { + PlaceReviewsWorkflow.WorkflowResult review = placeReviewsWorkflow.handle( + sessionId, caller, toGatewayHistory(history), request.message()); + if (review.handled()) return saveWorkflowReply(sessionId, caller, request.message(), review.reply()); + } + if (campusStatusWorkflow != null && routing.workflowType() == ChatRequestRouter.WorkflowType.CAMPUS_STATUS + && routing.route() == ChatRequestRouter.Route.NEW_INTENT) { + CampusStatusWorkflow.WorkflowResult status = campusStatusWorkflow.handle(sessionId, caller, request.message()); + return saveWorkflowReply(sessionId, caller, request.message(), status.reply()); + } if (routing.route() == ChatRequestRouter.Route.CONTINUE_PENDING && routing.workflowType() == ChatRequestRouter.WorkflowType.ROUTE) { pending = loadPending(sessionId, caller); @@ -159,6 +195,12 @@ private ChatRequestRouter.PendingWorkflow activePendingWorkflow(UUID sessionId, if (crowdStatusWorkflow != null && crowdStatusWorkflow.hasPending(sessionId, caller)) { return ChatRequestRouter.PendingWorkflow.CROWD; } + if (locationDetailWorkflow != null && locationDetailWorkflow.hasPending(sessionId, caller)) { + return ChatRequestRouter.PendingWorkflow.LOCATION_DETAIL; + } + if (placeReviewsWorkflow != null && placeReviewsWorkflow.hasPending(sessionId, caller)) { + return ChatRequestRouter.PendingWorkflow.REVIEW; + } return pendingRoute == null ? ChatRequestRouter.PendingWorkflow.NONE : ChatRequestRouter.PendingWorkflow.ROUTE; } @@ -168,6 +210,12 @@ private void releaseSupersededWorkflows(UUID sessionId, ChatCaller caller, if (crowdStatusWorkflow != null && crowdStatusWorkflow.hasPending(sessionId, caller)) { crowdStatusWorkflow.cancel(sessionId, caller); } + if (locationDetailWorkflow != null && locationDetailWorkflow.hasPending(sessionId, caller)) { + locationDetailWorkflow.cancel(sessionId, caller); + } + if (placeReviewsWorkflow != null && placeReviewsWorkflow.hasPending(sessionId, caller)) { + placeReviewsWorkflow.cancel(sessionId, caller); + } if (pendingRoute != null) { deletePending(sessionId, caller); } @@ -178,6 +226,12 @@ private PendingRouteState loadPending(UUID sessionId, ChatCaller caller) { : pendingRouteStateService.load(sessionId, caller).orElse(null); } + private ChatMessageRes saveWorkflowReply(UUID sessionId, ChatCaller caller, String userMessage, String reply) { + String safeReply = reply == null || reply.isBlank() ? "요청을 처리하지 못했어요." : reply; + memoryService.save(sessionId, caller, userMessage, safeReply); + return new ChatMessageRes(sessionId, safeReply, null); + } + private void deletePending(UUID sessionId, ChatCaller caller) { if (pendingRouteStateService != null) { pendingRouteStateService.delete(sessionId, caller); diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/LocationCandidateSelector.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/LocationCandidateSelector.java new file mode 100644 index 00000000..507a70de --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/LocationCandidateSelector.java @@ -0,0 +1,36 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import devkor.com.teamcback.domain.chatbot.dto.LocationCandidateSelection; +import devkor.com.teamcback.domain.chatbot.dto.LocationCandidateView; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** Interprets a clarification reply using safe candidate views; it never receives backend IDs. */ +@Component +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class LocationCandidateSelector implements SemanticCandidateSelector { + private static final String PROMPT = """ + Select exactly one campus location from the candidates below. + Return SELECTED only for one clear match, AMBIGUOUS when unclear, and NONE when no candidate matches. + Return only a candidate index. Never invent IDs or call tools. + Candidates:\n%s + """; + + private final LlmGateway gateway; + + public LocationCandidateSelector(LlmGateway gateway) { this.gateway = gateway; } + + @Override + public LocationCandidateSelection select(String userMessage, List candidates) { + List safe = candidates == null ? List.of() : List.copyOf(candidates); + if (safe.isEmpty()) return LocationCandidateSelection.none(); + String text = safe.stream().map(c -> "%d: %s%s".formatted(c.index(), c.name(), + c.buildingName().isBlank() ? "" : " (" + c.buildingName() + ")")) + .reduce((a, b) -> a + "\n" + b).orElse(""); + LocationCandidateSelection selection = gateway.selectLocationCandidate( + PROMPT.formatted(text), List.of(), userMessage, safe); + return selection == null ? LocationCandidateSelection.none() : selection; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/LocationDetailPlanner.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/LocationDetailPlanner.java new file mode 100644 index 00000000..c47a41ec --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/LocationDetailPlanner.java @@ -0,0 +1,19 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import devkor.com.teamcback.domain.chatbot.dto.LocationDetailPlan; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class LocationDetailPlanner { + private final LlmGateway gateway; + + public LocationDetailPlanner(LlmGateway gateway) { this.gateway = gateway; } + + public LocationDetailPlan plan(List history, String message) { + return gateway.planLocationDetail("LOCATION_DETAIL workflow planner", history, message); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/LocationDetailWorkflow.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/LocationDetailWorkflow.java new file mode 100644 index 00000000..4be91e2f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/LocationDetailWorkflow.java @@ -0,0 +1,255 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.bsc.langgraph4j.StateGraph.END; +import static org.bsc.langgraph4j.StateGraph.START; + +import devkor.com.teamcback.domain.chatbot.dto.LocationCandidateSelection; +import devkor.com.teamcback.domain.chatbot.dto.LocationCandidateView; +import devkor.com.teamcback.domain.chatbot.dto.LocationDetailPlan; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import devkor.com.teamcback.domain.chatbot.tool.CampusToolAdapter; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.LocationDetailToolData; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusItem; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.bsc.langgraph4j.CompileConfig; +import org.bsc.langgraph4j.CompiledGraph; +import org.bsc.langgraph4j.GraphInput; +import org.bsc.langgraph4j.GraphStateException; +import org.bsc.langgraph4j.RunnableConfig; +import org.bsc.langgraph4j.StateGraph; +import org.bsc.langgraph4j.action.AsyncEdgeAction; +import org.bsc.langgraph4j.action.AsyncNodeAction; +import org.bsc.langgraph4j.action.EdgeAction; +import org.bsc.langgraph4j.action.NodeAction; +import org.bsc.langgraph4j.checkpoint.MemorySaver; +import org.bsc.langgraph4j.state.AgentState; +import org.bsc.langgraph4j.state.Channel; +import org.bsc.langgraph4j.state.Channels; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** Deterministic location-detail workflow. The model may parse/select semantics, never execution order or IDs. */ +@Component +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class LocationDetailWorkflow { + private static final String SEARCH = "search_location"; + private static final String DECIDE = "candidate_decision"; + private static final String ASK = "ask_clarification"; + private static final String SELECT = "select_candidate"; + private static final String LOAD = "get_location_detail"; + private static final String FORMAT = "format_response"; + private static final String OWNER = "owner"; + private static final String QUERY = "query"; + private static final String CANDIDATES = "candidates"; + private static final String SELECTED_ID = "selectedId"; + private static final String SELECTED_TYPE = "selectedType"; + private static final String SELECTED_NAME = "selectedName"; + private static final String REPLY = "reply"; + private static final String INPUT = "input"; + private static final String AWAITING = "awaiting"; + private static final String DETAIL = "detail"; + + private final LocationDetailPlanner planner; + private final CampusToolAdapter adapter; + private final LocationCandidateSelector selector; + private final MemorySaver saver = new MemorySaver(); + private final CompiledGraph graph; + + @Autowired + public LocationDetailWorkflow(LocationDetailPlanner planner, CampusToolAdapter adapter, + LocationCandidateSelector selector) { + this.planner = planner; + this.adapter = adapter; + this.selector = selector; + try { + StateGraph state = new StateGraph<>(LocationGraphState.SCHEMA, LocationGraphState::new); + state.addNode(SEARCH, AsyncNodeAction.node_async((NodeAction) this::search)); + state.addNode(DECIDE, AsyncNodeAction.node_async((NodeAction) this::decide)); + state.addNode(ASK, AsyncNodeAction.node_async((NodeAction) this::ask)); + state.addNode(SELECT, AsyncNodeAction.node_async((NodeAction) this::select)); + state.addNode(LOAD, AsyncNodeAction.node_async((NodeAction) this::load)); + state.addNode(FORMAT, AsyncNodeAction.node_async((NodeAction) this::format)); + state.addEdge(START, SEARCH); + state.addEdge(SEARCH, DECIDE); + state.addConditionalEdges(DECIDE, + AsyncEdgeAction.edge_async((EdgeAction) this::afterDecision), + Map.of(ASK, ASK, LOAD, LOAD, FORMAT, FORMAT, END, END)); + state.addEdge(ASK, SELECT); + state.addConditionalEdges(SELECT, + AsyncEdgeAction.edge_async((EdgeAction) this::afterSelection), + Map.of(ASK, ASK, LOAD, LOAD, END, END)); + state.addEdge(LOAD, FORMAT); + state.addEdge(FORMAT, END); + graph = state.compile(CompileConfig.builder().checkpointSaver(saver).interruptAfter(ASK) + .releaseThread(true).build()); + } catch (GraphStateException exception) { + throw new IllegalStateException("Unable to compile LOCATION_DETAIL workflow", exception); + } + } + + public WorkflowResult handle(UUID sessionId, ChatCaller caller, List history, + String message) { + RunnableConfig config = config(sessionId); + var checkpoint = graph.lastStateOf(config); + LocationGraphState state; + if (checkpoint.isPresent()) { + assertOwner(checkpoint.get().state(), caller); + state = run(GraphInput.resume(Map.of(INPUT, message)), config); + } else { + LocationDetailPlan plan = planner.plan(history, message); + if (plan == null || !plan.isLocationDetail()) return WorkflowResult.notHandled(); + state = run(GraphInput.args(Map.of(OWNER, caller.key(), QUERY, plan.locationQuery(), INPUT, message)), config); + } + WorkflowResult result = result(state); + if (!result.waiting()) release(config); + return result; + } + + public boolean hasPending(UUID sessionId, ChatCaller caller) { + var checkpoint = graph.lastStateOf(config(sessionId)); + if (checkpoint.isEmpty()) return false; + assertOwner(checkpoint.get().state(), caller); + return true; + } + + public void cancel(UUID sessionId, ChatCaller caller) { + var config = config(sessionId); + var checkpoint = graph.lastStateOf(config); + if (checkpoint.isEmpty()) return; + assertOwner(checkpoint.get().state(), caller); + release(config); + } + + private Map search(LocationGraphState state) { + SearchCampusToolResult found = adapter.searchCampus(new SearchCampusToolRequest(state.value(QUERY, ""), 5)); + List> candidates = new ArrayList<>(); + if (found != null && found.candidates() != null) { + for (SearchCampusItem item : found.candidates()) { + Map value = new HashMap<>(); + value.put("id", item.locationId()); value.put("type", item.locationType().name()); + value.put("name", item.name()); value.put("buildingName", item.buildingName()); + value.put("floor", item.floor() == null ? "" : String.valueOf(item.floor())); + value.put("placeType", item.placeType() == null ? "" : item.placeType().name()); + candidates.add(value); + } + } + return Map.of(CANDIDATES, candidates, REPLY, candidates.isEmpty() + ? "요청한 장소를 찾지 못했어요." : ""); + } + + private Map decide(LocationGraphState state) { + List> candidates = state.value(CANDIDATES, List.of()); + if (candidates.size() == 1) return selected(candidates.get(0)); + if (candidates.size() > 1) return Map.of(AWAITING, "LOCATION_SELECTION"); + return Map.of(AWAITING, ""); + } + + private Map ask(LocationGraphState state) { + String names = state.value(CANDIDATES, List.>of()).stream() + .map(c -> String.valueOf(c.get("name"))).distinct().reduce((a, b) -> a + ", " + b).orElse(""); + return Map.of(REPLY, "어느 장소를 말씀하신 건가요? " + names); + } + + private Map select(LocationGraphState state) { + List> candidates = state.value(CANDIDATES, List.of()); + List views = new ArrayList<>(); + for (int i = 0; i < candidates.size(); i++) { + Map c = candidates.get(i); + views.add(new LocationCandidateView(i, String.valueOf(c.get("name")), + ToolLocationType.valueOf(String.valueOf(c.get("type"))), String.valueOf(c.get("buildingName")), + String.valueOf(c.get("floor")), String.valueOf(c.get("placeType")))); + } + LocationCandidateSelection selection = selector.select(state.value(INPUT, ""), views); + Integer index = selection == null ? null : selection.candidateIndex(); + if (selection != null && selection.status() == LocationCandidateSelection.Status.SELECTED + && index != null && index >= 0 && index < candidates.size()) return selected(candidates.get(index)); + return Map.of(AWAITING, "LOCATION_SELECTION", INPUT, "", REPLY, + "후보 중 하나를 장소 이름으로 말씀해 주세요."); + } + + private Map load(LocationGraphState state) { + Long id = Long.valueOf(state.value(SELECTED_ID, "0")); + GetLocationDetailToolResult result = adapter.getLocationDetail(new GetLocationDetailToolRequest( + ToolLocationType.valueOf(state.value(SELECTED_TYPE, "PLACE")), id)); + if (result == null || result.location() == null) return Map.of(REPLY, error(result)); + LocationDetailToolData detail = result.location(); + Map data = new HashMap<>(); + data.put("name", detail.name()); data.put("address", detail.address()); data.put("detail", detail.detail()); + data.put("openNow", detail.openNow()); data.put("weekdayHours", detail.weekdayHours()); + data.put("saturdayHours", detail.saturdayHours()); data.put("sundayHours", detail.sundayHours()); + return Map.of(DETAIL, data); + } + + private Map format(LocationGraphState state) { + if (!state.value(REPLY, "").isBlank()) return Map.of(); + Map d = state.value(DETAIL, Map.of()); + StringBuilder reply = new StringBuilder(String.valueOf(d.getOrDefault("name", state.value(SELECTED_NAME, "장소")))); + if (d.get("openNow") != null) reply.append(" 현재 ").append(Boolean.TRUE.equals(d.get("openNow")) ? "운영 중" : "운영 종료"); + if (d.get("weekdayHours") != null && !String.valueOf(d.get("weekdayHours")).isBlank()) + reply.append("\n평일: ").append(d.get("weekdayHours")); + if (d.get("address") != null && !String.valueOf(d.get("address")).isBlank()) + reply.append("\n위치: ").append(d.get("address")); + return Map.of(REPLY, reply.toString()); + } + + private Map selected(Map candidate) { + return Map.of(SELECTED_ID, String.valueOf(candidate.get("id")), SELECTED_TYPE, candidate.get("type"), + SELECTED_NAME, candidate.get("name"), AWAITING, "", INPUT, ""); + } + + private String afterDecision(LocationGraphState state) { + if (state.value(CANDIDATES, List.>of()).isEmpty()) return END; + if (!state.value(SELECTED_ID, "").isBlank()) return LOAD; + return ASK; + } + + private String afterSelection(LocationGraphState state) { + return state.value(SELECTED_ID, "").isBlank() ? ASK : LOAD; + } + + private WorkflowResult result(LocationGraphState state) { + return new WorkflowResult(true, state.value(REPLY, ""), !state.value(AWAITING, "").isBlank()); + } + + private LocationGraphState run(GraphInput input, RunnableConfig config) { + try { return graph.invoke(input, config).orElseGet(() -> graph.lastStateOf(config).orElseThrow().state()); } + catch (Exception exception) { throw new IllegalStateException("LOCATION_DETAIL workflow failed", exception); } + } + + private RunnableConfig config(UUID sessionId) { return RunnableConfig.builder().threadId("location:" + sessionId).build(); } + private void release(RunnableConfig config) { try { saver.release(config); } catch (Exception e) { throw new IllegalStateException(e); } } + private void assertOwner(LocationGraphState state, ChatCaller caller) { + if (!caller.key().equals(state.value(OWNER, ""))) + throw new GlobalException(devkor.com.teamcback.global.response.ResultCode.CHATBOT_SESSION_FORBIDDEN); + } + private String error(SearchCampusToolResult result) { return result != null && result.error() != null + ? result.error().message() : "장소 정보를 확인할 수 없어요."; } + private String error(GetLocationDetailToolResult result) { return result != null && result.error() != null + ? result.error().message() : "장소 정보를 확인할 수 없어요."; } + + public record WorkflowResult(boolean handled, String reply, boolean waiting) { + public static WorkflowResult notHandled() { return new WorkflowResult(false, null, false); } + } + + public static final class LocationGraphState extends AgentState { + private static Channel text() { return Channels.base(() -> ""); } + private static Channel>> list() { return Channels.base(ArrayList::new); } + private static Channel> map() { return Channels.>base(() -> new HashMap<>()); } + public static final Map> SCHEMA = Map.ofEntries( + Map.entry(OWNER, text()), Map.entry(QUERY, text()), Map.entry(CANDIDATES, list()), + Map.entry(SELECTED_ID, text()), Map.entry(SELECTED_TYPE, text()), Map.entry(SELECTED_NAME, text()), + Map.entry(REPLY, text()), Map.entry(INPUT, text()), Map.entry(AWAITING, text()), Map.entry(DETAIL, map())); + public LocationGraphState(Map data) { super(data); } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/PlaceReviewsPlanner.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/PlaceReviewsPlanner.java new file mode 100644 index 00000000..b8b4a084 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/PlaceReviewsPlanner.java @@ -0,0 +1,19 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import devkor.com.teamcback.domain.chatbot.dto.PlaceReviewsPlan; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class PlaceReviewsPlanner { + private final LlmGateway gateway; + + public PlaceReviewsPlanner(LlmGateway gateway) { this.gateway = gateway; } + + public PlaceReviewsPlan plan(List history, String message) { + return gateway.planPlaceReviews("PLACE_REVIEWS workflow planner", history, message); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/PlaceReviewsWorkflow.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/PlaceReviewsWorkflow.java new file mode 100644 index 00000000..5f6a8331 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/PlaceReviewsWorkflow.java @@ -0,0 +1,121 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.bsc.langgraph4j.StateGraph.END; +import static org.bsc.langgraph4j.StateGraph.START; + +import devkor.com.teamcback.domain.chatbot.dto.LocationCandidateSelection; +import devkor.com.teamcback.domain.chatbot.dto.LocationCandidateView; +import devkor.com.teamcback.domain.chatbot.dto.PlaceReviewsPlan; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import devkor.com.teamcback.domain.chatbot.tool.CampusToolAdapter; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetPlaceReviewsToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetPlaceReviewsToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.PlaceReviewsToolData; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusItem; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.bsc.langgraph4j.CompileConfig; +import org.bsc.langgraph4j.CompiledGraph; +import org.bsc.langgraph4j.GraphInput; +import org.bsc.langgraph4j.GraphStateException; +import org.bsc.langgraph4j.RunnableConfig; +import org.bsc.langgraph4j.StateGraph; +import org.bsc.langgraph4j.action.AsyncEdgeAction; +import org.bsc.langgraph4j.action.AsyncNodeAction; +import org.bsc.langgraph4j.action.EdgeAction; +import org.bsc.langgraph4j.action.NodeAction; +import org.bsc.langgraph4j.checkpoint.MemorySaver; +import org.bsc.langgraph4j.state.AgentState; +import org.bsc.langgraph4j.state.Channel; +import org.bsc.langgraph4j.state.Channels; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** Backend-owned reviews workflow. Place IDs never leave graph state. */ +@Component +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class PlaceReviewsWorkflow { + private static final String SEARCH="search_location", DECIDE="candidate_decision", ASK="ask_clarification", + SELECT="select_candidate", LOAD="get_reviews", FORMAT="format_response"; + private static final String OWNER="owner", QUERY="query", CANDIDATES="candidates", ID="selectedId", + NAME="selectedName", REPLY="reply", INPUT="input", AWAITING="awaiting", REVIEWS="reviews"; + private final PlaceReviewsPlanner planner; + private final CampusToolAdapter adapter; + private final LocationCandidateSelector selector; + private final MemorySaver saver = new MemorySaver(); + private final CompiledGraph graph; + + @Autowired + public PlaceReviewsWorkflow(PlaceReviewsPlanner planner, CampusToolAdapter adapter, + LocationCandidateSelector selector) { + this.planner=planner; this.adapter=adapter; this.selector=selector; + try { + StateGraph s = new StateGraph<>(ReviewGraphState.SCHEMA, ReviewGraphState::new); + s.addNode(SEARCH, AsyncNodeAction.node_async((NodeAction) this::search)); + s.addNode(DECIDE, AsyncNodeAction.node_async((NodeAction) this::decide)); + s.addNode(ASK, AsyncNodeAction.node_async((NodeAction) this::ask)); + s.addNode(SELECT, AsyncNodeAction.node_async((NodeAction) this::select)); + s.addNode(LOAD, AsyncNodeAction.node_async((NodeAction) this::load)); + s.addNode(FORMAT, AsyncNodeAction.node_async((NodeAction) this::format)); + s.addEdge(START, SEARCH); s.addEdge(SEARCH, DECIDE); + s.addConditionalEdges(DECIDE, AsyncEdgeAction.edge_async((EdgeAction) this::afterDecision), + Map.of(ASK,ASK,LOAD,LOAD,END,END)); + s.addEdge(ASK, SELECT); + s.addConditionalEdges(SELECT, AsyncEdgeAction.edge_async((EdgeAction) this::afterSelection), + Map.of(ASK,ASK,LOAD,LOAD)); + s.addEdge(LOAD, FORMAT); s.addEdge(FORMAT, END); + graph=s.compile(CompileConfig.builder().checkpointSaver(saver).interruptAfter(ASK).releaseThread(true).build()); + } catch (GraphStateException e) { throw new IllegalStateException("Unable to compile REVIEW workflow", e); } + } + + public WorkflowResult handle(UUID sessionId, ChatCaller caller, List history, + String message) { + RunnableConfig config=config(sessionId); var checkpoint=graph.lastStateOf(config); ReviewGraphState state; + if (checkpoint.isPresent()) { assertOwner(checkpoint.get().state(),caller); state=run(GraphInput.resume(Map.of(INPUT,message)),config); } + else { PlaceReviewsPlan plan=planner.plan(history,message); if(plan==null||!plan.isPlaceReviews()) return WorkflowResult.notHandled(); + state=run(GraphInput.args(Map.of(OWNER,caller.key(),QUERY,plan.locationQuery(),INPUT,message)),config); } + WorkflowResult result=result(state); if(!result.waiting()) release(config); return result; + } + public boolean hasPending(UUID sessionId, ChatCaller caller) { var c=graph.lastStateOf(config(sessionId)); if(c.isEmpty())return false; assertOwner(c.get().state(),caller); return true; } + public void cancel(UUID sessionId, ChatCaller caller) { var c=graph.lastStateOf(config(sessionId)); if(c.isEmpty())return; assertOwner(c.get().state(),caller); release(config(sessionId)); } + + private Map search(ReviewGraphState state) { + SearchCampusToolResult found=adapter.searchCampus(new SearchCampusToolRequest(state.value(QUERY,""),5)); + List> values=new ArrayList<>(); + if(found!=null&&found.candidates()!=null) for(SearchCampusItem item:found.candidates()) if(item.locationType()==ToolLocationType.PLACE) { + Map v=new HashMap<>(); v.put("id",item.locationId()); v.put("name",item.name()); v.put("buildingName",item.buildingName()); + v.put("type",item.locationType().name()); v.put("floor",item.floor()==null?"":String.valueOf(item.floor())); + v.put("placeType",item.placeType()==null?"":item.placeType().name()); values.add(v); + } + return Map.of(CANDIDATES,values,REPLY,values.isEmpty()?"리뷰를 확인할 수 있는 장소를 찾지 못했어요.":""); + } + private Map decide(ReviewGraphState state) { List> c=state.value(CANDIDATES,List.of()); if(c.size()==1)return selected(c.get(0)); if(c.size()>1)return Map.of(AWAITING,"LOCATION_SELECTION"); return Map.of(AWAITING,""); } + private Map ask(ReviewGraphState state) { String n=state.value(CANDIDATES,List.>of()).stream().map(c->String.valueOf(c.get("name"))).distinct().reduce((a,b)->a+", "+b).orElse(""); return Map.of(REPLY,"어느 장소의 리뷰를 볼까요? "+n); } + private Map select(ReviewGraphState state) { + List> c=state.value(CANDIDATES,List.of()); List v=new ArrayList<>(); + for(int i=0;ix=c.get(i);v.add(new LocationCandidateView(i,String.valueOf(x.get("name")),ToolLocationType.PLACE,String.valueOf(x.get("buildingName")),String.valueOf(x.get("floor")),String.valueOf(x.get("placeType"))));} + LocationCandidateSelection s=selector.select(state.value(INPUT,""),v); Integer i=s==null?null:s.candidateIndex(); + if(s!=null&&s.status()==LocationCandidateSelection.Status.SELECTED&&i!=null&&i>=0&&i load(ReviewGraphState state) { GetPlaceReviewsToolResult r=adapter.getPlaceReviews(new GetPlaceReviewsToolRequest(Long.valueOf(state.value(ID,"0")),5)); if(r==null||r.reviews()==null)return Map.of(REPLY,r!=null&&r.error()!=null?r.error().message():"리뷰 데이터가 없어요."); PlaceReviewsToolData d=r.reviews(); Map m=new HashMap<>();m.put("name",d.placeName());m.put("rating",d.averageRating());m.put("tags",d.topTags());m.put("reviews",d.reviews());return Map.of(REVIEWS,m); } + private Map format(ReviewGraphState state) { if(!state.value(REPLY,"").isBlank())return Map.of(); Mapd=state.value(REVIEWS,Map.of()); StringBuilder b=new StringBuilder(String.valueOf(d.getOrDefault("name",state.value(NAME,"장소")))); if(d.get("rating")!=null)b.append(" 평균 평점 ").append(d.get("rating")); Object tags=d.get("tags");if(tags!=null&&!String.valueOf(tags).equals("[]"))b.append("\n주요 태그: ").append(tags); Object reviews=d.get("reviews");if(reviews!=null&&!String.valueOf(reviews).equals("[]"))b.append("\n후기: ").append(reviews);return Map.of(REPLY,b.toString()); } + private Map selected(Map c){return Map.of(ID,String.valueOf(c.get("id")),NAME,c.get("name"),AWAITING,"",INPUT,"");} + private String afterDecision(ReviewGraphState s){if(s.value(CANDIDATES,List.>of()).isEmpty())return END;return s.value(ID,"").isBlank()?ASK:LOAD;} + private String afterSelection(ReviewGraphState s){return s.value(ID,"").isBlank()?ASK:LOAD;} + private WorkflowResult result(ReviewGraphState s){return new WorkflowResult(true,s.value(REPLY,""),!s.value(AWAITING,"").isBlank());} + private ReviewGraphState run(GraphInput i,RunnableConfig c){try{return graph.invoke(i,c).orElseGet(()->graph.lastStateOf(c).orElseThrow().state());}catch(Exception e){throw new IllegalStateException("REVIEW workflow failed",e);}} + private RunnableConfig config(UUID id){return RunnableConfig.builder().threadId("review:"+id).build();} + private void release(RunnableConfig c){try{saver.release(c);}catch(Exception e){throw new IllegalStateException(e);}} + private void assertOwner(ReviewGraphState s,ChatCaller c){if(!c.key().equals(s.value(OWNER,"")))throw new GlobalException(devkor.com.teamcback.global.response.ResultCode.CHATBOT_SESSION_FORBIDDEN);} + public record WorkflowResult(boolean handled,String reply,boolean waiting){public static WorkflowResult notHandled(){return new WorkflowResult(false,null,false);}} + public static final class ReviewGraphState extends AgentState { private static Channel text(){return Channels.base(()->"");} private static Channel>> list(){return Channels.base(ArrayList::new);} private static Channel> map(){return Channels.>base(() -> new HashMap<>());} public static final Map> SCHEMA=Map.ofEntries(Map.entry(OWNER,text()),Map.entry(QUERY,text()),Map.entry(CANDIDATES,list()),Map.entry(ID,text()),Map.entry(NAME,text()),Map.entry(REPLY,text()),Map.entry(INPUT,text()),Map.entry(AWAITING,text()),Map.entry(REVIEWS,map())); public ReviewGraphState(Mapd){super(d);} } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/BackendWorkflowTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/BackendWorkflowTest.java new file mode 100644 index 00000000..a80be870 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/BackendWorkflowTest.java @@ -0,0 +1,130 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.chatbot.dto.LocationCandidateSelection; +import devkor.com.teamcback.domain.chatbot.dto.LocationDetailPlan; +import devkor.com.teamcback.domain.chatbot.dto.PlaceReviewsPlan; +import devkor.com.teamcback.domain.chatbot.tool.CampusToolAdapter; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolRequest; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetLocationDetailToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.GetPlaceReviewsToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.LocationDetailToolData; +import devkor.com.teamcback.domain.chatbot.tool.dto.PlaceReviewsToolData; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusItem; +import devkor.com.teamcback.domain.chatbot.tool.dto.SearchCampusToolResult; +import devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class BackendWorkflowTest { + private static final ChatCaller CALLER = new ChatCaller("user:test", true); + + @Mock LocationDetailPlanner locationPlanner; + @Mock PlaceReviewsPlanner reviewPlanner; + @Mock CampusToolAdapter adapter; + @Mock LocationCandidateSelector selector; + + @Test + void locationDetailSearchesThenLoadsBackendIdWithoutAskingUserForId() { + when(locationPlanner.plan(any(), any())).thenReturn( + new LocationDetailPlan(LocationDetailPlan.Intent.LOCATION_DETAIL, "중앙도서관")); + when(adapter.searchCampus(any())).thenReturn(new SearchCampusToolResult(List.of( + new SearchCampusItem(41L, ToolLocationType.BUILDING, "중앙도서관", 41L, + "중앙도서관", null, null, null, null)), false, null)); + when(adapter.getLocationDetail(any())).thenReturn(new GetLocationDetailToolResult( + new LocationDetailToolData(41L, ToolLocationType.BUILDING, "중앙도서관", 41L, + "중앙도서관", null, "서울", null, null, true, null, + "09:00-21:00", null, null, null, null, null, null), null)); + + LocationDetailWorkflow workflow = new LocationDetailWorkflow(locationPlanner, adapter, selector); + var result = workflow.handle(UUID.randomUUID(), CALLER, List.of(), "중앙도서관 몇시에 닫어?"); + + assertThat(result.handled()).isTrue(); + assertThat(result.reply()).contains("중앙도서관").doesNotContain("ID").doesNotContain("아이디"); + verify(adapter).getLocationDetail(any(GetLocationDetailToolRequest.class)); + verify(selector, never()).select(any(), any()); + } + + @Test + void ambiguousLocationIsInterruptedAndResumesWithCandidateIndex() { + UUID session = UUID.randomUUID(); + when(locationPlanner.plan(any(), any())).thenReturn( + new LocationDetailPlan(LocationDetailPlan.Intent.LOCATION_DETAIL, "블루포트")); + when(adapter.searchCampus(any())).thenReturn(new SearchCampusToolResult(List.of( + new SearchCampusItem(1L, ToolLocationType.PLACE, "블루포트", 10L, "중앙광장", 1D, null, null, null), + new SearchCampusItem(2L, ToolLocationType.PLACE, "블루포트", 20L, "하나스퀘어", 1D, null, null, null)), true, null)); + when(selector.select(any(), any())).thenReturn( + new LocationCandidateSelection(LocationCandidateSelection.Status.SELECTED, 1)); + when(adapter.getLocationDetail(any())).thenReturn(new GetLocationDetailToolResult( + new LocationDetailToolData(2L, ToolLocationType.PLACE, "블루포트", 20L, + "하나스퀘어", 1, null, null, null, false, null, + "10:00-20:00", null, null, null, null, null, null), null)); + + LocationDetailWorkflow workflow = new LocationDetailWorkflow(locationPlanner, adapter, selector); + var pending = workflow.handle(session, CALLER, List.of(), "블루포트 몇시에 닫아?"); + var resumed = workflow.handle(session, CALLER, List.of(), "하나스퀘어"); + + assertThat(pending.waiting()).isTrue(); + assertThat(resumed.waiting()).isFalse(); + verify(adapter).getLocationDetail(any(GetLocationDetailToolRequest.class)); + } + + @Test + void invalidCandidateIndexNeverLoadsDetail() { + when(locationPlanner.plan(any(), any())).thenReturn( + new LocationDetailPlan(LocationDetailPlan.Intent.LOCATION_DETAIL, "블루포트")); + when(adapter.searchCampus(any())).thenReturn(new SearchCampusToolResult(List.of( + new SearchCampusItem(1L, ToolLocationType.PLACE, "블루포트", 10L, "중앙광장", 1D, null, null, null), + new SearchCampusItem(2L, ToolLocationType.PLACE, "블루포트", 20L, "하나스퀘어", 1D, null, null, null)), true, null)); + when(selector.select(any(), any())).thenReturn( + new LocationCandidateSelection(LocationCandidateSelection.Status.SELECTED, 99)); + + LocationDetailWorkflow workflow = new LocationDetailWorkflow(locationPlanner, adapter, selector); + UUID session = UUID.randomUUID(); + workflow.handle(session, CALLER, List.of(), "블루포트"); + var result = workflow.handle(session, CALLER, List.of(), "알 수 없는 곳"); + + assertThat(result.waiting()).isTrue(); + verify(adapter, never()).getLocationDetail(any()); + } + + @Test + void reviewWorkflowResolvesPlaceBeforeLoadingReviews() { + when(reviewPlanner.plan(any(), any())).thenReturn( + new PlaceReviewsPlan(PlaceReviewsPlan.Intent.PLACE_REVIEWS, "블루포트")); + when(adapter.searchCampus(any())).thenReturn(new SearchCampusToolResult(List.of( + new SearchCampusItem(7L, ToolLocationType.PLACE, "블루포트", 20L, "하나스퀘어", + 1D, null, null, null)), false, null)); + when(adapter.getPlaceReviews(any())).thenReturn(new GetPlaceReviewsToolResult( + new PlaceReviewsToolData(7L, "블루포트", 4.5, List.of(), List.of(), false), null)); + + PlaceReviewsWorkflow workflow = new PlaceReviewsWorkflow(reviewPlanner, adapter, selector); + var result = workflow.handle(UUID.randomUUID(), CALLER, List.of(), "블루포트 후기 알려줘"); + + assertThat(result.reply()).contains("블루포트").contains("4.5"); + verify(adapter).getPlaceReviews(any()); + } + + @Test + void campusStatusWorkflowDoesNotUseGeneralToolCalling() { + when(adapter.getCampusStatus()).thenReturn( + new devkor.com.teamcback.domain.chatbot.tool.dto.CampusStatusToolResult( + "2026-1", false, false, null)); + + CampusStatusWorkflow workflow = new CampusStatusWorkflow(adapter); + var result = workflow.handle(UUID.randomUUID(), CALLER, "지금 학기야?"); + + assertThat(result.reply()).contains("학기"); + verify(adapter).getCampusStatus(); + } +} From 1e9669918eea6c243ba4792949f3ce22a1fa428a Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Tue, 29 Sep 2026 01:37:19 +0900 Subject: [PATCH 19/21] feat(chatbot): add backend-owned menu, facility, and room workflows --- .../domain/chatbot/dto/FacilityPlan.java | 8 ++ .../domain/chatbot/dto/MenuPlan.java | 8 ++ .../domain/chatbot/dto/RoomCoursePlan.java | 8 ++ .../domain/chatbot/gateway/LlmGateway.java | 6 ++ .../chatbot/gateway/SpringAiLlmGateway.java | 33 ++++++++ .../chatbot/service/ChatRequestRouter.java | 10 ++- .../domain/chatbot/service/ChatService.java | 48 +++++++++-- .../chatbot/service/FacilityPlanner.java | 15 ++++ .../chatbot/service/FacilityWorkflow.java | 23 ++++++ .../domain/chatbot/service/MenuPlanner.java | 15 ++++ .../domain/chatbot/service/MenuWorkflow.java | 59 ++++++++++++++ .../chatbot/service/RoomCoursePlanner.java | 15 ++++ .../chatbot/service/RoomCourseWorkflow.java | 18 +++++ .../domain/chatbot/service/WorkflowDates.java | 45 +++++++++++ .../service/BackendCampusWorkflowsTest.java | 80 +++++++++++++++++++ 15 files changed, 384 insertions(+), 7 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/FacilityPlan.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/MenuPlan.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/dto/RoomCoursePlan.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/FacilityPlanner.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/FacilityWorkflow.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/MenuPlanner.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/MenuWorkflow.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/RoomCoursePlanner.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/RoomCourseWorkflow.java create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/service/WorkflowDates.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/service/BackendCampusWorkflowsTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/FacilityPlan.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/FacilityPlan.java new file mode 100644 index 00000000..c7e9213c --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/FacilityPlan.java @@ -0,0 +1,8 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +public record FacilityPlan(Intent intent, String locationQuery, String facilityType, Integer floor) { + public enum Intent { FACILITY, OTHER } + public FacilityPlan { intent = intent == null ? Intent.OTHER : intent; } + public boolean isFacility() { return intent == Intent.FACILITY; } + public static FacilityPlan other() { return new FacilityPlan(Intent.OTHER, null, null, null); } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/MenuPlan.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/MenuPlan.java new file mode 100644 index 00000000..20f77e26 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/MenuPlan.java @@ -0,0 +1,8 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +public record MenuPlan(Intent intent, String cafeteriaQuery, String dateExpression) { + public enum Intent { MENU, OTHER } + public MenuPlan { intent = intent == null ? Intent.OTHER : intent; } + public boolean isMenu() { return intent == Intent.MENU && cafeteriaQuery != null && !cafeteriaQuery.isBlank(); } + public static MenuPlan other() { return new MenuPlan(Intent.OTHER, null, null); } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/dto/RoomCoursePlan.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/RoomCoursePlan.java new file mode 100644 index 00000000..687762e4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/RoomCoursePlan.java @@ -0,0 +1,8 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +public record RoomCoursePlan(Intent intent, String roomQuery, String weekdayExpression) { + public enum Intent { ROOM_COURSE, OTHER } + public RoomCoursePlan { intent = intent == null ? Intent.OTHER : intent; } + public boolean isRoomCourse() { return intent == Intent.ROOM_COURSE && roomQuery != null && !roomQuery.isBlank(); } + public static RoomCoursePlan other() { return new RoomCoursePlan(Intent.OTHER, null, null); } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java index b46459d2..efdf212d 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java @@ -8,6 +8,9 @@ import devkor.com.teamcback.domain.chatbot.dto.LocationCandidateView; import devkor.com.teamcback.domain.chatbot.dto.LocationDetailPlan; import devkor.com.teamcback.domain.chatbot.dto.PlaceReviewsPlan; +import devkor.com.teamcback.domain.chatbot.dto.MenuPlan; +import devkor.com.teamcback.domain.chatbot.dto.FacilityPlan; +import devkor.com.teamcback.domain.chatbot.dto.RoomCoursePlan; import devkor.com.teamcback.domain.chatbot.service.ResolvedLocationCollector; import java.util.List; @@ -35,6 +38,9 @@ default PlaceReviewsPlan planPlaceReviews(String systemPrompt, List history, String userMessage) { return MenuPlan.other(); } + default FacilityPlan planFacility(String systemPrompt, List history, String userMessage) { return FacilityPlan.other(); } + default RoomCoursePlan planRoomCourse(String systemPrompt, List history, String userMessage) { return RoomCoursePlan.other(); } default LocationCandidateSelection selectLocationCandidate(String systemPrompt, List history, String userMessage, diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java index 71314f96..953f1bf4 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java @@ -11,6 +11,9 @@ import devkor.com.teamcback.domain.chatbot.dto.LocationCandidateView; import devkor.com.teamcback.domain.chatbot.dto.LocationDetailPlan; import devkor.com.teamcback.domain.chatbot.dto.PlaceReviewsPlan; +import devkor.com.teamcback.domain.chatbot.dto.MenuPlan; +import devkor.com.teamcback.domain.chatbot.dto.FacilityPlan; +import devkor.com.teamcback.domain.chatbot.dto.RoomCoursePlan; import devkor.com.teamcback.domain.chatbot.service.ResolvedLocationCollector; import devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter; import devkor.com.teamcback.domain.chatbot.service.ToolCallLimitExceededException; @@ -216,6 +219,36 @@ public PlaceReviewsPlan planPlaceReviews(String systemPrompt, List history, String userMessage) { + return structured(() -> plan(MenuPlan.class, "MENU", "cafeteriaQuery", "dateExpression", systemPrompt, history, userMessage), MenuPlan.other()); + } + + @Override + public FacilityPlan planFacility(String systemPrompt, List history, String userMessage) { + return structured(() -> plan(FacilityPlan.class, "FACILITY", "locationQuery", "facilityType", systemPrompt, history, userMessage), FacilityPlan.other()); + } + + @Override + public RoomCoursePlan planRoomCourse(String systemPrompt, List history, String userMessage) { + return structured(() -> plan(RoomCoursePlan.class, "ROOM_COURSE", "roomQuery", "weekdayExpression", systemPrompt, history, userMessage), RoomCoursePlan.other()); + } + + private T plan(Class type, String intent, String firstSlot, String secondSlot, + String systemPrompt, List history, String userMessage) { + try { + BeanOutputConverter converter = new BeanOutputConverter<>(type); + String prompt = "Extract only intent and natural-language slots for the " + intent + + " workflow. Do not call tools, invent IDs, or choose database entities. " + + "Use OTHER when unrelated. Preserve the user's wording. " + firstSlot + " and " + secondSlot + + " are optional.\n" + (systemPrompt == null ? "" : systemPrompt) + "\n" + converter.getFormat(); + var response = chatClient.prompt().system(prompt).messages(toSpringMessages(history, userMessage)).call(); + return response == null ? null : response.entity(converter); + } catch (RuntimeException exception) { + return null; + } + } + @Override public LocationCandidateSelection selectLocationCandidate(String systemPrompt, List history, diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java index d160d161..c723f589 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java @@ -34,7 +34,10 @@ private boolean matchesPending(PendingWorkflow pendingWorkflow, Intent intent) { return (pendingWorkflow == PendingWorkflow.CROWD && intent == Intent.CROWD) || (pendingWorkflow == PendingWorkflow.ROUTE && intent == Intent.ROUTE) || (pendingWorkflow == PendingWorkflow.LOCATION_DETAIL && intent == Intent.LOCATION_DETAIL) - || (pendingWorkflow == PendingWorkflow.REVIEW && intent == Intent.REVIEW); + || (pendingWorkflow == PendingWorkflow.REVIEW && intent == Intent.REVIEW) + || (pendingWorkflow == PendingWorkflow.MENU && intent == Intent.MENU) + || (pendingWorkflow == PendingWorkflow.FACILITY && intent == Intent.FACILITY) + || (pendingWorkflow == PendingWorkflow.ROOM_COURSE && intent == Intent.ROOM_COURSE); } private Intent detectObviousIntent(String message) { @@ -88,7 +91,7 @@ private String normalize(String message) { } public enum PendingWorkflow { - NONE, + NONE, MENU, FACILITY, ROOM_COURSE, ROUTE, CROWD, LOCATION_DETAIL, @@ -100,6 +103,9 @@ private WorkflowType toWorkflowType() { case LOCATION_DETAIL -> WorkflowType.LOCATION_DETAIL; case REVIEW -> WorkflowType.REVIEW; case ROUTE -> WorkflowType.ROUTE; + case MENU -> WorkflowType.MENU; + case FACILITY -> WorkflowType.FACILITY; + case ROOM_COURSE -> WorkflowType.ROOM_COURSE; case NONE -> WorkflowType.GENERAL_CHAT; }; } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java index 303d65e1..ef055991 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java @@ -71,17 +71,20 @@ NAVIGATE_ROUTE requires searchCampus(query, role=START, intent=NAVIGATE_ROUTE) a private final LocationDetailWorkflow locationDetailWorkflow; private final PlaceReviewsWorkflow placeReviewsWorkflow; private final CampusStatusWorkflow campusStatusWorkflow; + private final MenuWorkflow menuWorkflow; + private final FacilityWorkflow facilityWorkflow; + private final RoomCourseWorkflow roomCourseWorkflow; /** Compatibility constructor for focused unit tests that do not exercise pending state. */ public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService memoryService, ChatRateLimiter rateLimiter) { - this(chatOrchestrator, memoryService, rateLimiter, null, null, new ChatRequestRouter(), null, null, null); + this(chatOrchestrator, memoryService, rateLimiter, null, null, new ChatRequestRouter(), null, null, null, null, null, null); } /** Compatibility constructor for tests that provide pending-route state only. */ public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService memoryService, ChatRateLimiter rateLimiter, PendingRouteStateService pendingRouteStateService) { - this(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService, null, new ChatRequestRouter(), null, null, null); + this(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService, null, new ChatRequestRouter(), null, null, null, null, null, null); } /** Compatibility constructor for focused tests that provide the existing Crowd workflow. */ @@ -89,7 +92,7 @@ public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService m ChatRateLimiter rateLimiter, PendingRouteStateService pendingRouteStateService, CrowdStatusWorkflow crowdStatusWorkflow) { this(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService, - crowdStatusWorkflow, new ChatRequestRouter(), null, null, null); + crowdStatusWorkflow, new ChatRequestRouter(), null, null, null, null, null, null); } @Autowired @@ -100,7 +103,9 @@ public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService m ChatRequestRouter requestRouter, LocationDetailWorkflow locationDetailWorkflow, PlaceReviewsWorkflow placeReviewsWorkflow, - CampusStatusWorkflow campusStatusWorkflow) { + CampusStatusWorkflow campusStatusWorkflow, + MenuWorkflow menuWorkflow, FacilityWorkflow facilityWorkflow, + RoomCourseWorkflow roomCourseWorkflow) { this.chatOrchestrator = chatOrchestrator; this.memoryService = memoryService; this.rateLimiter = rateLimiter; @@ -110,6 +115,21 @@ public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService m this.locationDetailWorkflow = locationDetailWorkflow; this.placeReviewsWorkflow = placeReviewsWorkflow; this.campusStatusWorkflow = campusStatusWorkflow; + this.menuWorkflow = menuWorkflow; + this.facilityWorkflow = facilityWorkflow; + this.roomCourseWorkflow = roomCourseWorkflow; + } + + /** Compatibility constructor retained for existing workflow-focused tests. */ + public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService memoryService, + ChatRateLimiter rateLimiter, PendingRouteStateService pendingRouteStateService, + CrowdStatusWorkflow crowdStatusWorkflow, ChatRequestRouter requestRouter, + LocationDetailWorkflow locationDetailWorkflow, + PlaceReviewsWorkflow placeReviewsWorkflow, + CampusStatusWorkflow campusStatusWorkflow) { + this(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService, crowdStatusWorkflow, + requestRouter, locationDetailWorkflow, placeReviewsWorkflow, campusStatusWorkflow, + null, null, null); } /** Compatibility constructor for callers that only wire the PR1 routing boundary. */ @@ -117,7 +137,7 @@ public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService m ChatRateLimiter rateLimiter, PendingRouteStateService pendingRouteStateService, CrowdStatusWorkflow crowdStatusWorkflow, ChatRequestRouter requestRouter) { this(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService, crowdStatusWorkflow, - requestRouter, null, null, null); + requestRouter, null, null, null, null, null, null); } public ChatMessageRes sendMessage(ChatMessageReq request, ChatCaller caller) { @@ -162,6 +182,18 @@ public ChatMessageRes sendMessage(ChatMessageReq request, ChatCaller caller) { CampusStatusWorkflow.WorkflowResult status = campusStatusWorkflow.handle(sessionId, caller, request.message()); return saveWorkflowReply(sessionId, caller, request.message(), status.reply()); } + if (menuWorkflow != null && routing.workflowType() == ChatRequestRouter.WorkflowType.MENU) { + MenuWorkflow.WorkflowResult menu = menuWorkflow.handle(sessionId, caller, toGatewayHistory(history), request.message()); + if (menu.handled()) return saveWorkflowReply(sessionId, caller, request.message(), menu.reply()); + } + if (facilityWorkflow != null && routing.workflowType() == ChatRequestRouter.WorkflowType.FACILITY) { + FacilityWorkflow.WorkflowResult facility = facilityWorkflow.handle(sessionId, caller, toGatewayHistory(history), request.message()); + if (facility.handled()) return saveWorkflowReply(sessionId, caller, request.message(), facility.reply()); + } + if (roomCourseWorkflow != null && routing.workflowType() == ChatRequestRouter.WorkflowType.ROOM_COURSE) { + RoomCourseWorkflow.WorkflowResult room = roomCourseWorkflow.handle(sessionId, caller, toGatewayHistory(history), request.message()); + if (room.handled()) return saveWorkflowReply(sessionId, caller, request.message(), room.reply()); + } if (routing.route() == ChatRequestRouter.Route.CONTINUE_PENDING && routing.workflowType() == ChatRequestRouter.WorkflowType.ROUTE) { pending = loadPending(sessionId, caller); @@ -201,6 +233,9 @@ private ChatRequestRouter.PendingWorkflow activePendingWorkflow(UUID sessionId, if (placeReviewsWorkflow != null && placeReviewsWorkflow.hasPending(sessionId, caller)) { return ChatRequestRouter.PendingWorkflow.REVIEW; } + if (menuWorkflow != null && menuWorkflow.hasPending(sessionId, caller)) return ChatRequestRouter.PendingWorkflow.MENU; + if (facilityWorkflow != null && facilityWorkflow.hasPending(sessionId, caller)) return ChatRequestRouter.PendingWorkflow.FACILITY; + if (roomCourseWorkflow != null && roomCourseWorkflow.hasPending(sessionId, caller)) return ChatRequestRouter.PendingWorkflow.ROOM_COURSE; return pendingRoute == null ? ChatRequestRouter.PendingWorkflow.NONE : ChatRequestRouter.PendingWorkflow.ROUTE; } @@ -216,6 +251,9 @@ private void releaseSupersededWorkflows(UUID sessionId, ChatCaller caller, if (placeReviewsWorkflow != null && placeReviewsWorkflow.hasPending(sessionId, caller)) { placeReviewsWorkflow.cancel(sessionId, caller); } + if (menuWorkflow != null && menuWorkflow.hasPending(sessionId, caller)) menuWorkflow.cancel(sessionId, caller); + if (facilityWorkflow != null && facilityWorkflow.hasPending(sessionId, caller)) facilityWorkflow.cancel(sessionId, caller); + if (roomCourseWorkflow != null && roomCourseWorkflow.hasPending(sessionId, caller)) roomCourseWorkflow.cancel(sessionId, caller); if (pendingRoute != null) { deletePending(sessionId, caller); } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/FacilityPlanner.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/FacilityPlanner.java new file mode 100644 index 00000000..d869ba63 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/FacilityPlanner.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.chatbot.service; +import devkor.com.teamcback.domain.chatbot.dto.FacilityPlan; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +@Component +@ConditionalOnProperty(prefix="chatbot", name="enabled", havingValue="true") +public class FacilityPlanner { + private final LlmGateway gateway; + public FacilityPlanner(LlmGateway gateway) { this.gateway = gateway; } + public FacilityPlan plan(List history, String message) { + return gateway.planFacility("FACILITY: extract natural-language locationQuery, facilityType, and optional numeric floor", history, message); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/FacilityWorkflow.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/FacilityWorkflow.java new file mode 100644 index 00000000..58bbbebf --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/FacilityWorkflow.java @@ -0,0 +1,23 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.bsc.langgraph4j.StateGraph.END; import static org.bsc.langgraph4j.StateGraph.START; +import devkor.com.teamcback.domain.chatbot.dto.*; import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; import devkor.com.teamcback.domain.chatbot.tool.CampusToolAdapter; import devkor.com.teamcback.domain.chatbot.tool.dto.*; import devkor.com.teamcback.domain.place.entity.PlaceType; import devkor.com.teamcback.global.exception.exception.GlobalException; import java.util.*; import org.bsc.langgraph4j.*; import org.bsc.langgraph4j.action.*; import org.bsc.langgraph4j.checkpoint.MemorySaver; import org.bsc.langgraph4j.state.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; + +@Component @ConditionalOnProperty(prefix="chatbot",name="enabled",havingValue="true") +public class FacilityWorkflow { + private static final String SEARCH="search",DECIDE="decide",ASK="ask",SELECT="select",LOAD="load",FORMAT="format",OWNER="owner",QUERY="query",TYPE="type",FLOOR="floor",INPUT="input",CANDIDATES="candidates",ID="id",NAME="name",REPLY="reply",WAITING="waiting",DATA="data"; + private final FacilityPlanner planner; private final CampusToolAdapter adapter; private final LocationCandidateSelector selector; private final MemorySaver saver=new MemorySaver(); private final CompiledGraph graph; + @Autowired public FacilityWorkflow(FacilityPlanner p,CampusToolAdapter a,LocationCandidateSelector s){planner=p;adapter=a;selector=s;try{StateGraph g=new StateGraph<>(State.SCHEMA,State::new);g.addNode(SEARCH,AsyncNodeAction.node_async((NodeAction)this::search));g.addNode(DECIDE,AsyncNodeAction.node_async((NodeAction)this::decide));g.addNode(ASK,AsyncNodeAction.node_async((NodeAction)this::ask));g.addNode(SELECT,AsyncNodeAction.node_async((NodeAction)this::select));g.addNode(LOAD,AsyncNodeAction.node_async((NodeAction)this::load));g.addNode(FORMAT,AsyncNodeAction.node_async((NodeAction)this::format));g.addEdge(START,SEARCH);g.addEdge(SEARCH,DECIDE);g.addConditionalEdges(DECIDE,AsyncEdgeAction.edge_async((EdgeAction)this::afterDecision),Map.of(ASK,ASK,LOAD,LOAD,END,END));g.addEdge(ASK,SELECT);g.addConditionalEdges(SELECT,AsyncEdgeAction.edge_async((EdgeAction)this::afterSelect),Map.of(ASK,ASK,LOAD,LOAD));g.addEdge(LOAD,FORMAT);g.addEdge(FORMAT,END);graph=g.compile(CompileConfig.builder().checkpointSaver(saver).interruptAfter(ASK).releaseThread(true).build());}catch(GraphStateException e){throw new IllegalStateException("Unable to compile FACILITY workflow",e);}} + public WorkflowResult handle(UUID id,ChatCaller caller,List history,String message){RunnableConfig c=config(id);var cp=graph.lastStateOf(c);State st;if(cp.isPresent()){owner(cp.get().state(),caller);st=run(GraphInput.resume(Map.of(INPUT,message)),c);}else{FacilityPlan p=planner.plan(history,message);if(p==null||!p.isFacility())return WorkflowResult.notHandled();Map in=new HashMap<>();in.put(OWNER,caller.key());in.put(QUERY,p.locationQuery()==null?"":p.locationQuery());in.put(TYPE,p.facilityType()==null?"":p.facilityType());in.put(FLOOR,p.floor()==null?"":String.valueOf(p.floor()));in.put(INPUT,message);st=run(GraphInput.args(in),c);}WorkflowResult r=new WorkflowResult(true,st.value(REPLY,""),!st.value(WAITING,"").isBlank());if(!r.waiting())release(c);return r;} + public boolean hasPending(UUID id,ChatCaller c){var x=graph.lastStateOf(config(id));if(x.isEmpty())return false;owner(x.get().state(),c);return true;} public void cancel(UUID id,ChatCaller c){var x=graph.lastStateOf(config(id));if(x.isPresent()){owner(x.get().state(),c);release(config(id));}} + private Map search(State s){String q=s.value(QUERY,"");List> out=new ArrayList<>();if(!q.isBlank()){SearchCampusToolResult r=adapter.searchCampus(new SearchCampusToolRequest(q,5));if(r!=null&&r.candidates()!=null)for(SearchCampusItem i:r.candidates())if(i.locationType()==ToolLocationType.BUILDING)out.add(Map.of(ID,String.valueOf(i.locationId()),NAME,i.name()));}return Map.of(CANDIDATES,out,REPLY,out.isEmpty()&&q.isBlank()?"":"");} + private Map decide(State s){List> c=s.value(CANDIDATES,List.of());if(s.value(QUERY,"").isBlank())return Map.of(ID,"campus",WAITING,"");if(c.size()==1)return selected(c.get(0));if(c.size()>1)return Map.of(WAITING,"selection");return Map.of(REPLY,"요청한 건물을 찾지 못했어요.",WAITING,"");} + private Map ask(State s){return Map.of(REPLY,"어느 건물의 시설인지 선택해 주세요: "+names(s));} + private Map select(State s){List> cs=s.value(CANDIDATES,List.of());List vs=new ArrayList<>();for(int i=0;i c=cs.get(i);vs.add(new LocationCandidateView(i,String.valueOf(c.get(NAME)),ToolLocationType.BUILDING,"","",""));}LocationCandidateSelection x=selector.select(s.value(INPUT,""),vs);Integer i=x==null?null:x.candidateIndex();if(x!=null&&x.status()==CandidateSelection.Status.SELECTED&&i!=null&&i>=0&&i load(State s){try{CampusFacilityType t=facilityType(s.value(TYPE,""));if(t==null)return Map.of(REPLY,"지원하지 않는 시설 종류예요.",WAITING,"");Long b="campus".equals(s.value(ID,""))?null:Long.valueOf(s.value(ID,""));Integer f=s.value(FLOOR,"").isBlank()?null:Integer.valueOf(s.value(FLOOR,""));FindFacilitiesToolResult r=adapter.findFacilities(new FindFacilitiesToolRequest(t,b,f,10));return Map.of(DATA,facilityText(r),WAITING,"");}catch(IllegalArgumentException e){return Map.of(REPLY,"시설 종류나 층 정보가 올바르지 않아요.",WAITING,"");}} + private Map format(State s){return Map.of(REPLY,s.value(DATA,""));} + private String facilityText(FindFacilitiesToolResult r){if(r==null||r.error()!=null)return r==null?"시설 정보를 확인하지 못했어요.":r.error().message();return r.facilities().stream().map(x->x.name()+" ("+x.placeType()+", "+(x.floor()==null?"":x.floor()+"층")+")").reduce((a,b)->a+"\n"+b).orElse("검색된 시설이 없어요.");} + private CampusFacilityType facilityType(String raw){if(raw==null||raw.isBlank())return null;String v=raw.trim().toUpperCase(Locale.ROOT);Map alias=Map.of("화장실","TOILET","프린터","PRINTER","정수기","WATER_PURIFIER","카페","CAFE","식당","CAFETERIA","강의실","CLASSROOM");v=alias.getOrDefault(raw.trim(),v);return CampusFacilityType.valueOf(v);} + private String afterDecision(State s){if(!s.value(REPLY,"").isBlank()&&!s.value(CANDIDATES,List.>of()).isEmpty())return END;return s.value(ID,"").equals("campus")||!s.value(ID,"").isBlank()?LOAD:(s.value(CANDIDATES,List.>of()).isEmpty()?END:ASK);}private String afterSelect(State s){return s.value(ID,"").isBlank()?ASK:LOAD;}private Map selected(Map c){return Map.of(ID,c.get(ID),NAME,c.get(NAME),WAITING,"");}private String names(State s){return s.value(CANDIDATES,List.>of()).stream().map(c->String.valueOf(c.get(NAME))).distinct().reduce((a,b)->a+", "+b).orElse("");}private State run(GraphInput i,RunnableConfig c){try{return graph.invoke(i,c).orElseGet(()->graph.lastStateOf(c).orElseThrow().state());}catch(Exception e){throw new IllegalStateException("FACILITY workflow failed",e);}}private RunnableConfig config(UUID id){return RunnableConfig.builder().threadId("facility:"+id).build();}private void release(RunnableConfig c){try{saver.release(c);}catch(Exception e){throw new IllegalStateException(e);}}private void owner(State s,ChatCaller c){if(!c.key().equals(s.value(OWNER,"")))throw new GlobalException(devkor.com.teamcback.global.response.ResultCode.CHATBOT_SESSION_FORBIDDEN);} + public record WorkflowResult(boolean handled,String reply,boolean waiting){static WorkflowResult notHandled(){return new WorkflowResult(false,null,false);}} public static final class State extends AgentState{static Channel text(){return Channels.base(()->"");}static Channel>> list(){return Channels.base(ArrayList::new);}static Channel obj(){return Channels.base(()->"");}static final Map> SCHEMA=Map.ofEntries(Map.entry(OWNER,text()),Map.entry(QUERY,text()),Map.entry(TYPE,text()),Map.entry(FLOOR,text()),Map.entry(INPUT,text()),Map.entry(ID,text()),Map.entry(NAME,text()),Map.entry(REPLY,text()),Map.entry(WAITING,text()),Map.entry(CANDIDATES,list()),Map.entry(DATA,obj()));public State(Mapd){super(d);}} +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/MenuPlanner.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/MenuPlanner.java new file mode 100644 index 00000000..f9c94303 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/MenuPlanner.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.chatbot.service; +import devkor.com.teamcback.domain.chatbot.dto.MenuPlan; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +@Component +@ConditionalOnProperty(prefix="chatbot", name="enabled", havingValue="true") +public class MenuPlanner { + private final LlmGateway gateway; + public MenuPlanner(LlmGateway gateway) { this.gateway = gateway; } + public MenuPlan plan(List history, String message) { + return gateway.planMenu("MENU: cafeteria meal lookup only; extract cafeteriaQuery and dateExpression", history, message); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/MenuWorkflow.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/MenuWorkflow.java new file mode 100644 index 00000000..2d655284 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/MenuWorkflow.java @@ -0,0 +1,59 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.bsc.langgraph4j.StateGraph.END; +import static org.bsc.langgraph4j.StateGraph.START; +import devkor.com.teamcback.domain.chatbot.dto.*; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import devkor.com.teamcback.domain.chatbot.tool.CampusToolAdapter; +import devkor.com.teamcback.domain.chatbot.tool.dto.*; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.time.LocalDate; +import java.util.*; +import org.bsc.langgraph4j.*; +import org.bsc.langgraph4j.action.*; +import org.bsc.langgraph4j.checkpoint.MemorySaver; +import org.bsc.langgraph4j.state.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(prefix="chatbot", name="enabled", havingValue="true") +public class MenuWorkflow { + private static final String SEARCH="search", DECIDE="decide", ASK="ask", SELECT="select", LOAD="load", FORMAT="format"; + private static final String OWNER="owner", QUERY="query", DATE="date", INPUT="input", CANDIDATES="candidates", ID="id", NAME="name", REPLY="reply", WAITING="waiting", DATA="data"; + private final MenuPlanner planner; private final CampusToolAdapter adapter; private final LocationCandidateSelector selector; + private final MemorySaver saver=new MemorySaver(); private final CompiledGraph graph; + @Autowired public MenuWorkflow(MenuPlanner planner, CampusToolAdapter adapter, LocationCandidateSelector selector) { + this.planner=planner; this.adapter=adapter; this.selector=selector; + try { StateGraph s=new StateGraph<>(State.SCHEMA, State::new); + s.addNode(SEARCH, AsyncNodeAction.node_async((NodeAction)this::search)); s.addNode(DECIDE,AsyncNodeAction.node_async((NodeAction)this::decide)); + s.addNode(ASK,AsyncNodeAction.node_async((NodeAction)this::ask)); s.addNode(SELECT,AsyncNodeAction.node_async((NodeAction)this::select)); + s.addNode(LOAD,AsyncNodeAction.node_async((NodeAction)this::load)); s.addNode(FORMAT,AsyncNodeAction.node_async((NodeAction)this::format)); + s.addEdge(START,SEARCH); s.addEdge(SEARCH,DECIDE); s.addConditionalEdges(DECIDE,AsyncEdgeAction.edge_async((EdgeAction)this::afterDecision),Map.of(ASK,ASK,LOAD,LOAD,END,END)); + s.addEdge(ASK,SELECT); s.addConditionalEdges(SELECT,AsyncEdgeAction.edge_async((EdgeAction)this::afterSelect),Map.of(ASK,ASK,LOAD,LOAD)); s.addEdge(LOAD,FORMAT); s.addEdge(FORMAT,END); + graph=s.compile(CompileConfig.builder().checkpointSaver(saver).interruptAfter(ASK).releaseThread(true).build()); + } catch(GraphStateException e){throw new IllegalStateException("Unable to compile MENU workflow",e);} + } + public WorkflowResult handle(UUID sessionId, ChatCaller caller,List history,String message){ + RunnableConfig c=config(sessionId); var checkpoint=graph.lastStateOf(c); State state; + if(checkpoint.isPresent()){assertOwner(checkpoint.get().state(),caller); state=run(GraphInput.resume(Map.of(INPUT,message)),c);} + else {MenuPlan p=planner.plan(history,message); if(p==null||!p.isMenu())return WorkflowResult.notHandled(); state=run(GraphInput.args(Map.of(OWNER,caller.key(),QUERY,p.cafeteriaQuery(),DATE,p.dateExpression()==null?"":p.dateExpression(),INPUT,message)),c);} + WorkflowResult result=new WorkflowResult(true,state.value(REPLY,""),!state.value(WAITING,"").isBlank()); if(!result.waiting())release(c); return result; + } + public boolean hasPending(UUID id,ChatCaller caller){var x=graph.lastStateOf(config(id));if(x.isEmpty())return false;assertOwner(x.get().state(),caller);return true;} + public void cancel(UUID id,ChatCaller caller){var x=graph.lastStateOf(config(id));if(x.isPresent()){assertOwner(x.get().state(),caller);release(config(id));}} + private Map search(State st){SearchCampusToolResult r=adapter.searchCampus(new SearchCampusToolRequest(st.value(QUERY,""),5));List> out=new ArrayList<>();if(r!=null&&r.candidates()!=null)for(SearchCampusItem i:r.candidates())if(i.locationType()==ToolLocationType.PLACE&&i.placeType()==devkor.com.teamcback.domain.place.entity.PlaceType.CAFETERIA)out.add(Map.of(ID,String.valueOf(i.locationId()),NAME,i.name(),"buildingName",Objects.toString(i.buildingName(),""),"floor",Objects.toString(i.floor(),""))); + return Map.of(CANDIDATES,out,REPLY,out.isEmpty()?"요청한 식당을 찾지 못했어요.":"");} + private Map decide(State st){List> c=st.value(CANDIDATES,List.of());if(c.size()==1)return selected(c.get(0));if(c.size()>1)return Map.of(WAITING,"selection");return Map.of(WAITING,"");} + private Map ask(State st){String names=names(st);return Map.of(REPLY,"어느 식당의 메뉴인지 선택해 주세요: "+names);} + private Map select(State st){List> cs=st.value(CANDIDATES,List.of());List views=new ArrayList<>();for(int i=0;i c=cs.get(i);views.add(new LocationCandidateView(i,String.valueOf(c.get(NAME)),ToolLocationType.PLACE,String.valueOf(c.get("buildingName")),String.valueOf(c.get("floor")),"CAFETERIA"));}LocationCandidateSelection x=selector.select(st.value(INPUT,""),views);Integer i=x==null?null:x.candidateIndex();if(x!=null&&x.status()==CandidateSelection.Status.SELECTED&&i!=null&&i>=0&&i load(State st){try{LocalDate d=WorkflowDates.menuDate(st.value(DATE,""));GetCafeteriaMenuToolResult r=adapter.getCafeteriaMenu(new GetCafeteriaMenuToolRequest(Long.valueOf(st.value(ID,"0")),d,d));return Map.of(DATA,menuText(r),WAITING,"");}catch(IllegalArgumentException e){return Map.of(REPLY,"조회할 수 없는 날짜예요.",WAITING,"");}} + private Map format(State st){return Map.of(REPLY,st.value(DATA,""));} + private String menuText(GetCafeteriaMenuToolResult r){if(r==null||r.error()!=null)return r==null?"메뉴 정보를 확인하지 못했어요.":r.error().message();StringBuilder b=new StringBuilder(r.cafeteria().placeName());for(CafeteriaMenuDay day:r.cafeteria().days()){b.append("\n").append(day.date()).append(": ");b.append(day.meals().stream().map(m->m.mealType()+" "+m.menu()).reduce((a,x)->a+" / "+x).orElse("등록된 메뉴가 없어요"));}return b.toString();} + private String afterDecision(State s){return s.value(CANDIDATES,List.>of()).isEmpty()?END:(!s.value(ID,"").isBlank()?LOAD:ASK);} private String afterSelect(State s){return s.value(ID,"").isBlank()?ASK:LOAD;} + private Map selected(Map c){return Map.of(ID,c.get(ID),NAME,c.get(NAME),WAITING,"");} private String names(State s){return s.value(CANDIDATES,List.>of()).stream().map(c->String.valueOf(c.get(NAME))).distinct().reduce((a,b)->a+", "+b).orElse("");} + private State run(GraphInput i,RunnableConfig c){try{return graph.invoke(i,c).orElseGet(()->graph.lastStateOf(c).orElseThrow().state());}catch(Exception e){throw new IllegalStateException("MENU workflow failed",e);}} private RunnableConfig config(UUID id){return RunnableConfig.builder().threadId("menu:"+id).build();} private void release(RunnableConfig c){try{saver.release(c);}catch(Exception e){throw new IllegalStateException(e);}} private void assertOwner(State s,ChatCaller c){if(!c.key().equals(s.value(OWNER,"")))throw new GlobalException(devkor.com.teamcback.global.response.ResultCode.CHATBOT_SESSION_FORBIDDEN);} + public record WorkflowResult(boolean handled,String reply,boolean waiting){static WorkflowResult notHandled(){return new WorkflowResult(false,null,false);}} + public static final class State extends AgentState {static Channel text(){return Channels.base(()->"");}static Channel>> list(){return Channels.base(ArrayList::new);}static Channel obj(){return Channels.base(()->"");}static final Map> SCHEMA=Map.of(OWNER,text(),QUERY,text(),DATE,text(),INPUT,text(),ID,text(),NAME,text(),REPLY,text(),WAITING,text(),CANDIDATES,list(),DATA,obj());public State(Mapd){super(d);}} +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/RoomCoursePlanner.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/RoomCoursePlanner.java new file mode 100644 index 00000000..4a3f74ba --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/RoomCoursePlanner.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.chatbot.service; +import devkor.com.teamcback.domain.chatbot.dto.RoomCoursePlan; +import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; +import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +@Component +@ConditionalOnProperty(prefix="chatbot", name="enabled", havingValue="true") +public class RoomCoursePlanner { + private final LlmGateway gateway; + public RoomCoursePlanner(LlmGateway gateway) { this.gateway = gateway; } + public RoomCoursePlan plan(List history, String message) { + return gateway.planRoomCourse("ROOM_COURSE: classroom schedule only; extract roomQuery and weekdayExpression", history, message); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/RoomCourseWorkflow.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/RoomCourseWorkflow.java new file mode 100644 index 00000000..4281715b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/RoomCourseWorkflow.java @@ -0,0 +1,18 @@ +package devkor.com.teamcback.domain.chatbot.service; +import static org.bsc.langgraph4j.StateGraph.END; import static org.bsc.langgraph4j.StateGraph.START; +import devkor.com.teamcback.domain.chatbot.dto.*; import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; import devkor.com.teamcback.domain.chatbot.tool.CampusToolAdapter; import devkor.com.teamcback.domain.chatbot.tool.dto.*; import devkor.com.teamcback.domain.place.entity.PlaceType; import devkor.com.teamcback.global.exception.exception.GlobalException; import java.util.*; import org.bsc.langgraph4j.*; import org.bsc.langgraph4j.action.*; import org.bsc.langgraph4j.checkpoint.MemorySaver; import org.bsc.langgraph4j.state.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; +@Component @ConditionalOnProperty(prefix="chatbot",name="enabled",havingValue="true") public class RoomCourseWorkflow { + private static final String SEARCH="search",DECIDE="decide",ASK="ask",SELECT="select",LOAD="load",FORMAT="format",OWNER="owner",QUERY="query",DAY="day",INPUT="input",CANDIDATES="candidates",ID="id",NAME="name",REPLY="reply",WAITING="waiting",DATA="data"; + private final RoomCoursePlanner planner;private final CampusToolAdapter adapter;private final LocationCandidateSelector selector;private final MemorySaver saver=new MemorySaver();private final CompiledGraph graph; + @Autowired public RoomCourseWorkflow(RoomCoursePlanner p,CampusToolAdapter a,LocationCandidateSelector s){planner=p;adapter=a;selector=s;try{StateGraph g=new StateGraph<>(State.SCHEMA,State::new);g.addNode(SEARCH,AsyncNodeAction.node_async((NodeAction)this::search));g.addNode(DECIDE,AsyncNodeAction.node_async((NodeAction)this::decide));g.addNode(ASK,AsyncNodeAction.node_async((NodeAction)this::ask));g.addNode(SELECT,AsyncNodeAction.node_async((NodeAction)this::select));g.addNode(LOAD,AsyncNodeAction.node_async((NodeAction)this::load));g.addNode(FORMAT,AsyncNodeAction.node_async((NodeAction)this::format));g.addEdge(START,SEARCH);g.addEdge(SEARCH,DECIDE);g.addConditionalEdges(DECIDE,AsyncEdgeAction.edge_async((EdgeAction)this::afterDecision),Map.of(ASK,ASK,LOAD,LOAD,END,END));g.addEdge(ASK,SELECT);g.addConditionalEdges(SELECT,AsyncEdgeAction.edge_async((EdgeAction)this::afterSelect),Map.of(ASK,ASK,LOAD,LOAD));g.addEdge(LOAD,FORMAT);g.addEdge(FORMAT,END);graph=g.compile(CompileConfig.builder().checkpointSaver(saver).interruptAfter(ASK).releaseThread(true).build());}catch(GraphStateException e){throw new IllegalStateException("Unable to compile ROOM_COURSE workflow",e);}} + public WorkflowResult handle(UUID id,ChatCaller c,List h,String m){RunnableConfig rc=config(id);var cp=graph.lastStateOf(rc);State st;if(cp.isPresent()){owner(cp.get().state(),c);st=run(GraphInput.resume(Map.of(INPUT,m)),rc);}else{RoomCoursePlan p=planner.plan(h,m);if(p==null||!p.isRoomCourse())return WorkflowResult.notHandled();st=run(GraphInput.args(Map.of(OWNER,c.key(),QUERY,p.roomQuery(),DAY,p.weekdayExpression()==null?"":p.weekdayExpression(),INPUT,m)),rc);}WorkflowResult r=new WorkflowResult(true,st.value(REPLY,""),!st.value(WAITING,"").isBlank());if(!r.waiting())release(rc);return r;} + public boolean hasPending(UUID id,ChatCaller c){var x=graph.lastStateOf(config(id));if(x.isEmpty())return false;owner(x.get().state(),c);return true;}public void cancel(UUID id,ChatCaller c){var x=graph.lastStateOf(config(id));if(x.isPresent()){owner(x.get().state(),c);release(config(id));}} + private Map search(State s){SearchCampusToolResult r=adapter.searchCampus(new SearchCampusToolRequest(s.value(QUERY,""),5));List> out=new ArrayList<>();if(r!=null&&r.candidates()!=null)for(SearchCampusItem i:r.candidates())if(i.locationType()==ToolLocationType.PLACE&&i.placeType()==PlaceType.CLASSROOM)out.add(Map.of(ID,String.valueOf(i.locationId()),NAME,i.name(),"buildingName",Objects.toString(i.buildingName(),""),"floor",Objects.toString(i.floor(),"")));return Map.of(CANDIDATES,out,REPLY,out.isEmpty()?"해당 강의실을 찾지 못했어요.":"");} + private Map decide(State s){List> c=s.value(CANDIDATES,List.of());if(c.size()==1)return selected(c.get(0));if(c.size()>1)return Map.of(WAITING,"selection");return Map.of(WAITING,"");}private Map ask(State s){return Map.of(REPLY,"어느 강의실인지 선택해 주세요: "+names(s));} + private Map select(State s){List> cs=s.value(CANDIDATES,List.of());List vs=new ArrayList<>();for(int i=0;i c=cs.get(i);vs.add(new LocationCandidateView(i,String.valueOf(c.get(NAME)),ToolLocationType.PLACE,String.valueOf(c.get("buildingName")),String.valueOf(c.get("floor")),"CLASSROOM"));}LocationCandidateSelection x=selector.select(s.value(INPUT,""),vs);Integer i=x==null?null:x.candidateIndex();if(x!=null&&x.status()==CandidateSelection.Status.SELECTED&&i!=null&&i>=0&&i load(State s){try{devkor.com.teamcback.domain.common.entity.Weekday d=WorkflowDates.weekday(s.value(DAY,""));GetRoomCoursesToolResult r=adapter.getRoomCourses(new GetRoomCoursesToolRequest(Long.valueOf(s.value(ID,"0")),d));return Map.of(DATA,roomText(r),WAITING,"");}catch(IllegalArgumentException e){return Map.of(REPLY,"요일 정보가 올바르지 않아요.",WAITING,"");}} + private Map format(State s){return Map.of(REPLY,s.value(DATA,""));} + private String roomText(GetRoomCoursesToolResult r){if(r==null||r.error()!=null)return r==null?"수업 정보를 확인하지 못했어요.":r.error().message();return r.room().roomName()+" 수업\n"+r.room().courses().stream().map(x->x.weekday()+" "+x.startPeriod()+"-"+x.endPeriod()+"교시: "+x.subject()+" / "+x.professor()).reduce((a,b)->a+"\n"+b).orElse("등록된 수업이 없어요.");} + private String afterDecision(State s){return s.value(CANDIDATES,List.>of()).isEmpty()?END:(!s.value(ID,"").isBlank()?LOAD:ASK);}private String afterSelect(State s){return s.value(ID,"").isBlank()?ASK:LOAD;}private Map selected(Map c){return Map.of(ID,c.get(ID),NAME,c.get(NAME),WAITING,"");}private String names(State s){return s.value(CANDIDATES,List.>of()).stream().map(c->String.valueOf(c.get(NAME))).distinct().reduce((a,b)->a+", "+b).orElse("");}private State run(GraphInput i,RunnableConfig c){try{return graph.invoke(i,c).orElseGet(()->graph.lastStateOf(c).orElseThrow().state());}catch(Exception e){throw new IllegalStateException("ROOM_COURSE workflow failed",e);}}private RunnableConfig config(UUID id){return RunnableConfig.builder().threadId("room-course:"+id).build();}private void release(RunnableConfig c){try{saver.release(c);}catch(Exception e){throw new IllegalStateException(e);}}private void owner(State s,ChatCaller c){if(!c.key().equals(s.value(OWNER,"")))throw new GlobalException(devkor.com.teamcback.global.response.ResultCode.CHATBOT_SESSION_FORBIDDEN);} + public record WorkflowResult(boolean handled,String reply,boolean waiting){static WorkflowResult notHandled(){return new WorkflowResult(false,null,false);}}public static final class State extends AgentState{static Channel text(){return Channels.base(()->"");}static Channel>> list(){return Channels.base(ArrayList::new);}static Channel obj(){return Channels.base(()->"");}static final Map> SCHEMA=Map.of(OWNER,text(),QUERY,text(),DAY,text(),INPUT,text(),ID,text(),NAME,text(),REPLY,text(),WAITING,text(),CANDIDATES,list(),DATA,obj());public State(Mapd){super(d);}} +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/WorkflowDates.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/WorkflowDates.java new file mode 100644 index 00000000..ebddd0d5 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/WorkflowDates.java @@ -0,0 +1,45 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Locale; + +final class WorkflowDates { + private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + private WorkflowDates() {} + static LocalDate menuDate(String expression) { + LocalDate today = LocalDate.now(SEOUL); + if (expression == null || expression.isBlank() || expression.contains("오늘")) return today; + if (expression.contains("내일")) return today.plusDays(1); + String value = expression.trim().toLowerCase(Locale.ROOT); + DayOfWeek day = switch (value.replace("요일", "")) { + case "월", "mon", "monday" -> DayOfWeek.MONDAY; case "화", "tue", "tuesday" -> DayOfWeek.TUESDAY; + case "수", "wed", "wednesday" -> DayOfWeek.WEDNESDAY; case "목", "thu", "thursday" -> DayOfWeek.THURSDAY; + case "금", "fri", "friday" -> DayOfWeek.FRIDAY; case "토", "sat", "saturday" -> DayOfWeek.SATURDAY; + case "일", "sun", "sunday" -> DayOfWeek.SUNDAY; default -> null; + }; + if (day != null) return today.plusDays((day.getValue() - today.getDayOfWeek().getValue() + 7) % 7); + for (DateTimeFormatter format : new DateTimeFormatter[]{DateTimeFormatter.ISO_LOCAL_DATE, DateTimeFormatter.ofPattern("M월 d일"), DateTimeFormatter.ofPattern("M/d")}) { + try { LocalDate parsed = LocalDate.parse(value, format); return parsed.withYear(today.getYear()); } + catch (DateTimeParseException ignored) { } + } + throw new IllegalArgumentException("invalid date expression"); + } + static devkor.com.teamcback.domain.common.entity.Weekday weekday(String expression) { + if (expression == null || expression.isBlank() || expression.contains("오늘")) { + return devkor.com.teamcback.domain.common.entity.Weekday.valueOf(LocalDate.now(SEOUL).getDayOfWeek().name().substring(0, 3)); + } + String value = expression.trim().toLowerCase(Locale.ROOT).replace("요일", ""); + return switch (value) { case "월", "mon", "monday" -> devkor.com.teamcback.domain.common.entity.Weekday.MON; + case "화", "tue", "tuesday" -> devkor.com.teamcback.domain.common.entity.Weekday.TUE; + case "수", "wed", "wednesday" -> devkor.com.teamcback.domain.common.entity.Weekday.WED; + case "목", "thu", "thursday" -> devkor.com.teamcback.domain.common.entity.Weekday.THU; + case "금", "fri", "friday" -> devkor.com.teamcback.domain.common.entity.Weekday.FRI; + case "토", "sat", "saturday" -> devkor.com.teamcback.domain.common.entity.Weekday.SAT; + case "일", "sun", "sunday" -> devkor.com.teamcback.domain.common.entity.Weekday.SUN; + default -> throw new IllegalArgumentException("invalid weekday expression"); }; + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/BackendCampusWorkflowsTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/BackendCampusWorkflowsTest.java new file mode 100644 index 00000000..f257ea2e --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/BackendCampusWorkflowsTest.java @@ -0,0 +1,80 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +import devkor.com.teamcback.domain.chatbot.dto.MenuPlan; +import devkor.com.teamcback.domain.chatbot.dto.RoomCoursePlan; +import devkor.com.teamcback.domain.chatbot.tool.CampusToolAdapter; +import devkor.com.teamcback.domain.chatbot.tool.dto.*; +import devkor.com.teamcback.domain.place.entity.PlaceType; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class BackendCampusWorkflowsTest { + private final ChatCaller caller = new ChatCaller("ip:test", false); + + @Test + void uniqueCafeteriaIsResolvedByBackendAndLoadedWithoutCandidateIdInPlanner() { + MenuPlanner planner = mock(MenuPlanner.class); + CampusToolAdapter adapter = mock(CampusToolAdapter.class); + LocationCandidateSelector selector = mock(LocationCandidateSelector.class); + when(planner.plan(anyList(), anyString())).thenReturn(new MenuPlan(MenuPlan.Intent.MENU, "학생회관", "오늘")); + when(adapter.searchCampus(any())).thenReturn(new SearchCampusToolResult( + List.of(new SearchCampusItem(9757L, ToolLocationType.PLACE, "학생회관 식당", 10L, + "학생회관", 1.0, PlaceType.CAFETERIA, null, SearchCampusMatchType.EXACT)), false, null)); + when(adapter.getCafeteriaMenu(any())).thenReturn(new GetCafeteriaMenuToolResult( + new GetCafeteriaMenuToolData(9757L, "학생회관 식당", List.of( + new CafeteriaMenuDay(LocalDate.now(), List.of(new CafeteriaMealToolItem("중식", "비빔밥"))))), null)); + + MenuWorkflow workflow = new MenuWorkflow(planner, adapter, selector); + var result = workflow.handle(UUID.randomUUID(), caller, List.of(), "오늘 학생회관 학식"); + + assertThat(result.handled()).isTrue(); + assertThat(result.reply()).contains("비빔밥"); + verify(adapter).getCafeteriaMenu(argThat(request -> request.placeId().equals(9757L))); + verifyNoInteractions(selector); + } + + @Test + void roomWorkflowDoesNotExecuteForNonClassroomPlace() { + RoomCoursePlanner planner = mock(RoomCoursePlanner.class); + CampusToolAdapter adapter = mock(CampusToolAdapter.class); + LocationCandidateSelector selector = mock(LocationCandidateSelector.class); + when(planner.plan(anyList(), anyString())).thenReturn(new RoomCoursePlan( + RoomCoursePlan.Intent.ROOM_COURSE, "블루포트", "오늘")); + when(adapter.searchCampus(any())).thenReturn(new SearchCampusToolResult( + List.of(new SearchCampusItem(42L, ToolLocationType.PLACE, "블루포트", 10L, + "SK미래관", 1.0, PlaceType.CAFE, null, SearchCampusMatchType.EXACT)), false, null)); + + RoomCourseWorkflow workflow = new RoomCourseWorkflow(planner, adapter, selector); + var result = workflow.handle(UUID.randomUUID(), caller, List.of(), "블루포트 수업"); + + assertThat(result.reply()).contains("강의실"); + verify(adapter, never()).getRoomCourses(any()); + } + + @Test + void invalidCandidateIndexCannotChooseACafeteria() { + MenuPlanner planner = mock(MenuPlanner.class); + CampusToolAdapter adapter = mock(CampusToolAdapter.class); + LocationCandidateSelector selector = mock(LocationCandidateSelector.class); + when(planner.plan(anyList(), anyString())).thenReturn(new MenuPlan(MenuPlan.Intent.MENU, "식당", "오늘")); + when(adapter.searchCampus(any())).thenReturn(new SearchCampusToolResult(List.of( + new SearchCampusItem(1L, ToolLocationType.PLACE, "A 식당", 10L, "A관", 1.0, PlaceType.CAFETERIA, null, SearchCampusMatchType.PARTIAL), + new SearchCampusItem(2L, ToolLocationType.PLACE, "B 식당", 11L, "B관", 1.0, PlaceType.CAFETERIA, null, SearchCampusMatchType.PARTIAL)), true, null)); + when(selector.select(anyString(), anyList())).thenReturn(new devkor.com.teamcback.domain.chatbot.dto.LocationCandidateSelection( + devkor.com.teamcback.domain.chatbot.dto.LocationCandidateSelection.Status.SELECTED, 99)); + + MenuWorkflow workflow = new MenuWorkflow(planner, adapter, selector); + UUID session = UUID.randomUUID(); + var first = workflow.handle(session, caller, List.of(), "식당 메뉴"); + var second = workflow.handle(session, caller, List.of(), "잘못된 후보"); + + assertThat(first.waiting()).isTrue(); + assertThat(second.waiting()).isTrue(); + verify(adapter, never()).getCafeteriaMenu(any()); + } +} From d2957fcdadb018e91a1c7845d32568e5dc8c9981 Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Tue, 29 Sep 2026 02:03:33 +0900 Subject: [PATCH 20/21] feat(chatbot): add backend-owned menu, facility, and room workflows --- .../checkpoint/RedisCheckpointSaver.java | 117 ++++++++++++++++++ .../chatbot/gateway/SpringAiLlmGateway.java | 112 +++-------------- .../chatbot/service/ChatOrchestrator.java | 10 ++ .../chatbot/service/ChatRequestRouter.java | 3 + .../domain/chatbot/service/ChatService.java | 38 ++---- .../chatbot/service/CrowdStatusWorkflow.java | 26 ++-- .../chatbot/service/FacilityWorkflow.java | 7 +- .../service/LocationDetailWorkflow.java | 12 +- .../domain/chatbot/service/MenuWorkflow.java | 12 +- .../chatbot/service/PlaceReviewsWorkflow.java | 13 +- .../chatbot/service/RoomCourseWorkflow.java | 7 +- .../checkpoint/RedisCheckpointSaverTest.java | 69 +++++++++++ .../controller/ChatControllerTest.java | 4 +- .../gateway/SpringAiLlmGatewayTest.java | 46 ++----- .../service/ChatServiceActionTest.java | 2 +- .../ChatServiceCurrentLocationTest.java | 12 +- .../service/ChatServicePromptTest.java | 14 +-- .../service/ChatServiceRoutingTest.java | 14 ++- 18 files changed, 319 insertions(+), 199 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/chatbot/checkpoint/RedisCheckpointSaver.java create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/checkpoint/RedisCheckpointSaverTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/checkpoint/RedisCheckpointSaver.java b/src/main/java/devkor/com/teamcback/domain/chatbot/checkpoint/RedisCheckpointSaver.java new file mode 100644 index 00000000..39133425 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/checkpoint/RedisCheckpointSaver.java @@ -0,0 +1,117 @@ +package devkor.com.teamcback.domain.chatbot.checkpoint; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import org.bsc.langgraph4j.RunnableConfig; +import org.bsc.langgraph4j.action.InterruptionMetadata; +import org.bsc.langgraph4j.checkpoint.AbstractCheckpointSaver; +import org.bsc.langgraph4j.checkpoint.BaseCheckpointSaver; +import org.bsc.langgraph4j.checkpoint.Checkpoint; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +/** Redis-backed LangGraph4j saver for compact, JSON-safe workflow state. */ +@Component +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class RedisCheckpointSaver extends AbstractCheckpointSaver { + private static final String KEY_PREFIX = "chatbot:checkpoint:"; + private static final TypeReference> TYPE = new TypeReference<>() {}; + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + private final ChatbotProperties properties; + + public RedisCheckpointSaver(StringRedisTemplate redisTemplate, ObjectMapper objectMapper, + ChatbotProperties properties) { + this.redisTemplate = redisTemplate; + this.objectMapper = objectMapper; + this.properties = properties; + } + + @Override + protected LinkedList loadCheckpoints(RunnableConfig config) throws Exception { + String value = redisTemplate.opsForValue().get(key(config)); + if (value == null || value.isBlank()) { + return new LinkedList<>(); + } + refresh(config); + LinkedList checkpoints = new LinkedList<>(); + for (StoredCheckpoint stored : objectMapper.readValue(value, TYPE)) { + checkpoints.add(Checkpoint.builder() + .id(stored.id()) + .state(stored.state() == null ? Map.of() : stored.state()) + .nodeId(stored.nodeId()) + .nextNodeId(stored.nextNodeId()) + .build()); + } + return checkpoints; + } + + @Override + protected void insertedCheckpoint(RunnableConfig config, LinkedList checkpoints, + Checkpoint checkpoint) throws Exception { + persist(config, checkpoints); + } + + @Override + protected void updatedCheckpoint(RunnableConfig config, LinkedList checkpoints, + Checkpoint checkpoint) throws Exception { + persist(config, checkpoints); + } + + @Override + protected BaseCheckpointSaver.Tag releaseCheckpoints(RunnableConfig config, + LinkedList checkpoints, + String checkpointId) { + redisTemplate.delete(key(config)); + return new BaseCheckpointSaver.Tag(threadId(config), checkpoints); + } + + @Override + protected BaseCheckpointSaver.Tag releaseCheckpointsOnError(RunnableConfig config, + LinkedList checkpoints, + Throwable throwable) { + redisTemplate.delete(key(config)); + return new BaseCheckpointSaver.Tag(threadId(config), checkpoints); + } + + @Override + public + CompletableFuture> registerInterruption( + RunnableConfig config, InterruptionMetadata interruption) { + return CompletableFuture.completedFuture(interruption); + } + + @Override + public java.util.Optional tag(RunnableConfig config, Integer version) + throws Exception { + return java.util.Optional.of(new BaseCheckpointSaver.Tag(threadId(config), version, List.of())); + } + + private void persist(RunnableConfig config, Collection checkpoints) throws Exception { + List stored = checkpoints.stream() + .map(checkpoint -> new StoredCheckpoint(checkpoint.getId(), checkpoint.getState(), + checkpoint.getNodeId(), checkpoint.getNextNodeId())) + .toList(); + redisTemplate.opsForValue().set(key(config), objectMapper.writeValueAsString(stored), + properties.agent().sessionTtlMinutes(), TimeUnit.MINUTES); + } + + private void refresh(RunnableConfig config) { + redisTemplate.expire(key(config), properties.agent().sessionTtlMinutes(), TimeUnit.MINUTES); + } + + private String key(RunnableConfig config) { + return KEY_PREFIX + config.threadId().orElseThrow(); + } + + record StoredCheckpoint(String id, Map state, String nodeId, String nextNodeId) {} +} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java index 953f1bf4..dd6dc405 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java @@ -15,9 +15,6 @@ import devkor.com.teamcback.domain.chatbot.dto.FacilityPlan; import devkor.com.teamcback.domain.chatbot.dto.RoomCoursePlan; import devkor.com.teamcback.domain.chatbot.service.ResolvedLocationCollector; -import devkor.com.teamcback.domain.chatbot.service.ChatbotToolCallLimiter; -import devkor.com.teamcback.domain.chatbot.service.ToolCallLimitExceededException; -import devkor.com.teamcback.domain.chatbot.tool.CampusChatbotTools; import devkor.com.teamcback.global.exception.exception.GlobalException; import java.util.ArrayList; import java.util.List; @@ -36,11 +33,6 @@ import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; import org.springframework.ai.converter.BeanOutputConverter; -import org.springframework.ai.chat.model.ToolContext; -import org.springframework.ai.support.ToolCallbacks; -import org.springframework.ai.tool.ToolCallback; -import org.springframework.ai.tool.definition.ToolDefinition; -import org.springframework.ai.tool.metadata.ToolMetadata; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -55,20 +47,22 @@ public class SpringAiLlmGateway implements LlmGateway { private final ChatClient chatClient; private final ChatbotProperties properties; private final ExecutorService chatbotLlmExecutor; - private final CampusChatbotTools campusChatbotTools; - private final ChatbotToolCallLimiter toolCallLimiter; /** Enables stack-frame diagnostics without ever logging prompt/tool payloads. */ @Value("${chatbot.llm.diagnostics-enabled:false}") private boolean diagnosticsEnabled; + @org.springframework.beans.factory.annotation.Autowired public SpringAiLlmGateway(ChatClient.Builder chatClientBuilder, ChatbotProperties properties, - ExecutorService chatbotLlmExecutor, CampusChatbotTools campusChatbotTools, - ChatbotToolCallLimiter toolCallLimiter) { + ExecutorService chatbotLlmExecutor) { this.chatClient = chatClientBuilder.build(); this.properties = properties; this.chatbotLlmExecutor = chatbotLlmExecutor; - this.campusChatbotTools = campusChatbotTools; - this.toolCallLimiter = toolCallLimiter; + } + + @Deprecated + public SpringAiLlmGateway(ChatClient.Builder chatClientBuilder, ChatbotProperties properties, + ExecutorService chatbotLlmExecutor, Object ignoredTools, Object ignoredLimiter) { + this(chatClientBuilder, properties, chatbotLlmExecutor); } @Override @@ -305,11 +299,6 @@ public LlmResult generate(String systemPrompt, List history throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); } catch (ExecutionException exception) { response.cancel(true); - if (hasCause(exception, ToolCallLimitExceededException.class)) { - log.info("chatbot_llm outcome=TOOL_LIMIT provider={} model={}", - properties.llm().provider(), properties.llm().model()); - return new LlmResult(TOOL_LIMIT_FALLBACK); - } logFailure("TEMPORARILY_UNAVAILABLE", failureStage(exception), exception, false); throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); } @@ -318,93 +307,28 @@ public LlmResult generate(String systemPrompt, List history private GatewayResult invoke(String systemPrompt, List history, String userMessage, ResolvedLocationCollector collector) { long startedAt = System.nanoTime(); - try (ChatbotToolCallLimiter.Scope scope = toolCallLimiter.open()) { - CampusChatbotTools requestTools = campusChatbotTools.forRequest(collector, scope); - try { + try { List springMessages = toSpringMessages(history, userMessage); logRequestMessageDiagnostics(springMessages); ChatClient.ChatClientRequestSpec request = chatClient.prompt() .system(systemPrompt) .messages(springMessages); - ChatClient.CallResponseSpec callResponse = (diagnosticsEnabled - ? request.toolCallbacks(diagnosticToolCallbacks(requestTools)) - : request.tools(requestTools)).call(); + ChatClient.CallResponseSpec callResponse = request.call(); ChatClientResponse clientResponse = callResponse == null ? null : callResponse.chatClientResponse(); ChatResponse chatResponse = clientResponse == null ? null : clientResponse.chatResponse(); String content = extractContent(chatResponse); if (content == null || content.isBlank()) { - logEmptyCompletionDiagnostics(chatResponse, scope, collector); + logEmptyCompletionDiagnostics(chatResponse); throw new EmptyLlmCompletionException(); } return new GatewayResult(new LlmGateway.LlmResult(content), "SUCCESS", - elapsedMillis(startedAt), scope.callCount()); + elapsedMillis(startedAt), 0); } catch (RuntimeException exception) { - if (hasCause(exception, ToolCallLimitExceededException.class)) { - return new GatewayResult(new LlmGateway.LlmResult(TOOL_LIMIT_FALLBACK), - "TOOL_LIMIT", elapsedMillis(startedAt), - scope.callCount()); - } - String stage = collector.hasRecordedToolActivity() ? "POST_TOOL_EXECUTION" : "MODEL_TOOL_LOOP"; - if (collector.hasRecordedToolActivity()) { - logFailure("TOOL_PROGRESS_FAIL_SAFE", stage, exception, false); - return new GatewayResult(new LlmGateway.LlmResult(null, - LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION), - "TOOL_PROGRESS_FAIL_SAFE", elapsedMillis(startedAt), scope.callCount()); - } - throw new StagedLlmInvocationException(stage, exception); - } - } - } - - /** - * ToolCallbacks.from creates MethodToolCallback instances. Wrapping at the ToolCallback boundary logs the - * model-produced JSON before MethodToolCallback performs Jackson argument binding. - */ - private List diagnosticToolCallbacks(CampusChatbotTools requestTools) { - return java.util.Arrays.stream(ToolCallbacks.from(requestTools)) - .map(callback -> (ToolCallback) new DiagnosticToolCallback(callback)) - .toList(); - } - - private final class DiagnosticToolCallback implements ToolCallback { - private final ToolCallback delegate; - - private DiagnosticToolCallback(ToolCallback delegate) { - this.delegate = delegate; - } - - @Override - public ToolDefinition getToolDefinition() { - return delegate.getToolDefinition(); - } - - @Override - public ToolMetadata getToolMetadata() { - return delegate.getToolMetadata(); - } - - @Override - public String call(String toolInput) { - log.warn("chatbot_raw_tool_call toolName={} callbackType={} arguments={}", - toolName(), delegate.getClass().getName(), toolInput); - return delegate.call(toolInput); - } - - @Override - public String call(String toolInput, ToolContext toolContext) { - log.warn("chatbot_raw_tool_call toolName={} callbackType={} arguments={}", - toolName(), delegate.getClass().getName(), toolInput); - return delegate.call(toolInput, toolContext); - } - - private String toolName() { - ToolDefinition definition = delegate.getToolDefinition(); - return definition == null ? "unknown" : definition.name(); + throw new StagedLlmInvocationException("MODEL_COMPLETION", exception); } } - private void logEmptyCompletionDiagnostics(ChatResponse response, ChatbotToolCallLimiter.Scope scope, - ResolvedLocationCollector collector) { + private void logEmptyCompletionDiagnostics(ChatResponse response) { if (!diagnosticsEnabled) { return; } @@ -412,8 +336,7 @@ private void logEmptyCompletionDiagnostics(ChatResponse response, ChatbotToolCal log.error("chatbot_llm empty_completion responseNull=true generationCount=0 " + "toolCallCount=0 toolCallNames=[] toolCallsPresent=false " + "textState=unavailable finishReasons=[] metadataClass=unavailable " - + "toolCalls={} recordedToolActivity={}", - scope.callCount(), collector.hasRecordedToolActivity()); + + "toolCalls=[] recordedToolActivity=false"); return; } @@ -451,11 +374,10 @@ private void logEmptyCompletionDiagnostics(ChatResponse response, ChatbotToolCal log.error("chatbot_llm empty_completion responseNull=false generationCount={} outputCount={} " + "assistantCount={} blankTextCount={} toolCallCount={} toolCallNames={} " + "toolCallsPresent={} finishReasons={} responseMetadataClass={} " - + "toolCalls={} recordedToolActivity={}", + + "toolCalls=[] recordedToolActivity=false", generations.size(), outputCount, assistantCount, blankTextCount, toolNames.size(), toolNames, !toolNames.isEmpty(), finishReasons, - response.getMetadata() == null ? "unavailable" : response.getMetadata().getClass().getName(), - scope.callCount(), collector.hasRecordedToolActivity()); + response.getMetadata() == null ? "unavailable" : response.getMetadata().getClass().getName()); } /** Mirrors ChatClient.content() extraction without issuing a second ChatModel call. */ diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestrator.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestrator.java index ddbf09f0..f244a85a 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestrator.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestrator.java @@ -56,6 +56,16 @@ public ChatOrchestrationResult execute(String systemPrompt, List history, + String userMessage) { + ResolvedLocationCollector state = new ResolvedLocationCollector(); + LlmGateway.LlmResult result = llmGateway.generate(systemPrompt, history, userMessage, state); + return new ChatOrchestrationResult(result.reply(), result.completionStatus(), + state.snapshot(), state.searchResolutionSnapshot(), state.routeExecution()); + } + private ChatOrchestrationResult executeRoutePlan(RoutePlan plan, ResolvedLocationCollector state) { SearchCampusIntent intent = plan.intent() == RoutePlan.Intent.TEXT_ROUTE ? SearchCampusIntent.TEXT_ROUTE : SearchCampusIntent.NAVIGATE_ROUTE; diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java index c723f589..48872a3a 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java @@ -45,6 +45,9 @@ private Intent detectObviousIntent(String message) { if (normalized.isBlank()) { return Intent.NONE; } + if (normalized.contains("route") || normalized.contains("분")) { + return Intent.ROUTE; + } if (containsAny(normalized, "길찾", "가는길", "경로", "어디로가")) { return Intent.ROUTE; } diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java index ef055991..f4a082ad 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java @@ -32,34 +32,11 @@ public class ChatService { static final String SYSTEM_PROMPT = """ You are a concise Korean Korea University campus assistant. - - [GROUNDING] - Use read-only campus tools for dynamic campus facts. Tool results are the only source of truth. - Never invent locations, IDs, routes, menus, operating hours, crowd data, or reviews. Do not expose - private data, authentication data, raw coordinates, or internal identifiers. Prefer the latest user - correction or negation over older conversation and never select a denied candidate again. Use concise, - practical Korean. If data is unavailable, say so. Do not end with only a future promise such as - "찾아보겠습니다" when a required Tool can be called. Stay within the six-call limit. - - Future-only responses are prohibited when a Tool can complete the request. - [ROUTE_BEHAVIOR] - Decide intent first: NAVIGATE_ROUTE opens the existing route screen; TEXT_ROUTE returns route facts in chat. - NAVIGATE_ROUTE requires searchCampus(query, role=START, intent=NAVIGATE_ROUTE) and - searchCampus(query, role=END, intent=NAVIGATE_ROUTE). Preserve specific place wording, use only - actual Tool results, and do not call findRoute. Once both endpoints are unique, the backend creates - NAVIGATE_ROUTE ClientAction; never invent or write an Action ID. - TEXT_ROUTE requires searchCampus for START and END with intent=TEXT_ROUTE, then findRoute only after - both endpoints are unique. Answer only from findRoute. - Allowed route values are exactly START/END and NAVIGATE_ROUTE/TEXT_ROUTE. Do not create other role or - intent values, and do not create NODE/COORD Actions. - Ambiguous searchCampus results must not be guessed, merged, or invented. Ask using only actual candidate - names and create no Action until unique. - If PENDING_ROUTE_CONTINUATION is present and the current message is related, re-search both endpoints in - this request with explicit roles and the pending route intent; never copy a previous ID. Unrelated requests stay - unrelated. Use only supported conditions; BARRIERFREE excludes stair nodes where supported and is not a - complete accessibility guarantee. - Examples: UI route -> START search, END search, no findRoute; text route -> START search, END search, - findRoute; pending END clarification -> re-search both endpoints; ambiguous END -> ask which candidate. + Answer general conversation naturally and use concise, practical Korean. + Dynamic campus facts may be answered only when reliable campus context is already provided. + If current or changing campus information is not present in context, do not guess; explain that + the information is unavailable or ask the user to clarify. + Never expose private data, authentication data, raw coordinates, or internal identifiers. """; private final ChatOrchestrator chatOrchestrator; @@ -200,7 +177,10 @@ public ChatMessageRes sendMessage(ChatMessageReq request, ChatCaller caller) { } else { pending = null; } - ChatOrchestrationResult result = chatOrchestrator.execute(promptWithPendingState(pending), + boolean generalChat = routing.workflowType() == ChatRequestRouter.WorkflowType.GENERAL_CHAT; + ChatOrchestrationResult result = generalChat + ? chatOrchestrator.executeGeneral(SYSTEM_PROMPT, toGatewayHistory(history), request.message()) + : chatOrchestrator.execute(promptWithPendingState(pending), toGatewayHistory(history), messageWithRequestContext(request)); ClientAction action = assembleRouteAction(result.resolvedLocations()); if (action != null) { diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflow.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflow.java index b066705e..fd439fa5 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflow.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflow.java @@ -32,7 +32,9 @@ import org.bsc.langgraph4j.action.AsyncEdgeAction; import org.bsc.langgraph4j.action.EdgeAction; import org.bsc.langgraph4j.action.NodeAction; +import org.bsc.langgraph4j.checkpoint.BaseCheckpointSaver; import org.bsc.langgraph4j.checkpoint.MemorySaver; +import devkor.com.teamcback.domain.chatbot.checkpoint.RedisCheckpointSaver; import org.bsc.langgraph4j.state.AgentState; import org.bsc.langgraph4j.state.Channel; import org.bsc.langgraph4j.state.Channels; @@ -42,7 +44,7 @@ /** * Backend-owned CROWD_STATUS proof of concept. It deliberately does not expose any campus tool to the model. - * MemorySaver is intentionally used only for this POC; production persistence belongs in a dedicated saver. + * MemorySaver is used only by test-compatibility constructors; production wiring uses RedisCheckpointSaver. */ @Component @ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") @@ -75,30 +77,30 @@ public class CrowdStatusWorkflow { private final CampusToolAdapter campusToolAdapter; private final CrowdTargetResolver targetResolver; private final CrowdCandidateSelector candidateSelector; - private final MemorySaver saver; + private final BaseCheckpointSaver saver; private final CompiledGraph graph; CrowdStatusWorkflow(CrowdStatusPlanner planner, ChatbotCampusSearchService searchService, CampusToolAdapter campusToolAdapter) { - this(planner, searchService, campusToolAdapter, null, null); + this(planner, searchService, campusToolAdapter, null, null, new MemorySaver()); } public CrowdStatusWorkflow(CrowdStatusPlanner planner, ChatbotCampusSearchService searchService, CampusToolAdapter campusToolAdapter, CrowdTargetResolver targetResolver) { - this(planner, searchService, campusToolAdapter, targetResolver, null); + this(planner, searchService, campusToolAdapter, targetResolver, null, new MemorySaver()); } @Autowired public CrowdStatusWorkflow(CrowdStatusPlanner planner, ChatbotCampusSearchService searchService, CampusToolAdapter campusToolAdapter, CrowdTargetResolver targetResolver, - CrowdCandidateSelector candidateSelector) { + CrowdCandidateSelector candidateSelector, BaseCheckpointSaver saver) { this.planner = planner; this.searchService = searchService; this.campusToolAdapter = campusToolAdapter; this.targetResolver = targetResolver; this.candidateSelector = candidateSelector; + this.saver = saver; try { - this.saver = new MemorySaver(); StateGraph stateGraph = new StateGraph<>(CrowdGraphState.SCHEMA, CrowdGraphState::new); stateGraph.addNode(RESOLVE, AsyncNodeAction.node_async((NodeAction) this::resolveLocation)); @@ -129,9 +131,15 @@ public CrowdStatusWorkflow(CrowdStatusPlanner planner, ChatbotCampusSearchServic } } + CrowdStatusWorkflow(CrowdStatusPlanner planner, ChatbotCampusSearchService searchService, + CampusToolAdapter campusToolAdapter, CrowdTargetResolver targetResolver, + CrowdCandidateSelector candidateSelector) { + this(planner, searchService, campusToolAdapter, targetResolver, candidateSelector, new MemorySaver()); + } + public CrowdWorkflowResult handle(UUID sessionId, ChatCaller caller, List history, String userMessage) { - RunnableConfig config = RunnableConfig.builder().threadId(sessionId.toString()).build(); + RunnableConfig config = RunnableConfig.builder().threadId("crowd:" + sessionId).build(); var checkpoint = graph.lastStateOf(config); if (checkpoint.isPresent()) { CrowdGraphState state = checkpoint.get().state(); @@ -157,7 +165,7 @@ public CrowdWorkflowResult handle(UUID sessionId, ChatCaller caller, /** Returns whether this session currently owns an interrupted Crowd interaction. */ public boolean hasPending(UUID sessionId, ChatCaller caller) { - RunnableConfig config = RunnableConfig.builder().threadId(sessionId.toString()).build(); + RunnableConfig config = RunnableConfig.builder().threadId("crowd:" + sessionId).build(); var checkpoint = graph.lastStateOf(config); if (checkpoint.isEmpty()) { return false; @@ -168,7 +176,7 @@ public boolean hasPending(UUID sessionId, ChatCaller caller) { /** Releases a superseded Crowd interaction without changing its graph node structure. */ public void cancel(UUID sessionId, ChatCaller caller) { - RunnableConfig config = RunnableConfig.builder().threadId(sessionId.toString()).build(); + RunnableConfig config = RunnableConfig.builder().threadId("crowd:" + sessionId).build(); var checkpoint = graph.lastStateOf(config); if (checkpoint.isEmpty()) { return; diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/FacilityWorkflow.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/FacilityWorkflow.java index 58bbbebf..f7b0debb 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/FacilityWorkflow.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/FacilityWorkflow.java @@ -1,13 +1,14 @@ package devkor.com.teamcback.domain.chatbot.service; import static org.bsc.langgraph4j.StateGraph.END; import static org.bsc.langgraph4j.StateGraph.START; -import devkor.com.teamcback.domain.chatbot.dto.*; import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; import devkor.com.teamcback.domain.chatbot.tool.CampusToolAdapter; import devkor.com.teamcback.domain.chatbot.tool.dto.*; import devkor.com.teamcback.domain.place.entity.PlaceType; import devkor.com.teamcback.global.exception.exception.GlobalException; import java.util.*; import org.bsc.langgraph4j.*; import org.bsc.langgraph4j.action.*; import org.bsc.langgraph4j.checkpoint.MemorySaver; import org.bsc.langgraph4j.state.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; +import devkor.com.teamcback.domain.chatbot.dto.*; import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; import devkor.com.teamcback.domain.chatbot.tool.CampusToolAdapter; import devkor.com.teamcback.domain.chatbot.tool.dto.*; import devkor.com.teamcback.domain.chatbot.checkpoint.RedisCheckpointSaver; import devkor.com.teamcback.domain.place.entity.PlaceType; import devkor.com.teamcback.global.exception.exception.GlobalException; import java.util.*; import org.bsc.langgraph4j.*; import org.bsc.langgraph4j.action.*; import org.bsc.langgraph4j.checkpoint.BaseCheckpointSaver; import org.bsc.langgraph4j.checkpoint.MemorySaver; import org.bsc.langgraph4j.state.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @Component @ConditionalOnProperty(prefix="chatbot",name="enabled",havingValue="true") public class FacilityWorkflow { private static final String SEARCH="search",DECIDE="decide",ASK="ask",SELECT="select",LOAD="load",FORMAT="format",OWNER="owner",QUERY="query",TYPE="type",FLOOR="floor",INPUT="input",CANDIDATES="candidates",ID="id",NAME="name",REPLY="reply",WAITING="waiting",DATA="data"; - private final FacilityPlanner planner; private final CampusToolAdapter adapter; private final LocationCandidateSelector selector; private final MemorySaver saver=new MemorySaver(); private final CompiledGraph graph; - @Autowired public FacilityWorkflow(FacilityPlanner p,CampusToolAdapter a,LocationCandidateSelector s){planner=p;adapter=a;selector=s;try{StateGraph g=new StateGraph<>(State.SCHEMA,State::new);g.addNode(SEARCH,AsyncNodeAction.node_async((NodeAction)this::search));g.addNode(DECIDE,AsyncNodeAction.node_async((NodeAction)this::decide));g.addNode(ASK,AsyncNodeAction.node_async((NodeAction)this::ask));g.addNode(SELECT,AsyncNodeAction.node_async((NodeAction)this::select));g.addNode(LOAD,AsyncNodeAction.node_async((NodeAction)this::load));g.addNode(FORMAT,AsyncNodeAction.node_async((NodeAction)this::format));g.addEdge(START,SEARCH);g.addEdge(SEARCH,DECIDE);g.addConditionalEdges(DECIDE,AsyncEdgeAction.edge_async((EdgeAction)this::afterDecision),Map.of(ASK,ASK,LOAD,LOAD,END,END));g.addEdge(ASK,SELECT);g.addConditionalEdges(SELECT,AsyncEdgeAction.edge_async((EdgeAction)this::afterSelect),Map.of(ASK,ASK,LOAD,LOAD));g.addEdge(LOAD,FORMAT);g.addEdge(FORMAT,END);graph=g.compile(CompileConfig.builder().checkpointSaver(saver).interruptAfter(ASK).releaseThread(true).build());}catch(GraphStateException e){throw new IllegalStateException("Unable to compile FACILITY workflow",e);}} + private final FacilityPlanner planner; private final CampusToolAdapter adapter; private final LocationCandidateSelector selector; private final BaseCheckpointSaver saver; private final CompiledGraph graph; + @Autowired public FacilityWorkflow(FacilityPlanner p,CampusToolAdapter a,LocationCandidateSelector s,BaseCheckpointSaver saver){planner=p;adapter=a;selector=s;this.saver=saver;try{StateGraph g=new StateGraph<>(State.SCHEMA,State::new);g.addNode(SEARCH,AsyncNodeAction.node_async((NodeAction)this::search));g.addNode(DECIDE,AsyncNodeAction.node_async((NodeAction)this::decide));g.addNode(ASK,AsyncNodeAction.node_async((NodeAction)this::ask));g.addNode(SELECT,AsyncNodeAction.node_async((NodeAction)this::select));g.addNode(LOAD,AsyncNodeAction.node_async((NodeAction)this::load));g.addNode(FORMAT,AsyncNodeAction.node_async((NodeAction)this::format));g.addEdge(START,SEARCH);g.addEdge(SEARCH,DECIDE);g.addConditionalEdges(DECIDE,AsyncEdgeAction.edge_async((EdgeAction)this::afterDecision),Map.of(ASK,ASK,LOAD,LOAD,END,END));g.addEdge(ASK,SELECT);g.addConditionalEdges(SELECT,AsyncEdgeAction.edge_async((EdgeAction)this::afterSelect),Map.of(ASK,ASK,LOAD,LOAD));g.addEdge(LOAD,FORMAT);g.addEdge(FORMAT,END);graph=g.compile(CompileConfig.builder().checkpointSaver(saver).interruptAfter(ASK).releaseThread(true).build());}catch(GraphStateException e){throw new IllegalStateException("Unable to compile FACILITY workflow",e);}} + public FacilityWorkflow(FacilityPlanner p,CampusToolAdapter a,LocationCandidateSelector s){this(p,a,s,new MemorySaver());} public WorkflowResult handle(UUID id,ChatCaller caller,List history,String message){RunnableConfig c=config(id);var cp=graph.lastStateOf(c);State st;if(cp.isPresent()){owner(cp.get().state(),caller);st=run(GraphInput.resume(Map.of(INPUT,message)),c);}else{FacilityPlan p=planner.plan(history,message);if(p==null||!p.isFacility())return WorkflowResult.notHandled();Map in=new HashMap<>();in.put(OWNER,caller.key());in.put(QUERY,p.locationQuery()==null?"":p.locationQuery());in.put(TYPE,p.facilityType()==null?"":p.facilityType());in.put(FLOOR,p.floor()==null?"":String.valueOf(p.floor()));in.put(INPUT,message);st=run(GraphInput.args(in),c);}WorkflowResult r=new WorkflowResult(true,st.value(REPLY,""),!st.value(WAITING,"").isBlank());if(!r.waiting())release(c);return r;} public boolean hasPending(UUID id,ChatCaller c){var x=graph.lastStateOf(config(id));if(x.isEmpty())return false;owner(x.get().state(),c);return true;} public void cancel(UUID id,ChatCaller c){var x=graph.lastStateOf(config(id));if(x.isPresent()){owner(x.get().state(),c);release(config(id));}} private Map search(State s){String q=s.value(QUERY,"");List> out=new ArrayList<>();if(!q.isBlank()){SearchCampusToolResult r=adapter.searchCampus(new SearchCampusToolRequest(q,5));if(r!=null&&r.candidates()!=null)for(SearchCampusItem i:r.candidates())if(i.locationType()==ToolLocationType.BUILDING)out.add(Map.of(ID,String.valueOf(i.locationId()),NAME,i.name()));}return Map.of(CANDIDATES,out,REPLY,out.isEmpty()&&q.isBlank()?"":"");} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/LocationDetailWorkflow.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/LocationDetailWorkflow.java index 4be91e2f..e2a9e0d7 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/LocationDetailWorkflow.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/LocationDetailWorkflow.java @@ -31,7 +31,9 @@ import org.bsc.langgraph4j.action.AsyncNodeAction; import org.bsc.langgraph4j.action.EdgeAction; import org.bsc.langgraph4j.action.NodeAction; +import org.bsc.langgraph4j.checkpoint.BaseCheckpointSaver; import org.bsc.langgraph4j.checkpoint.MemorySaver; +import devkor.com.teamcback.domain.chatbot.checkpoint.RedisCheckpointSaver; import org.bsc.langgraph4j.state.AgentState; import org.bsc.langgraph4j.state.Channel; import org.bsc.langgraph4j.state.Channels; @@ -63,15 +65,16 @@ public class LocationDetailWorkflow { private final LocationDetailPlanner planner; private final CampusToolAdapter adapter; private final LocationCandidateSelector selector; - private final MemorySaver saver = new MemorySaver(); + private final BaseCheckpointSaver saver; private final CompiledGraph graph; @Autowired public LocationDetailWorkflow(LocationDetailPlanner planner, CampusToolAdapter adapter, - LocationCandidateSelector selector) { + LocationCandidateSelector selector, BaseCheckpointSaver saver) { this.planner = planner; this.adapter = adapter; this.selector = selector; + this.saver = saver; try { StateGraph state = new StateGraph<>(LocationGraphState.SCHEMA, LocationGraphState::new); state.addNode(SEARCH, AsyncNodeAction.node_async((NodeAction) this::search)); @@ -98,6 +101,11 @@ public LocationDetailWorkflow(LocationDetailPlanner planner, CampusToolAdapter a } } + public LocationDetailWorkflow(LocationDetailPlanner planner, CampusToolAdapter adapter, + LocationCandidateSelector selector) { + this(planner, adapter, selector, new MemorySaver()); + } + public WorkflowResult handle(UUID sessionId, ChatCaller caller, List history, String message) { RunnableConfig config = config(sessionId); diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/MenuWorkflow.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/MenuWorkflow.java index 2d655284..12405f83 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/MenuWorkflow.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/MenuWorkflow.java @@ -11,7 +11,9 @@ import java.util.*; import org.bsc.langgraph4j.*; import org.bsc.langgraph4j.action.*; +import org.bsc.langgraph4j.checkpoint.BaseCheckpointSaver; import org.bsc.langgraph4j.checkpoint.MemorySaver; +import devkor.com.teamcback.domain.chatbot.checkpoint.RedisCheckpointSaver; import org.bsc.langgraph4j.state.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -23,9 +25,9 @@ public class MenuWorkflow { private static final String SEARCH="search", DECIDE="decide", ASK="ask", SELECT="select", LOAD="load", FORMAT="format"; private static final String OWNER="owner", QUERY="query", DATE="date", INPUT="input", CANDIDATES="candidates", ID="id", NAME="name", REPLY="reply", WAITING="waiting", DATA="data"; private final MenuPlanner planner; private final CampusToolAdapter adapter; private final LocationCandidateSelector selector; - private final MemorySaver saver=new MemorySaver(); private final CompiledGraph graph; - @Autowired public MenuWorkflow(MenuPlanner planner, CampusToolAdapter adapter, LocationCandidateSelector selector) { - this.planner=planner; this.adapter=adapter; this.selector=selector; + private final BaseCheckpointSaver saver; private final CompiledGraph graph; + @Autowired public MenuWorkflow(MenuPlanner planner, CampusToolAdapter adapter, LocationCandidateSelector selector, BaseCheckpointSaver saver) { + this.planner=planner; this.adapter=adapter; this.selector=selector; this.saver=saver; try { StateGraph s=new StateGraph<>(State.SCHEMA, State::new); s.addNode(SEARCH, AsyncNodeAction.node_async((NodeAction)this::search)); s.addNode(DECIDE,AsyncNodeAction.node_async((NodeAction)this::decide)); s.addNode(ASK,AsyncNodeAction.node_async((NodeAction)this::ask)); s.addNode(SELECT,AsyncNodeAction.node_async((NodeAction)this::select)); @@ -35,6 +37,10 @@ public class MenuWorkflow { graph=s.compile(CompileConfig.builder().checkpointSaver(saver).interruptAfter(ASK).releaseThread(true).build()); } catch(GraphStateException e){throw new IllegalStateException("Unable to compile MENU workflow",e);} } + public MenuWorkflow(MenuPlanner planner, CampusToolAdapter adapter, LocationCandidateSelector selector) { + this(planner, adapter, selector, new MemorySaver()); + } + public WorkflowResult handle(UUID sessionId, ChatCaller caller,List history,String message){ RunnableConfig c=config(sessionId); var checkpoint=graph.lastStateOf(c); State state; if(checkpoint.isPresent()){assertOwner(checkpoint.get().state(),caller); state=run(GraphInput.resume(Map.of(INPUT,message)),c);} diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/PlaceReviewsWorkflow.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/PlaceReviewsWorkflow.java index 5f6a8331..24ed306c 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/PlaceReviewsWorkflow.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/PlaceReviewsWorkflow.java @@ -31,7 +31,9 @@ import org.bsc.langgraph4j.action.AsyncNodeAction; import org.bsc.langgraph4j.action.EdgeAction; import org.bsc.langgraph4j.action.NodeAction; +import org.bsc.langgraph4j.checkpoint.BaseCheckpointSaver; import org.bsc.langgraph4j.checkpoint.MemorySaver; +import devkor.com.teamcback.domain.chatbot.checkpoint.RedisCheckpointSaver; import org.bsc.langgraph4j.state.AgentState; import org.bsc.langgraph4j.state.Channel; import org.bsc.langgraph4j.state.Channels; @@ -50,13 +52,13 @@ public class PlaceReviewsWorkflow { private final PlaceReviewsPlanner planner; private final CampusToolAdapter adapter; private final LocationCandidateSelector selector; - private final MemorySaver saver = new MemorySaver(); + private final BaseCheckpointSaver saver; private final CompiledGraph graph; @Autowired public PlaceReviewsWorkflow(PlaceReviewsPlanner planner, CampusToolAdapter adapter, - LocationCandidateSelector selector) { - this.planner=planner; this.adapter=adapter; this.selector=selector; + LocationCandidateSelector selector, BaseCheckpointSaver saver) { + this.planner=planner; this.adapter=adapter; this.selector=selector; this.saver=saver; try { StateGraph s = new StateGraph<>(ReviewGraphState.SCHEMA, ReviewGraphState::new); s.addNode(SEARCH, AsyncNodeAction.node_async((NodeAction) this::search)); @@ -76,6 +78,11 @@ public PlaceReviewsWorkflow(PlaceReviewsPlanner planner, CampusToolAdapter adapt } catch (GraphStateException e) { throw new IllegalStateException("Unable to compile REVIEW workflow", e); } } + public PlaceReviewsWorkflow(PlaceReviewsPlanner planner, CampusToolAdapter adapter, + LocationCandidateSelector selector) { + this(planner, adapter, selector, new MemorySaver()); + } + public WorkflowResult handle(UUID sessionId, ChatCaller caller, List history, String message) { RunnableConfig config=config(sessionId); var checkpoint=graph.lastStateOf(config); ReviewGraphState state; diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/RoomCourseWorkflow.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/RoomCourseWorkflow.java index 4281715b..0a92608b 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/RoomCourseWorkflow.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/RoomCourseWorkflow.java @@ -1,10 +1,11 @@ package devkor.com.teamcback.domain.chatbot.service; import static org.bsc.langgraph4j.StateGraph.END; import static org.bsc.langgraph4j.StateGraph.START; -import devkor.com.teamcback.domain.chatbot.dto.*; import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; import devkor.com.teamcback.domain.chatbot.tool.CampusToolAdapter; import devkor.com.teamcback.domain.chatbot.tool.dto.*; import devkor.com.teamcback.domain.place.entity.PlaceType; import devkor.com.teamcback.global.exception.exception.GlobalException; import java.util.*; import org.bsc.langgraph4j.*; import org.bsc.langgraph4j.action.*; import org.bsc.langgraph4j.checkpoint.MemorySaver; import org.bsc.langgraph4j.state.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; +import devkor.com.teamcback.domain.chatbot.dto.*; import devkor.com.teamcback.domain.chatbot.gateway.LlmGateway; import devkor.com.teamcback.domain.chatbot.tool.CampusToolAdapter; import devkor.com.teamcback.domain.chatbot.tool.dto.*; import devkor.com.teamcback.domain.chatbot.checkpoint.RedisCheckpointSaver; import devkor.com.teamcback.domain.place.entity.PlaceType; import devkor.com.teamcback.global.exception.exception.GlobalException; import java.util.*; import org.bsc.langgraph4j.*; import org.bsc.langgraph4j.action.*; import org.bsc.langgraph4j.checkpoint.BaseCheckpointSaver; import org.bsc.langgraph4j.checkpoint.MemorySaver; import org.bsc.langgraph4j.state.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @Component @ConditionalOnProperty(prefix="chatbot",name="enabled",havingValue="true") public class RoomCourseWorkflow { private static final String SEARCH="search",DECIDE="decide",ASK="ask",SELECT="select",LOAD="load",FORMAT="format",OWNER="owner",QUERY="query",DAY="day",INPUT="input",CANDIDATES="candidates",ID="id",NAME="name",REPLY="reply",WAITING="waiting",DATA="data"; - private final RoomCoursePlanner planner;private final CampusToolAdapter adapter;private final LocationCandidateSelector selector;private final MemorySaver saver=new MemorySaver();private final CompiledGraph graph; - @Autowired public RoomCourseWorkflow(RoomCoursePlanner p,CampusToolAdapter a,LocationCandidateSelector s){planner=p;adapter=a;selector=s;try{StateGraph g=new StateGraph<>(State.SCHEMA,State::new);g.addNode(SEARCH,AsyncNodeAction.node_async((NodeAction)this::search));g.addNode(DECIDE,AsyncNodeAction.node_async((NodeAction)this::decide));g.addNode(ASK,AsyncNodeAction.node_async((NodeAction)this::ask));g.addNode(SELECT,AsyncNodeAction.node_async((NodeAction)this::select));g.addNode(LOAD,AsyncNodeAction.node_async((NodeAction)this::load));g.addNode(FORMAT,AsyncNodeAction.node_async((NodeAction)this::format));g.addEdge(START,SEARCH);g.addEdge(SEARCH,DECIDE);g.addConditionalEdges(DECIDE,AsyncEdgeAction.edge_async((EdgeAction)this::afterDecision),Map.of(ASK,ASK,LOAD,LOAD,END,END));g.addEdge(ASK,SELECT);g.addConditionalEdges(SELECT,AsyncEdgeAction.edge_async((EdgeAction)this::afterSelect),Map.of(ASK,ASK,LOAD,LOAD));g.addEdge(LOAD,FORMAT);g.addEdge(FORMAT,END);graph=g.compile(CompileConfig.builder().checkpointSaver(saver).interruptAfter(ASK).releaseThread(true).build());}catch(GraphStateException e){throw new IllegalStateException("Unable to compile ROOM_COURSE workflow",e);}} + private final RoomCoursePlanner planner;private final CampusToolAdapter adapter;private final LocationCandidateSelector selector;private final BaseCheckpointSaver saver;private final CompiledGraph graph; + @Autowired public RoomCourseWorkflow(RoomCoursePlanner p,CampusToolAdapter a,LocationCandidateSelector s,BaseCheckpointSaver saver){planner=p;adapter=a;selector=s;this.saver=saver;try{StateGraph g=new StateGraph<>(State.SCHEMA,State::new);g.addNode(SEARCH,AsyncNodeAction.node_async((NodeAction)this::search));g.addNode(DECIDE,AsyncNodeAction.node_async((NodeAction)this::decide));g.addNode(ASK,AsyncNodeAction.node_async((NodeAction)this::ask));g.addNode(SELECT,AsyncNodeAction.node_async((NodeAction)this::select));g.addNode(LOAD,AsyncNodeAction.node_async((NodeAction)this::load));g.addNode(FORMAT,AsyncNodeAction.node_async((NodeAction)this::format));g.addEdge(START,SEARCH);g.addEdge(SEARCH,DECIDE);g.addConditionalEdges(DECIDE,AsyncEdgeAction.edge_async((EdgeAction)this::afterDecision),Map.of(ASK,ASK,LOAD,LOAD,END,END));g.addEdge(ASK,SELECT);g.addConditionalEdges(SELECT,AsyncEdgeAction.edge_async((EdgeAction)this::afterSelect),Map.of(ASK,ASK,LOAD,LOAD));g.addEdge(LOAD,FORMAT);g.addEdge(FORMAT,END);graph=g.compile(CompileConfig.builder().checkpointSaver(saver).interruptAfter(ASK).releaseThread(true).build());}catch(GraphStateException e){throw new IllegalStateException("Unable to compile ROOM_COURSE workflow",e);}} + public RoomCourseWorkflow(RoomCoursePlanner p,CampusToolAdapter a,LocationCandidateSelector s){this(p,a,s,new MemorySaver());} public WorkflowResult handle(UUID id,ChatCaller c,List h,String m){RunnableConfig rc=config(id);var cp=graph.lastStateOf(rc);State st;if(cp.isPresent()){owner(cp.get().state(),c);st=run(GraphInput.resume(Map.of(INPUT,m)),rc);}else{RoomCoursePlan p=planner.plan(h,m);if(p==null||!p.isRoomCourse())return WorkflowResult.notHandled();st=run(GraphInput.args(Map.of(OWNER,c.key(),QUERY,p.roomQuery(),DAY,p.weekdayExpression()==null?"":p.weekdayExpression(),INPUT,m)),rc);}WorkflowResult r=new WorkflowResult(true,st.value(REPLY,""),!st.value(WAITING,"").isBlank());if(!r.waiting())release(rc);return r;} public boolean hasPending(UUID id,ChatCaller c){var x=graph.lastStateOf(config(id));if(x.isEmpty())return false;owner(x.get().state(),c);return true;}public void cancel(UUID id,ChatCaller c){var x=graph.lastStateOf(config(id));if(x.isPresent()){owner(x.get().state(),c);release(config(id));}} private Map search(State s){SearchCampusToolResult r=adapter.searchCampus(new SearchCampusToolRequest(s.value(QUERY,""),5));List> out=new ArrayList<>();if(r!=null&&r.candidates()!=null)for(SearchCampusItem i:r.candidates())if(i.locationType()==ToolLocationType.PLACE&&i.placeType()==PlaceType.CLASSROOM)out.add(Map.of(ID,String.valueOf(i.locationId()),NAME,i.name(),"buildingName",Objects.toString(i.buildingName(),""),"floor",Objects.toString(i.floor(),"")));return Map.of(CANDIDATES,out,REPLY,out.isEmpty()?"해당 강의실을 찾지 못했어요.":"");} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/checkpoint/RedisCheckpointSaverTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/checkpoint/RedisCheckpointSaverTest.java new file mode 100644 index 00000000..667cd5ad --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/checkpoint/RedisCheckpointSaverTest.java @@ -0,0 +1,69 @@ +package devkor.com.teamcback.domain.chatbot.checkpoint; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import devkor.com.teamcback.domain.chatbot.config.ChatbotProperties; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.bsc.langgraph4j.RunnableConfig; +import org.bsc.langgraph4j.checkpoint.Checkpoint; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +class RedisCheckpointSaverTest { + @Test + void persistsLoadsRefreshesTtlAndReleasesNamespacedCheckpoint() throws Exception { + StringRedisTemplate redis = mock(StringRedisTemplate.class); + ValueOperations values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + + AtomicReference stored = new AtomicReference<>(); + when(values.get(anyString())).thenAnswer(invocation -> stored.get()); + doAnswer(invocation -> { + stored.set(invocation.getArgument(1)); + return null; + }).when(values).set(anyString(), anyString(), eq(60L), eq(TimeUnit.MINUTES)); + ChatbotProperties properties = new ChatbotProperties(true, + new ChatbotProperties.Llm("test", "test", 100, 5), + new ChatbotProperties.Agent(6, 5, 60, 10), + new ChatbotProperties.Tools(new ChatbotProperties.Limits(5, 10), + new ChatbotProperties.Limits(10, 20), 7, + new ChatbotProperties.Limits(5, 10)), + new ChatbotProperties.RateLimit(30, 10, 5, "Asia/Seoul")); + RedisCheckpointSaver saver = new RedisCheckpointSaver(redis, new ObjectMapper(), properties); + RunnableConfig config = RunnableConfig.builder() + .threadId("menu:" + UUID.randomUUID()) + .build(); + Checkpoint checkpoint = Checkpoint.builder() + .id("checkpoint-1") + .state(Map.of("owner", "anonymous", "awaiting", "SELECT")) + .nodeId("ask") + .nextNodeId("select") + .build(); + + config = saver.put(config, checkpoint); + verify(values).set(eq("chatbot:checkpoint:" + config.threadId().orElseThrow()), + anyString(), eq(60L), eq(TimeUnit.MINUTES)); + + Checkpoint loaded = saver.get(config).orElseThrow(); + assertThat(loaded.getId()).isEqualTo("checkpoint-1"); + assertThat(loaded.getState()).containsEntry("owner", "anonymous"); + assertThat(loaded.getNodeId()).isEqualTo("ask"); + assertThat(loaded.getNextNodeId()).isEqualTo("select"); + verify(redis).expire(eq("chatbot:checkpoint:" + config.threadId().orElseThrow()), + eq(60L), eq(TimeUnit.MINUTES)); + + saver.release(config); + verify(redis).delete("chatbot:checkpoint:" + config.threadId().orElseThrow()); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java index 27f8bdf6..feec036d 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java @@ -74,7 +74,7 @@ void rejectsInvalidCoordinatesBeforeLlmCall() throws Exception { @Test void generatesSessionIdAndReturnsGatewayReplyWithoutAction() throws Exception { - when(chatOrchestrator.execute(anyString(), anyList(), anyString())) + when(chatOrchestrator.executeGeneral(anyString(), anyList(), anyString())) .thenReturn(new ChatOrchestrationResult("hello", List.of())); mockMvc.perform(post("/api/chatbot/messages").contentType(MediaType.APPLICATION_JSON) .content("{\"message\":\"hello\"}")) @@ -97,7 +97,7 @@ void returns429BeforeProviderCallWhenRateLimited() throws Exception { @Test void hidesProviderFailureDetails() throws Exception { - when(chatOrchestrator.execute(anyString(), anyList(), anyString())) + when(chatOrchestrator.executeGeneral(anyString(), anyList(), anyString())) .thenThrow(new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE)); mockMvc.perform(post("/api/chatbot/messages").contentType(MediaType.APPLICATION_JSON) .content("{\"message\":\"hello\"}")) diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java index 2b961319..dc37160f 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java @@ -147,7 +147,7 @@ void mapsProviderFailureWithoutExposingProviderDetails() { } @Test - void sendsHistoryAsOrderedRoleMessagesAndRegistersRequestLocalTools() { + void sendsHistoryAsOrderedRoleMessagesWithoutCampusTools() { ChatClient chatClient = mock(ChatClient.class); ChatClient.Builder builder = mock(ChatClient.Builder.class); ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class); @@ -159,7 +159,6 @@ void sendsHistoryAsOrderedRoleMessagesAndRegistersRequestLocalTools() { when(chatClient.prompt()).thenReturn(requestSpec); when(requestSpec.system("system")).thenReturn(requestSpec); when(requestSpec.messages(anyList())).thenReturn(requestSpec); - when(requestSpec.tools(requestTools)).thenReturn(requestSpec); when(requestSpec.call()).thenReturn(responseSpec); when(responseSpec.chatClientResponse()).thenReturn(new ChatClientResponse( new ChatResponse(List.of(new Generation(new AssistantMessage("done")))), java.util.Map.of())); @@ -179,52 +178,31 @@ void sendsHistoryAsOrderedRoleMessagesAndRegistersRequestLocalTools() { assertThat(messages.getValue().get(0)).isInstanceOf(UserMessage.class); assertThat(messages.getValue().get(1)).isInstanceOf(AssistantMessage.class); assertThat(messages.getValue().get(2)).isInstanceOf(UserMessage.class); - verify(requestSpec).tools(requestTools); + org.mockito.Mockito.verify(requestSpec, org.mockito.Mockito.never()).tools(any()); } @Test - void preservesRequestLocalToolStateWhenFinalModelFollowUpFails() { + void generalGenerationCannotAccessCampusTools() { ChatClient chatClient = mock(ChatClient.class); ChatClient.Builder builder = mock(ChatClient.Builder.class); ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class); ChatClient.CallResponseSpec responseSpec = mock(ChatClient.CallResponseSpec.class); - CampusToolAdapter adapter = mock(CampusToolAdapter.class); - ChatbotProperties properties = properties(); - ChatbotToolCallLimiter limiter = new ChatbotToolCallLimiter(properties); - CampusChatbotTools baseTools = new CampusChatbotTools(adapter, limiter); - AtomicReference requestTools = new AtomicReference<>(); - SearchCampusToolRequest startRequest = new SearchCampusToolRequest("start", 1, - SearchCampusRole.START, SearchCampusIntent.NAVIGATE_ROUTE, List.of()); - SearchCampusToolRequest endRequest = new SearchCampusToolRequest("end", 1, - SearchCampusRole.END, SearchCampusIntent.NAVIGATE_ROUTE, List.of()); when(builder.build()).thenReturn(chatClient); when(chatClient.prompt()).thenReturn(requestSpec); when(requestSpec.system("system")).thenReturn(requestSpec); when(requestSpec.messages(anyList())).thenReturn(requestSpec); - when(requestSpec.tools(any(CampusChatbotTools.class))).thenAnswer(invocation -> { - requestTools.set(invocation.getArgument(0)); - return requestSpec; - }); when(requestSpec.call()).thenReturn(responseSpec); - when(adapter.searchCampus(startRequest)).thenReturn(resolved(11L, "start")); - when(adapter.searchCampus(endRequest)).thenReturn(resolved(22L, "end")); - when(responseSpec.chatClientResponse()).thenAnswer(invocation -> { - requestTools.get().searchCampus(startRequest); - requestTools.get().searchCampus(endRequest); - return new ChatClientResponse( - new ChatResponse(List.of(new Generation(new AssistantMessage("")))), java.util.Map.of()); - }); - SpringAiLlmGateway gateway = new SpringAiLlmGateway(builder, properties, executor, baseTools, limiter); + when(responseSpec.chatClientResponse()).thenReturn(new ChatClientResponse( + new ChatResponse(List.of(new Generation(new AssistantMessage("done")))), java.util.Map.of())); + SpringAiLlmGateway gateway = new SpringAiLlmGateway(builder, properties(), executor, + mock(CampusChatbotTools.class), new ChatbotToolCallLimiter(properties())); ResolvedLocationCollector executionState = new ResolvedLocationCollector(); - LlmGateway.LlmResult result = gateway.generate("system", List.of(), "route request", executionState); - - assertThat(result.reply()).isNull(); - assertThat(result.completionStatus()) - .isEqualTo(LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION); - assertThat(executionState.snapshot()).hasSize(2); - assertThat(executionState.snapshot()).extracting(location -> location.id()) - .containsExactly(11L, 22L); + LlmGateway.LlmResult result = gateway.generate("system", List.of(), "hello", executionState); + + assertThat(result.reply()).isEqualTo("done"); + assertThat(executionState.snapshot()).isEmpty(); + org.mockito.Mockito.verify(requestSpec, org.mockito.Mockito.never()).tools(any()); } private SearchCampusToolResult resolved(long id, String name) { diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java index cd65ce9b..dd96416e 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java @@ -68,7 +68,7 @@ void doesNotAssembleActionForAmbiguousOrTextRouteTrace() { ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); when(memoryService.load(sessionId, caller)).thenReturn(List.of()); - var response = service.sendMessage(new ChatMessageReq(sessionId, "몇 분 걸려?", null), caller); + var response = service.sendMessage(new ChatMessageReq(sessionId, "text route", null), caller); assertThat(response.action()).isNull(); assertThat(response.reply()).isEqualTo("text route"); diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceCurrentLocationTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceCurrentLocationTest.java index d902f23e..9830bf04 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceCurrentLocationTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceCurrentLocationTest.java @@ -28,6 +28,8 @@ class ChatServiceCurrentLocationTest { void passesCurrentLocationOnlyInCurrentProviderRequestAndNeverSavesIt() { when(chatOrchestrator.execute(anyString(), anyList(), anyString())) .thenReturn(new ChatOrchestrationResult("route answer", List.of())); + when(chatOrchestrator.executeGeneral(anyString(), anyList(), anyString())) + .thenReturn(new ChatOrchestrationResult("route answer", List.of())); ChatService service = new ChatService(chatOrchestrator, memoryService, rateLimiter); UUID sessionId = UUID.randomUUID(); ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); @@ -38,9 +40,11 @@ void passesCurrentLocationOnlyInCurrentProviderRequestAndNeverSavesIt() { service.sendMessage(new ChatMessageReq(sessionId, "tell me again", null), caller); ArgumentCaptor messages = ArgumentCaptor.forClass(String.class); - verify(chatOrchestrator, org.mockito.Mockito.times(2)).execute(anyString(), anyList(), messages.capture()); + verify(chatOrchestrator).execute(anyString(), anyList(), messages.capture()); assertThat(messages.getAllValues().get(0)).contains("latitude=37.5861", "longitude=127.029"); - assertThat(messages.getAllValues().get(1)).isEqualTo("tell me again") + ArgumentCaptor generalMessage = ArgumentCaptor.forClass(String.class); + verify(chatOrchestrator).executeGeneral(anyString(), anyList(), generalMessage.capture()); + assertThat(generalMessage.getValue()).isEqualTo("tell me again") .doesNotContain("37.5861", "127.029", "currentLocation"); verify(memoryService).save(sessionId, caller, "route from current location", "route answer"); verify(memoryService).save(sessionId, caller, "tell me again", "route answer"); @@ -48,7 +52,7 @@ void passesCurrentLocationOnlyInCurrentProviderRequestAndNeverSavesIt() { @Test void preservesRecentRolesAndKeepsCurrentCorrectionAsLatestUserMessage() { - when(chatOrchestrator.execute(anyString(), anyList(), anyString())) + when(chatOrchestrator.executeGeneral(anyString(), anyList(), anyString())) .thenReturn(new ChatOrchestrationResult("answer", List.of())); ChatService service = new ChatService(chatOrchestrator, memoryService, rateLimiter); UUID sessionId = UUID.randomUUID(); @@ -61,7 +65,7 @@ void preservesRecentRolesAndKeepsCurrentCorrectionAsLatestUserMessage() { @SuppressWarnings("unchecked") ArgumentCaptor> history = ArgumentCaptor.forClass(List.class); ArgumentCaptor currentMessage = ArgumentCaptor.forClass(String.class); - verify(chatOrchestrator).execute(anyString(), history.capture(), currentMessage.capture()); + verify(chatOrchestrator).executeGeneral(anyString(), history.capture(), currentMessage.capture()); assertThat(history.getValue()).containsExactly( new LlmGateway.ConversationMessage(LlmGateway.Role.USER, "old user"), new LlmGateway.ConversationMessage(LlmGateway.Role.ASSISTANT, "old assistant")); diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServicePromptTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServicePromptTest.java index 301c847c..f7074eb6 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServicePromptTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServicePromptTest.java @@ -6,18 +6,16 @@ class ChatServicePromptTest { @Test - void separatesUiActionAndTextRouteOrchestration() { + void keepsGeneralPromptMinimalAndGrounded() { assertThat(ChatService.SYSTEM_PROMPT) - .contains("[ROUTE_BEHAVIOR]", "intent=NAVIGATE_ROUTE", "do not call findRoute") - .contains("intent=TEXT_ROUTE", "then findRoute") - .contains("backend creates", "actual Tool results") - .contains("never invent", "ambiguous", "PENDING_ROUTE_CONTINUATION", "never copy"); + .contains("general conversation", "Dynamic campus facts", "do not guess") + .doesNotContain("searchCampus", "findRoute", "Tool", "ROUTE_BEHAVIOR"); } @Test - void routeContractKeepsAllowedRolesAndIntentsExplicit() { + void generalPromptDoesNotExposeWorkflowOrchestration() { assertThat(ChatService.SYSTEM_PROMPT) - .contains("role=START", "role=END", "START/END", "NAVIGATE_ROUTE/TEXT_ROUTE") - .contains("BARRIERFREE", "six-call limit", "latest user"); + .doesNotContain("START", "END", "NAVIGATE_ROUTE", "TEXT_ROUTE", "six-call limit") + .contains("private data", "internal identifiers"); } } diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceRoutingTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceRoutingTest.java index 6553c48b..bb47814f 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceRoutingTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceRoutingTest.java @@ -27,6 +27,7 @@ class ChatServiceRoutingTest { @Mock ChatRateLimiter rateLimiter; @Mock PendingRouteStateService pendingRouteStateService; @Mock CrowdStatusWorkflow crowdStatusWorkflow; + @Mock MenuWorkflow menuWorkflow; private final UUID sessionId = UUID.randomUUID(); private final ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); @@ -37,6 +38,8 @@ void setUp() { when(pendingRouteStateService.load(sessionId, caller)).thenReturn(Optional.empty()); lenient().when(chatOrchestrator.execute(anyString(), anyList(), anyString())) .thenReturn(new ChatOrchestrationResult("general", List.of())); + lenient().when(chatOrchestrator.executeGeneral(anyString(), anyList(), anyString())) + .thenReturn(new ChatOrchestrationResult("general", List.of())); } @Test @@ -55,12 +58,16 @@ void crowdPendingCandidateReplyContinuesCrowd() { @Test void crowdPendingMenuMessageCancelsCrowdAndUsesGeneralPath() { when(crowdStatusWorkflow.hasPending(sessionId, caller)).thenReturn(true); + when(menuWorkflow.handle(any(), any(), anyList(), anyString())) + .thenReturn(new MenuWorkflow.WorkflowResult(true, "menu", false)); newService().sendMessage(new ChatMessageReq(sessionId, "오늘 학식 뭐야", null), caller); verify(crowdStatusWorkflow).cancel(sessionId, caller); verify(crowdStatusWorkflow, never()).handle(any(), any(), anyList(), anyString()); - verify(chatOrchestrator).execute(anyString(), anyList(), anyString()); + verify(menuWorkflow).handle(any(), any(), anyList(), anyString()); + verify(chatOrchestrator, never()).execute(anyString(), anyList(), anyString()); + verify(chatOrchestrator, never()).executeGeneral(anyString(), anyList(), anyString()); } @Test @@ -99,11 +106,12 @@ void noPendingGreetingUsesGeneralPath() { newService().sendMessage(new ChatMessageReq(sessionId, "안녕", null), caller); verify(crowdStatusWorkflow, never()).handle(any(), any(), anyList(), anyString()); - verify(chatOrchestrator).execute(anyString(), anyList(), anyString()); + verify(chatOrchestrator).executeGeneral(anyString(), anyList(), anyString()); } private ChatService newService() { return new ChatService(chatOrchestrator, memoryService, rateLimiter, - pendingRouteStateService, crowdStatusWorkflow, new ChatRequestRouter()); + pendingRouteStateService, crowdStatusWorkflow, new ChatRequestRouter(), + null, null, null, menuWorkflow, null, null); } } From b3cfcb2a1e4b0d7e8048da7290b48259cca67b4d Mon Sep 17 00:00:00 2001 From: Jokebear777 Date: Wed, 30 Sep 2026 19:27:01 +0900 Subject: [PATCH 21/21] fix(chatbot): improve intent routing and workflow error handling --- .../chatbot/service/ChatRequestRouter.java | 6 ++--- .../domain/chatbot/service/ChatService.java | 5 ++++ .../domain/chatbot/service/WorkflowDates.java | 22 +++++++++++++--- src/main/resources/application.yml | 6 ++--- .../service/ChatRequestRouterTest.java | 18 +++++++++++++ .../service/ChatServiceActionTest.java | 2 +- .../service/ChatServiceRoutingTest.java | 16 ++++++++++++ .../chatbot/service/WorkflowDatesTest.java | 26 +++++++++++++++++++ 8 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 src/test/java/devkor/com/teamcback/domain/chatbot/service/WorkflowDatesTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java index 48872a3a..735b7cf4 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java @@ -45,10 +45,10 @@ private Intent detectObviousIntent(String message) { if (normalized.isBlank()) { return Intent.NONE; } - if (normalized.contains("route") || normalized.contains("분")) { + if (normalized.contains("route")) { return Intent.ROUTE; } - if (containsAny(normalized, "길찾", "가는길", "경로", "어디로가")) { + if (containsAny(normalized, "길찾", "가는길", "경로", "어디로가", "길알려줘", "어떻게가", "길안내", "도보로")) { return Intent.ROUTE; } if (containsAny(normalized, "학식", "식단")) { @@ -66,7 +66,7 @@ private Intent detectObviousIntent(String message) { if (containsAny(normalized, "화장실", "프린터", "시설", "자판기", "정수기")) { return Intent.FACILITY; } - if (containsAny(normalized, "방학", "학기", "고연전")) { + if (containsAny(normalized, "방학", "학기", "고연전", "학교상태", "학교현황", "오늘학교", "지금학교", "캠퍼스상태")) { return Intent.CAMPUS_STATUS; } if (containsAny(normalized, "어디야", "어디있", "열려", "운영시간", "몇시", "닫", "정보")) { diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java index f4a082ad..f53f3197 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java @@ -171,6 +171,11 @@ public ChatMessageRes sendMessage(ChatMessageReq request, ChatCaller caller) { RoomCourseWorkflow.WorkflowResult room = roomCourseWorkflow.handle(sessionId, caller, toGatewayHistory(history), request.message()); if (room.handled()) return saveWorkflowReply(sessionId, caller, request.message(), room.reply()); } + if (routing.workflowType() != ChatRequestRouter.WorkflowType.GENERAL_CHAT + && routing.workflowType() != ChatRequestRouter.WorkflowType.ROUTE) { + return saveWorkflowReply(sessionId, caller, request.message(), + "캠퍼스 정보를 확인하지 못했어요. 장소나 요청 내용을 조금 더 구체적으로 알려주세요."); + } if (routing.route() == ChatRequestRouter.Route.CONTINUE_PENDING && routing.workflowType() == ChatRequestRouter.WorkflowType.ROUTE) { pending = loadPending(sessionId, caller); diff --git a/src/main/java/devkor/com/teamcback/domain/chatbot/service/WorkflowDates.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/WorkflowDates.java index ebddd0d5..c7d5ca78 100644 --- a/src/main/java/devkor/com/teamcback/domain/chatbot/service/WorkflowDates.java +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/WorkflowDates.java @@ -1,14 +1,19 @@ package devkor.com.teamcback.domain.chatbot.service; import java.time.DayOfWeek; +import java.time.DateTimeException; import java.time.LocalDate; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; final class WorkflowDates { private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + private static final Pattern KOREAN_MONTH_DAY = Pattern.compile("^(\\d{1,2})월\\s*(\\d{1,2})일$"); + private static final Pattern SLASH_MONTH_DAY = Pattern.compile("^(\\d{1,2})/(\\d{1,2})$"); private WorkflowDates() {} static LocalDate menuDate(String expression) { LocalDate today = LocalDate.now(SEOUL); @@ -22,9 +27,20 @@ static LocalDate menuDate(String expression) { case "일", "sun", "sunday" -> DayOfWeek.SUNDAY; default -> null; }; if (day != null) return today.plusDays((day.getValue() - today.getDayOfWeek().getValue() + 7) % 7); - for (DateTimeFormatter format : new DateTimeFormatter[]{DateTimeFormatter.ISO_LOCAL_DATE, DateTimeFormatter.ofPattern("M월 d일"), DateTimeFormatter.ofPattern("M/d")}) { - try { LocalDate parsed = LocalDate.parse(value, format); return parsed.withYear(today.getYear()); } - catch (DateTimeParseException ignored) { } + try { + return LocalDate.parse(value, DateTimeFormatter.ISO_LOCAL_DATE); + } catch (DateTimeParseException ignored) { + Matcher korean = KOREAN_MONTH_DAY.matcher(value); + Matcher slash = SLASH_MONTH_DAY.matcher(value); + Matcher matcher = korean.matches() ? korean : slash; + if (matcher.matches()) { + try { + return LocalDate.of(today.getYear(), Integer.parseInt(matcher.group(1)), + Integer.parseInt(matcher.group(2))); + } catch (DateTimeException invalidDate) { + throw new IllegalArgumentException("invalid date expression", invalidDate); + } + } } throw new IllegalArgumentException("invalid date expression"); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8f4379a2..86ca0f85 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -196,10 +196,8 @@ chatbot: max-limit: ${CHATBOT_REVIEWS_MAX_LIMIT:10} rate-limit: authenticated-daily-limit: ${CHATBOT_AUTHENTICATED_DAILY_LIMIT:30} - anonymous-daily-limit: ${CHATBOT_ANONYMOUS_DAILY_LIMIT:100} - burst-per-minute: ${CHATBOT_BURST_PER_MINUTE:50} - #anonymous-daily-limit: ${CHATBOT_ANONYMOUS_DAILY_LIMIT:10} - #burst-per-minute: ${CHATBOT_BURST_PER_MINUTE:5} + anonymous-daily-limit: ${CHATBOT_ANONYMOUS_DAILY_LIMIT:10} + burst-per-minute: ${CHATBOT_BURST_PER_MINUTE:5} daily-reset-zone: ${CHATBOT_DAILY_RESET_ZONE:Asia/Seoul} push: diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouterTest.java index a0a220ec..4614e64a 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouterTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouterTest.java @@ -58,4 +58,22 @@ void startsExistingCrowdWorkflowForNewCrowdIntent() { assertThat(decision.route()).isEqualTo(Route.NEW_INTENT); assertThat(decision.workflowType()).isEqualTo(WorkflowType.CROWD); } + + @Test + void routesNaturalLanguageDirectionsToRouteWorkflow() { + assertThat(router.route(PendingWorkflow.NONE, "중앙도서관에서 미래관까지 길 알려줘").workflowType()) + .isEqualTo(WorkflowType.ROUTE); + assertThat(router.route(PendingWorkflow.NONE, "중앙도서관에서 미래관까지 어떻게 가").workflowType()) + .isEqualTo(WorkflowType.ROUTE); + assertThat(router.route(PendingWorkflow.NONE, "길안내 창 띄워줘").workflowType()) + .isEqualTo(WorkflowType.ROUTE); + } + + @Test + void routesCampusStatusRequestToCampusStatusWorkflow() { + var decision = router.route(PendingWorkflow.NONE, "오늘 학교 상태"); + + assertThat(decision.route()).isEqualTo(Route.NEW_INTENT); + assertThat(decision.workflowType()).isEqualTo(WorkflowType.CAMPUS_STATUS); + } } diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java index dd96416e..e8dfe1c8 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java @@ -267,7 +267,7 @@ void returnsDeterministicTextRouteReplyWhenFinalCompletionIsBlank() { ChatCaller caller = ChatCaller.from(null, "127.0.0.1"); when(memoryService.load(sessionId, caller)).thenReturn(List.of()); - var response = service.sendMessage(new ChatMessageReq(sessionId, "몇 분 걸려?", null), caller); + var response = service.sendMessage(new ChatMessageReq(sessionId, "경로 몇 분 걸려?", null), caller); assertThat(response.action()).isNull(); assertThat(response.reply()).isEqualTo("start에서 end까지 경로를 찾았어요. 예상 소요 시간은 약 2분이에요. 길찾기 화면으로 안내할까요?"); diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceRoutingTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceRoutingTest.java index bb47814f..b49c3205 100644 --- a/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceRoutingTest.java +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceRoutingTest.java @@ -109,6 +109,22 @@ void noPendingGreetingUsesGeneralPath() { verify(chatOrchestrator).executeGeneral(anyString(), anyList(), anyString()); } + @Test + void factualWorkflowFailureDoesNotFallBackToGeneralLlm() { + when(crowdStatusWorkflow.hasPending(sessionId, caller)).thenReturn(false); + when(menuWorkflow.handle(any(), any(), anyList(), anyString())) + .thenReturn(new MenuWorkflow.WorkflowResult(false, null, false)); + + var response = newService().sendMessage( + new ChatMessageReq(sessionId, "오늘 학생회관 학식", null), caller); + + org.assertj.core.api.Assertions.assertThat(response.reply()) + .contains("캠퍼스 정보를 확인하지 못했어요"); + verify(menuWorkflow).handle(any(), any(), anyList(), anyString()); + verify(chatOrchestrator, never()).execute(anyString(), anyList(), anyString()); + verify(chatOrchestrator, never()).executeGeneral(anyString(), anyList(), anyString()); + } + private ChatService newService() { return new ChatService(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService, crowdStatusWorkflow, new ChatRequestRouter(), diff --git a/src/test/java/devkor/com/teamcback/domain/chatbot/service/WorkflowDatesTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/WorkflowDatesTest.java new file mode 100644 index 00000000..29aa627b --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/WorkflowDatesTest.java @@ -0,0 +1,26 @@ +package devkor.com.teamcback.domain.chatbot.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDate; +import java.time.ZoneId; +import org.junit.jupiter.api.Test; + +class WorkflowDatesTest { + private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + + @Test + void parsesYearlessKoreanDateInSeoulCurrentYear() { + LocalDate expected = LocalDate.of(LocalDate.now(SEOUL).getYear(), 9, 30); + + assertThat(WorkflowDates.menuDate("9월 30일")).isEqualTo(expected); + assertThat(WorkflowDates.menuDate("9/30")).isEqualTo(expected); + } + + @Test + void rejectsInvalidYearlessDate() { + assertThatThrownBy(() -> WorkflowDates.menuDate("2월 30일")) + .isInstanceOf(IllegalArgumentException.class); + } +}