diff --git a/build.gradle b/build.gradle index 5d7ba492..c0354a7c 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,12 @@ dependencies { // web implementation 'org.springframework.boot:spring-boot-starter-web' + // 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' @@ -132,6 +139,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/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/building/repository/BuildingNicknameRepository.java b/src/main/java/devkor/com/teamcback/domain/building/repository/BuildingNicknameRepository.java index 01c29ae5..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 @@ -2,15 +2,34 @@ 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; +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 { + @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); + + @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/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/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..afd263f8 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/config/ChatbotProperties.java @@ -0,0 +1,27 @@ +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, Tools tools, RateLimit rateLimit) { + 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, + int pendingRouteTtlMinutes) { + } + + public record Tools(Limits search, Limits facilities, int menuMaxDays, Limits reviews) { + } + + 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/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/controller/ChatController.java b/src/main/java/devkor/com/teamcback/domain/chatbot/controller/ChatController.java new file mode 100644 index 00000000..f5574f5d --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/controller/ChatController.java @@ -0,0 +1,33 @@ +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.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; +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; +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +@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, + @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/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/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/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/CrowdCandidateSelection.java b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdCandidateSelection.java new file mode 100644 index 00000000..3bdc35fa --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/CrowdCandidateSelection.java @@ -0,0 +1,13 @@ +package devkor.com.teamcback.domain.chatbot.dto; + +/** Structured, provider-neutral interpretation of a crowd place selection. */ +public record CrowdCandidateSelection(Status status, Integer candidateIndex) implements CandidateSelection { + public CrowdCandidateSelection { + status = status == null ? Status.NONE : status; + } + + public static CrowdCandidateSelection none() { + return new CrowdCandidateSelection(Status.NONE, null); + } + +} 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..80584de6 --- /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) implements CandidateView { + 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/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/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/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/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/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/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/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/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/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/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/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/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..3f69cac9 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/dto/response/ChatMessageRes.java @@ -0,0 +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; + +@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 new file mode 100644 index 00000000..efdf212d --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/LlmGateway.java @@ -0,0 +1,76 @@ +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.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.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; + +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 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(); + } + 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 MenuPlan planMenu(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, + List candidates) { + return LocationCandidateSelection.none(); + } + + LlmResult generate(String systemPrompt, List history, String userMessage, + ResolvedLocationCollector executionState); + + record LlmResult(String reply, CompletionStatus completionStatus) { + public LlmResult(String reply) { + this(reply, CompletionStatus.COMPLETE); + } + + public LlmResult { + completionStatus = completionStatus == null ? CompletionStatus.COMPLETE : completionStatus; + } + } + + 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 new file mode 100644 index 00000000..dd6dc405 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGateway.java @@ -0,0 +1,686 @@ +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.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.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.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.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.messages.ToolResponseMessage; +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; +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; + /** 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) { + this.chatClient = chatClientBuilder.build(); + this.properties = properties; + this.chatbotLlmExecutor = chatbotLlmExecutor; + } + + @Deprecated + public SpringAiLlmGateway(ChatClient.Builder chatClientBuilder, ChatbotProperties properties, + ExecutorService chatbotLlmExecutor, Object ignoredTools, Object ignoredLimiter) { + this(chatClientBuilder, properties, chatbotLlmExecutor); + } + + @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 CrowdCandidateSelection selectCrowdCandidate(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( + () -> 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 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 MenuPlan planMenu(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, + 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) { + Future response = chatbotLlmExecutor.submit( + () -> 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", + result.outcome(), properties.llm().provider(), properties.llm().model(), result.latencyMillis(), + result.toolCalls()); + return result.result(); + } catch (InterruptedException exception) { + response.cancel(true); + Thread.currentThread().interrupt(); + logFailure("TEMPORARILY_UNAVAILABLE", "ASYNC_WAIT", exception, false); + throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); + } catch (TimeoutException exception) { + response.cancel(true); + 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); + } catch (ExecutionException exception) { + response.cancel(true); + logFailure("TEMPORARILY_UNAVAILABLE", failureStage(exception), exception, false); + throw new GlobalException(CHATBOT_TEMPORARILY_UNAVAILABLE); + } + } + + private GatewayResult invoke(String systemPrompt, List history, String userMessage, + ResolvedLocationCollector collector) { + long startedAt = System.nanoTime(); + try { + List springMessages = toSpringMessages(history, userMessage); + logRequestMessageDiagnostics(springMessages); + ChatClient.ChatClientRequestSpec request = chatClient.prompt() + .system(systemPrompt) + .messages(springMessages); + 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); + throw new EmptyLlmCompletionException(); + } + return new GatewayResult(new LlmGateway.LlmResult(content), "SUCCESS", + elapsedMillis(startedAt), 0); + } catch (RuntimeException exception) { + throw new StagedLlmInvocationException("MODEL_COMPLETION", exception); + } + } + + private void logEmptyCompletionDiagnostics(ChatResponse response) { + 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=[] recordedToolActivity=false"); + 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=[] recordedToolActivity=false", + generations.size(), outputCount, assistantCount, blankTextCount, toolNames.size(), toolNames, + !toolNames.isEmpty(), finishReasons, + response.getMetadata() == null ? "unavailable" : response.getMetadata().getClass().getName()); + } + + /** 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 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 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) { + 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(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/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..684617bd --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotCampusSearchService.java @@ -0,0 +1,182 @@ +package devkor.com.teamcback.domain.chatbot.search; + +import devkor.com.teamcback.domain.building.repository.BuildingNicknameRepository; +import devkor.com.teamcback.domain.chatbot.tool.dto.ToolLocationType; +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); + } + } + 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, + 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, true); + addPlaceCandidates(target, findPlaces(compact(placeWord), buildingIds, limit), priority - 1, true); + } + + 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) { + 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.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.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/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..a6b5c882 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/search/ChatbotSearchCandidate.java @@ -0,0 +1,11 @@ +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, + String matchedNickname, boolean compositeQuery) { +} 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/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/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..f244a85a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestrator.java @@ -0,0 +1,148 @@ +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; + +/** 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; + 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); + 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()); + } + + /** General conversation path: no route planner and no campus capability execution. */ + public ChatOrchestrationResult executeGeneral(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; + 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; + } + 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("까지 경로를 찾았어요."); + 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/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/ChatRequestRouter.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java new file mode 100644 index 00000000..735b7cf4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouter.java @@ -0,0 +1,179 @@ +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) + || (pendingWorkflow == PendingWorkflow.LOCATION_DETAIL && intent == Intent.LOCATION_DETAIL) + || (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) { + String normalized = normalize(message); + if (normalized.isBlank()) { + return Intent.NONE; + } + if (normalized.contains("route")) { + return Intent.ROUTE; + } + 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, MENU, FACILITY, ROOM_COURSE, + ROUTE, + CROWD, + LOCATION_DETAIL, + REVIEW; + + private WorkflowType toWorkflowType() { + return switch (this) { + case CROWD -> WorkflowType.CROWD; + 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; + }; + } + } + + 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 new file mode 100644 index 00000000..f53f3197 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatService.java @@ -0,0 +1,449 @@ +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; + +@Service +@ConditionalOnProperty(prefix = "chatbot", name = "enabled", havingValue = "true") +public class ChatService { + static final String SYSTEM_PROMPT = """ + You are a concise Korean Korea University campus assistant. + 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; + private final ChatSessionMemoryService memoryService; + private final ChatRateLimiter rateLimiter; + private final PendingRouteStateService pendingRouteStateService; + private final CrowdStatusWorkflow crowdStatusWorkflow; + private final ChatRequestRouter requestRouter; + 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, 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, null, null, null); + } + + /** 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(), null, null, null, null, null, null); + } + + @Autowired + public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService memoryService, + ChatRateLimiter rateLimiter, + PendingRouteStateService pendingRouteStateService, + CrowdStatusWorkflow crowdStatusWorkflow, + ChatRequestRouter requestRouter, + LocationDetailWorkflow locationDetailWorkflow, + PlaceReviewsWorkflow placeReviewsWorkflow, + CampusStatusWorkflow campusStatusWorkflow, + MenuWorkflow menuWorkflow, FacilityWorkflow facilityWorkflow, + RoomCourseWorkflow roomCourseWorkflow) { + 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; + 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. */ + public ChatService(ChatOrchestrator chatOrchestrator, ChatSessionMemoryService memoryService, + ChatRateLimiter rateLimiter, PendingRouteStateService pendingRouteStateService, + CrowdStatusWorkflow crowdStatusWorkflow, ChatRequestRouter requestRouter) { + this(chatOrchestrator, memoryService, rateLimiter, pendingRouteStateService, crowdStatusWorkflow, + requestRouter, null, null, null, null, null, null); + } + + 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); + 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()) { + String reply = crowd.reply() == null || crowd.reply().isBlank() + ? "혼잡도 요청을 처리하지 못했어요." : crowd.reply(); + memoryService.save(sessionId, caller, request.message(), reply); + 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 (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.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); + } else { + pending = null; + } + 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) { + deletePending(sessionId, caller); + } else { + savePendingIfRouteIsIncomplete(sessionId, caller, pending, result.searchResolutions()); + } + boolean completionFailed = 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, action); + } + + private ChatRequestRouter.PendingWorkflow activePendingWorkflow(UUID sessionId, ChatCaller caller, + PendingRouteState pendingRoute) { + 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; + } + 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; + } + + private void releaseSupersededWorkflows(UUID sessionId, ChatCaller caller, + PendingRouteState pendingRoute) { + 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 (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); + } + } + + private PendingRouteState loadPending(UUID sessionId, ChatCaller caller) { + return pendingRouteStateService == null ? null + : 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); + } + } + + 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=").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"); + } + 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("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 the pending route intent; 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 + || trace.intent() == SearchCampusIntent.TEXT_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(); + } + 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(interaction, 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 + || trace.intent() == SearchCampusIntent.TEXT_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; + } + CurrentLocationReq location = request.context().currentLocation(); + 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]"; + } +} 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..a361a8e6 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ChatbotToolCallLimiter.java @@ -0,0 +1,55 @@ +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; + + public ChatbotToolCallLimiter(ChatbotProperties properties) { + this.properties = properties; + } + + public Scope open() { + return new Scope(new ScopeState(properties.agent().maxToolCallsPerMessage())); + } + + public final class Scope implements AutoCloseable { + private final ScopeState scope; + + private Scope(ScopeState scope) { + this.scope = scope; + } + + public int callCount() { + return scope.callCount; + } + + public void beforeToolCall() { + scope.increment(); + } + + @Override + public void close() { + } + } + + 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/CrowdCandidateSelector.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdCandidateSelector.java new file mode 100644 index 00000000..58f5721f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdCandidateSelector.java @@ -0,0 +1,59 @@ +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 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. + 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; + } + + @Override + 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/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..fd439fa5 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflow.java @@ -0,0 +1,475 @@ +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.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; +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.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; +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 used only by test-compatibility constructors; production wiring uses RedisCheckpointSaver. + */ +@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 CrowdCandidateSelector candidateSelector; + private final BaseCheckpointSaver saver; + private final CompiledGraph graph; + + CrowdStatusWorkflow(CrowdStatusPlanner planner, ChatbotCampusSearchService searchService, + CampusToolAdapter campusToolAdapter) { + this(planner, searchService, campusToolAdapter, null, null, new MemorySaver()); + } + + public CrowdStatusWorkflow(CrowdStatusPlanner planner, ChatbotCampusSearchService searchService, + CampusToolAdapter campusToolAdapter, CrowdTargetResolver targetResolver) { + this(planner, searchService, campusToolAdapter, targetResolver, null, new MemorySaver()); + } + + @Autowired + public CrowdStatusWorkflow(CrowdStatusPlanner planner, ChatbotCampusSearchService searchService, + CampusToolAdapter campusToolAdapter, CrowdTargetResolver targetResolver, + CrowdCandidateSelector candidateSelector, BaseCheckpointSaver saver) { + this.planner = planner; + this.searchService = searchService; + this.campusToolAdapter = campusToolAdapter; + this.targetResolver = targetResolver; + this.candidateSelector = candidateSelector; + this.saver = saver; + try { + 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); + } + } + + 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("crowd:" + sessionId).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; + } + + /** Returns whether this session currently owns an interrupted Crowd interaction. */ + public boolean hasPending(UUID sessionId, ChatCaller caller) { + RunnableConfig config = RunnableConfig.builder().threadId("crowd:" + sessionId).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("crowd:" + sessionId).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 { + 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) { + 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) { + 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, ""))) { + 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, + 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/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..f7b0debb --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/FacilityWorkflow.java @@ -0,0 +1,24 @@ +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.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 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()?"":"");} + 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/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..e2a9e0d7 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/LocationDetailWorkflow.java @@ -0,0 +1,263 @@ +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.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; +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 BaseCheckpointSaver saver; + private final CompiledGraph graph; + + @Autowired + public LocationDetailWorkflow(LocationDetailPlanner planner, CampusToolAdapter adapter, + 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)); + 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 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); + 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/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..12405f83 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/MenuWorkflow.java @@ -0,0 +1,65 @@ +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.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; +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 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)); + 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 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);} + 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/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/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..24ed306c --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/PlaceReviewsWorkflow.java @@ -0,0 +1,128 @@ +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.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; +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 BaseCheckpointSaver saver; + private final CompiledGraph graph; + + @Autowired + public PlaceReviewsWorkflow(PlaceReviewsPlanner planner, CampusToolAdapter adapter, + 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)); + 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 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; + 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/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..7fddffa6 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/ResolvedLocationCollector.java @@ -0,0 +1,162 @@ +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.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; +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<>()); + private volatile RouteExecutionTrace routeExecution; + + 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 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; + } + + 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() { + 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, + intent); + ResolvedLocation end = unique(devkor.com.teamcback.domain.chatbot.dto.ResolvedLocation.EndpointRole.END, + intent); + 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/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..0a92608b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/RoomCourseWorkflow.java @@ -0,0 +1,19 @@ +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.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 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()?"해당 강의실을 찾지 못했어요.":"");} + 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/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/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/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/service/WorkflowDates.java b/src/main/java/devkor/com/teamcback/domain/chatbot/service/WorkflowDates.java new file mode 100644 index 00000000..c7d5ca78 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/service/WorkflowDates.java @@ -0,0 +1,61 @@ +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); + 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); + 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"); + } + 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/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..542cf3c4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotTools.java @@ -0,0 +1,156 @@ +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.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.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 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 = "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 = "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 = "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 = "Resolved BUILDING or PLACE type and ID") GetLocationDetailToolRequest request) { + beforeToolCall(); + return adapter.getLocationDetail(request); + } + + @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 = "Facility type and optional building/floor filters") FindFacilitiesToolRequest request) { + beforeToolCall(); + return adapter.findFacilities(request); + } + + @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 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)); + } + 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. " + + "Use searchCampus first; do not invent missing menu data.") + public GetCafeteriaMenuToolResult getCafeteriaMenu( + @ToolParam(description = "Cafeteria PLACE ID and requested dates") GetCafeteriaMenuToolRequest request) { + beforeToolCall(); + return adapter.getCafeteriaMenu(request); + } + + @Tool(name = "getRoomCourses", description = "Get courses scheduled in a resolved classroom PLACE ID. " + + "This is not a personal timetable.") + public GetRoomCoursesToolResult getRoomCourses( + @ToolParam(description = "Classroom PLACE ID and optional weekday") GetRoomCoursesToolRequest request) { + beforeToolCall(); + return adapter.getRoomCourses(request); + } + + @Tool(name = "getCampusStatus", description = "Get current campus term, vacation, and Koyeon status. No location ID is needed.") + public CampusStatusToolResult getCampusStatus() { + beforeToolCall(); + return adapter.getCampusStatus(); + } + + @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 and crowd-pattern option") GetCrowdStatusToolRequest request) { + beforeToolCall(); + return adapter.getCrowdStatus(request); + } + + @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 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 new file mode 100644 index 00000000..f316cd36 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapter.java @@ -0,0 +1,688 @@ +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.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; +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; +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.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.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.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; +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.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; +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; +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.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; +import java.util.Comparator; +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.beans.factory.annotation.Autowired; +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 ChatbotCampusSearchService chatbotCampusSearchService; + private final RouteService routeService; + private final CafeteriaMenuService cafeteriaMenuService; + private final CourseService courseService; + private final SchoolCalendarService schoolCalendarService; + private final BLEService bleService; + private final ReviewService reviewService; + private final ChatbotProperties properties; + + @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; + this.schoolCalendarService = schoolCalendarService; + this.bleService = bleService; + this.reviewService = reviewService; + 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)); + } + long startedAt = System.nanoTime(); + int limit = Math.min(CHATBOT_SEARCH_RESULT_LIMIT, properties.tools().search().maxLimit()); + try { + String query = request.query().trim(); + List actualLocations = chatbotCampusSearchService.search(query); + String normalizedQuery = normalizeSearchName(query); + List candidates = actualLocations.stream() + .limit(limit) + .map(item -> toSearchItem(item, matchType(normalizedQuery, item.name()))) + .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; + 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 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) { + 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)); + } + } + + 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); + } + + 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); + } + } + + 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; + } + 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 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 String normalizeSearchName(String value) { + if (value == null) { + return ""; + } + return Normalizer.normalize(value, Normalizer.Form.NFC) + .replaceAll("\\s+", "") + .replace("(", "") + .replace(")", "") + .replace("[", "") + .replace("]", "") + .toLowerCase(Locale.ROOT); + } + + 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/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/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/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 new file mode 100644 index 00000000..d364a01f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusToolError.java @@ -0,0 +1,14 @@ +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 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 new file mode 100644 index 00000000..bd84f719 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/CampusToolErrorCode.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +public enum CampusToolErrorCode { + NOT_FOUND, + AMBIGUOUS_LOCATION, + INVALID_INPUT, + UNSUPPORTED, + NO_DATA, + TEMPORARILY_UNAVAILABLE +} 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/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/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/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/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/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/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/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/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/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/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/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/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 new file mode 100644 index 00000000..d264c116 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusItem.java @@ -0,0 +1,18 @@ +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, + 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 new file mode 100644 index 00000000..b0faf52c --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/chatbot/tool/dto/SearchCampusToolRequest.java @@ -0,0 +1,19 @@ +package devkor.com.teamcback.domain.chatbot.tool.dto; + +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/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/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/java/devkor/com/teamcback/domain/place/repository/PlaceNicknameRepository.java b/src/main/java/devkor/com/teamcback/domain/place/repository/PlaceNicknameRepository.java index 51e8835f..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 @@ -3,18 +3,48 @@ 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; +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"}) 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); + + @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/main/java/devkor/com/teamcback/global/response/ResultCode.java b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java index 2536d2e1..aab691d5 100644 --- a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java +++ b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java @@ -124,8 +124,15 @@ 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, + "챗봇 서비스를 일시적으로 사용할 수 없습니다."), + 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-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 c74cb841..76788e71 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,36 @@ 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: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} + max-limit: ${CHATBOT_SEARCH_MAX_LIMIT:10} + facilities: + 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} + 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: # Opt in only after the coordinated data migration. Local workers stay disabled. storage: ${PUSH_STORAGE:mysql} 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/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/controller/ChatControllerTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java new file mode 100644 index 00000000..feec036d --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/controller/ChatControllerTest.java @@ -0,0 +1,108 @@ +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.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.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; + +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; +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; +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 ChatOrchestrator chatOrchestrator; + @Mock private ChatSessionMemoryService memoryService; + @Mock private ChatRateLimiter rateLimiter; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + 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(); + } + + @Test + void rejectsBlankMessageBeforeLlmCall() throws Exception { + mockMvc.perform(post("/api/chatbot/messages").contentType(MediaType.APPLICATION_JSON) + .content("{\"message\":\" \"}")) + .andExpect(status().isBadRequest()); + verifyNoInteractions(chatOrchestrator); + } + + @Test + void rejectsMessageLongerThanOneThousandCharactersBeforeLlmCall() throws Exception { + mockMvc.perform(post("/api/chatbot/messages").contentType(MediaType.APPLICATION_JSON) + .content("{\"message\":\"" + "a".repeat(1001) + "\"}")) + .andExpect(status().isBadRequest()); + verifyNoInteractions(chatOrchestrator); + } + + @Test + void rejectsInvalidCoordinatesBeforeLlmCall() throws Exception { + 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(chatOrchestrator); + } + + @Test + void generatesSessionIdAndReturnsGatewayReplyWithoutAction() throws Exception { + 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\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.statusCode").value(0)) + .andExpect(jsonPath("$.data.sessionId", not(blankOrNullString()))) + .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\":\"hello\"}")) + .andExpect(status().isTooManyRequests()) + .andExpect(jsonPath("$.statusCode").value(20001)); + verifyNoInteractions(chatOrchestrator); + } + + @Test + void hidesProviderFailureDetails() throws Exception { + 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\"}")) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.statusCode").value(20000)) + .andExpect(jsonPath("$.data").doesNotExist()); + } +} 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 new file mode 100644 index 00000000..dc37160f --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/gateway/SpringAiLlmGatewayTest.java @@ -0,0 +1,224 @@ +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.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.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; +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.springframework.ai.converter.StructuredOutputConverter; +import org.mockito.ArgumentCaptor; + +class SpringAiLlmGatewayTest { + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + + @AfterEach + 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 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 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); + ChatClient.Builder builder = mock(ChatClient.Builder.class); + CampusChatbotTools tools = mock(CampusChatbotTools.class); + when(builder.build()).thenReturn(chatClient); + 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")); + SpringAiLlmGateway gateway = new SpringAiLlmGateway(builder, properties(), executor, tools, + new ChatbotToolCallLimiter(properties())); + + assertThatThrownBy(() -> gateway.generate("system", List.of(), "hello", + new ResolvedLocationCollector())) + .isInstanceOfSatisfying(GlobalException.class, exception -> { + assertThat(exception.getResultCode()).isEqualTo(CHATBOT_TEMPORARILY_UNAVAILABLE); + assertThat(exception.getMessage()).isNull(); + assertThat(exception.getCause()).isNull(); + }); + } + + @Test + void sendsHistoryAsOrderedRoleMessagesWithoutCampusTools() { + 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.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", + new ResolvedLocationCollector()); + + assertThat(result.reply()).isEqualTo("done"); + @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); + org.mockito.Mockito.verify(requestSpec, org.mockito.Mockito.never()).tools(any()); + } + + @Test + 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); + when(builder.build()).thenReturn(chatClient); + when(chatClient.prompt()).thenReturn(requestSpec); + when(requestSpec.system("system")).thenReturn(requestSpec); + when(requestSpec.messages(anyList())).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, + mock(CampusChatbotTools.class), new ChatbotToolCallLimiter(properties())); + + ResolvedLocationCollector executionState = new ResolvedLocationCollector(); + 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) { + 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/search/ChatbotCampusSearchServiceTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/search/ChatbotCampusSearchServiceTest.java new file mode 100644 index 00000000..e0484279 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/search/ChatbotCampusSearchServiceTest.java @@ -0,0 +1,98 @@ +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); + 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 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, "109C", 7L, + "Building Room", 1.0, PlaceType.CLASSROOM, null, "room"))); + + List result = service.search("building room"); + + assertThat(result).extracting(ChatbotSearchCandidate::locationId) + .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", "building"))); + + List result = service.search("building"); + + assertThat(result).extracting(ChatbotSearchCandidate::locationId) + .containsExactly(1L, 2L); + } +} 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()); + } +} 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(); + } +} 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..b5a1ddf5 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatOrchestratorTest.java @@ -0,0 +1,230 @@ +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.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; +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; + @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 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( + 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() { + 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까지 경로를 찾았어요. 예상 소요 시간은 약 2분이에요. 길찾기 화면으로 안내할까요?"); + 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/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/ChatRequestRouterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouterTest.java new file mode 100644 index 00000000..4614e64a --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatRequestRouterTest.java @@ -0,0 +1,79 @@ +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); + } + + @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 new file mode 100644 index 00000000..e8dfe1c8 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceActionTest.java @@ -0,0 +1,275 @@ +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.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; +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 ChatOrchestrator chatOrchestrator; + @Mock ChatSessionMemoryService memoryService; + @Mock ChatRateLimiter rateLimiter; + @Mock PendingRouteStateService pendingRouteStateService; + + @Test + void assemblesNavigateRouteOnlyFromResolvedToolReferences() { + 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(chatOrchestrator, 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(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(chatOrchestrator, 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, "text route", null), caller); + + assertThat(response.action()).isNull(); + assertThat(response.reply()).isEqualTo("text route"); + } + + @Test + void returnsValidatedActionWhenFinalModelFollowUpFailed() { + 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(chatOrchestrator, 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(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(chatOrchestrator, 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(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, + 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(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()); + 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(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(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()); + + 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(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(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(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()); + 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(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(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()); + 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(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(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()); + 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(chatOrchestrator.execute(anyString(), anyList(), anyString())).thenReturn(new ChatOrchestrationResult( + null, List.of(), LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION)); + 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()); + 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); + } + + @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(chatOrchestrator.execute(anyString(), anyList(), anyString())).thenReturn(new ChatOrchestrationResult( + "start에서 end까지 경로를 찾았어요. 예상 소요 시간은 약 2분이에요. 길찾기 화면으로 안내할까요?", List.of(start, end), + LlmGateway.CompletionStatus.FAILED_AFTER_TOOL_EXECUTION, + List.of(), execution)); + 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()); + + 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/ChatServiceArchitectureTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceArchitectureTest.java new file mode 100644 index 00000000..37cfedc4 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceArchitectureTest.java @@ -0,0 +1,25 @@ +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 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 new file mode 100644 index 00000000..9830bf04 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceCurrentLocationTest.java @@ -0,0 +1,74 @@ +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; + +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.List; +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 ChatOrchestrator chatOrchestrator; + @Mock ChatSessionMemoryService memoryService; + @Mock ChatRateLimiter rateLimiter; + + @Test + 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"); + when(memoryService.load(sessionId, caller)).thenReturn(List.of()); + + service.sendMessage(new ChatMessageReq(sessionId, "route from current location", + new ChatContextReq(new CurrentLocationReq(37.5861, 127.0290))), caller); + service.sendMessage(new ChatMessageReq(sessionId, "tell me again", null), caller); + + ArgumentCaptor messages = ArgumentCaptor.forClass(String.class); + verify(chatOrchestrator).execute(anyString(), anyList(), messages.capture()); + assertThat(messages.getAllValues().get(0)).contains("latitude=37.5861", "longitude=127.029"); + 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"); + } + + @Test + void preservesRecentRolesAndKeepsCurrentCorrectionAsLatestUserMessage() { + when(chatOrchestrator.executeGeneral(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( + 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(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")); + 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..f7074eb6 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServicePromptTest.java @@ -0,0 +1,21 @@ +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 keepsGeneralPromptMinimalAndGrounded() { + assertThat(ChatService.SYSTEM_PROMPT) + .contains("general conversation", "Dynamic campus facts", "do not guess") + .doesNotContain("searchCampus", "findRoute", "Tool", "ROUTE_BEHAVIOR"); + } + + @Test + void generalPromptDoesNotExposeWorkflowOrchestration() { + assertThat(ChatService.SYSTEM_PROMPT) + .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 new file mode 100644 index 00000000..b49c3205 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/ChatServiceRoutingTest.java @@ -0,0 +1,133 @@ +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; + @Mock MenuWorkflow menuWorkflow; + + 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())); + lenient().when(chatOrchestrator.executeGeneral(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); + 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(menuWorkflow).handle(any(), any(), anyList(), anyString()); + verify(chatOrchestrator, never()).execute(anyString(), anyList(), anyString()); + verify(chatOrchestrator, never()).executeGeneral(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).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(), + null, null, null, menuWorkflow, null, null); + } +} 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..50403ffc --- /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, 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")); + + @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++) { + scope.beforeToolCall(); + } + assertThat(scope.callCount()).isEqualTo(6); + assertThatThrownBy(scope::beforeToolCall).isInstanceOf(ToolCallLimitExceededException.class); + } + } +} 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 new file mode 100644 index 00000000..5a7260e3 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/service/CrowdStatusWorkflowTest.java @@ -0,0 +1,251 @@ +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.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; +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; + @Mock CrowdCandidateSelector candidateSelector; + + @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)); + } + + @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()); + } + + @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()); + } +} 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/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); + } +} 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); + } +} 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..3c0b694e --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsTest.java @@ -0,0 +1,69 @@ +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 registersExactlyNineApprovedTools() { + assertThat(Arrays.stream(CampusChatbotTools.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(Tool.class)) + .map(method -> method.getAnnotation(Tool.class).name())) + .containsExactlyInAnyOrder("searchCampus", "getLocationDetail", "findFacilities", "findRoute", + "getCafeteriaMenu", "getRoomCourses", "getCampusStatus", "getCrowdStatus", + "getPlaceReviews"); + } + + @Test + 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 new file mode 100644 index 00000000..c295ab29 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusChatbotToolsToolLimitTest.java @@ -0,0 +1,35 @@ +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.ResolvedLocationCollector; +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, 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); + 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(); + } + assertThatThrownBy(tools::getCampusStatus).isInstanceOf(ToolCallLimitExceededException.class); + } + verify(adapter, times(6)).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 new file mode 100644 index 00000000..c4ed76a9 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusLifeToolAdapterTest.java @@ -0,0 +1,207 @@ +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, 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")); + adapter = new CampusToolAdapter(searchService, routeService, cafeteriaMenuService, + courseService, schoolCalendarService, null, null, 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/CampusRealtimeReviewToolAdapterTest.java b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRealtimeReviewToolAdapterTest.java new file mode 100644 index 00000000..bdf172d6 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRealtimeReviewToolAdapterTest.java @@ -0,0 +1,201 @@ +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, 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")); + 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 new file mode 100644 index 00000000..4647f814 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusRouteToolAdapterTest.java @@ -0,0 +1,151 @@ +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, 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")); + adapter = new CampusToolAdapter(searchService, routeService, null, null, null, null, null, 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 new file mode 100644 index 00000000..75fe59ff --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/chatbot/tool/CampusToolAdapterTest.java @@ -0,0 +1,135 @@ +package devkor.com.teamcback.domain.chatbot.tool; + +import static org.assertj.core.api.Assertions.assertThat; +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.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.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.service.SearchService; +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 CampusToolAdapterTest { + @Mock + private SearchService searchService; + @Mock + private ChatbotCampusSearchService chatbotCampusSearchService; + @Mock + private 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, 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")); + adapter = new CampusToolAdapter(searchService, chatbotCampusSearchService, routeService, + null, null, null, null, null, properties); + } + + @Test + 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(chatbotCampusSearchService).search("중도"); + verifyNoInteractions(searchService); + } + + @Test + void preservesResolverCandidateOrder() { + when(chatbotCampusSearchService.search("문과대학 서관")).thenReturn(List.of( + candidate(21L, "문과대학(서관)", ToolLocationType.BUILDING), + candidate(29L, "문과대학(서관) 129B", ToolLocationType.PLACE))); + + var result = adapter.searchCampus(new SearchCampusToolRequest("문과대학 서관", null)); + + assertThat(result.candidates()).extracting("locationId").containsExactly(21L, 29L); + assertThat(result.ambiguous()).isTrue(); + } + + @Test + void keepsOnlyResolverLocations() { + when(chatbotCampusSearchService.search("시설")).thenReturn(List.of( + candidate(31L, "중앙도서관 장애인주차장", ToolLocationType.PLACE), + candidate(32L, "중앙도서관 129B", ToolLocationType.PLACE))); + + var result = adapter.searchCampus(new SearchCampusToolRequest("시설", 1)); + + assertThat(result.candidates()).extracting("locationId").containsExactly(31L, 32L); + assertThat(result.candidates()).allMatch(item -> item.locationId() != null); + } + + @Test + void resolverDedupeIsPreserved() { + when(chatbotCampusSearchService.search("중도")).thenReturn(List.of( + candidate(11L, "중앙도서관", ToolLocationType.BUILDING), + candidate(12L, "중앙도서관(신관)", ToolLocationType.BUILDING))); + + var result = adapter.searchCampus(new SearchCampusToolRequest("중도", null)); + + assertThat(result.candidates()).extracting("locationId").containsExactly(11L, 12L); + assertThat(result.ambiguous()).isTrue(); + } + + @Test + void keepsAtMostServerSearchLimitAfterLightweightSearch() { + List results = java.util.stream.IntStream.rangeClosed(1, 10) + .mapToObj(id -> candidate(id, "장소" + id, ToolLocationType.BUILDING)) + .toList(); + when(chatbotCampusSearchService.search("장소")).thenReturn(results); + + var result = adapter.searchCampus(new SearchCampusToolRequest("장소", 1)); + + assertThat(result.candidates()).hasSize(5); + assertThat(result.ambiguous()).isTrue(); + } + + @Test + void blankSearchDoesNotCallSearchService() { + var result = adapter.searchCampus(new SearchCampusToolRequest(" ", null)); + + assertThat(result.error().code()).isEqualTo(devkor.com.teamcback.domain.chatbot.tool.dto.CampusToolErrorCode.INVALID_INPUT); + verifyNoInteractions(searchService); + } + + @Test + void emptyGlobalSearchMapsToNotFound() { + 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 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, name, false); + } +} 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