Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4e50eac
feat(chatbot): add LLM gateway and chat API
JokeBear777 Sep 20, 2026
798599d
feat(chatbot): add campus search tools
JokeBear777 Sep 20, 2026
5314934
feat(chatbot): add campus route tool
JokeBear777 Sep 22, 2026
03b332a
feat(chatbot): add campus life tools
JokeBear777 Sep 22, 2026
5de8b83
feat(chatbot): add crowd and review tools
JokeBear777 Sep 24, 2026
75df913
feat(chatbot): add session memory and usage limits
JokeBear777 Sep 24, 2026
65c08fb
feat(chatbot): improve route tool calling guidance
JokeBear777 Sep 26, 2026
6998d31
refactor(chatbot): reuse global campus search
JokeBear777 Sep 26, 2026
2a3f81e
fix(chatbot): harden route execution and tool error handling
JokeBear777 Sep 26, 2026
ce19ef3
refactor(chatbot): separate orchestration from LLM gateway
JokeBear777 Sep 27, 2026
4ae814a
refactor(chatbot): make route workflow deterministic
JokeBear777 Sep 27, 2026
0e126d0
reafactor(chatbot): change message
JokeBear777 Sep 27, 2026
d70b7b3
light search
JokeBear777 Sep 27, 2026
9d80e7a
chore(chatbot): add raw tool call diagnostics
JokeBear777 Sep 27, 2026
50d7b25
feat(chatbot): add backend-owned crowd status workflow
JokeBear777 Sep 27, 2026
bfd4993
refactor(chatbot): use LLM semantic selection in crowd workflow
JokeBear777 Sep 27, 2026
f088d92
feat(chatbot): add top-level routing and pending workflow handling
JokeBear777 Sep 28, 2026
f9585d0
feat(chatbot): add backend-owned campus info workflows
JokeBear777 Sep 28, 2026
1e96699
feat(chatbot): add backend-owned menu, facility, and room workflows
JokeBear777 Sep 28, 2026
d2957fc
feat(chatbot): add backend-owned menu, facility, and room workflows
JokeBear777 Sep 28, 2026
b3cfcb2
fix(chatbot): improve intent routing and workflow error handling
JokeBear777 Sep 30, 2026
95b3820
Merge branch 'develop' into feat/campus-ai-chatbot
JokeBear777 Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ repositories {

ext {
set('springCloudVersion', "2024.0.0")
set('springAiVersion', "1.0.9")
}

dependencies {
Expand All @@ -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'

Expand Down Expand Up @@ -132,6 +139,7 @@ dependencies {
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
mavenBom "org.springframework.ai:spring-ai-bom:${springAiVersion}"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -11,4 +14,16 @@ public interface BLEDeviceRepository extends JpaRepository<BLEDevice, Long> {
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<CrowdPlaceCandidate> 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<CrowdPlaceCandidate> findAllChatbotCrowdPlaces();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<BuildingNickname, Long> {

@EntityGraph(attributePaths = "building")
List<BuildingNickname> findAllByChosungContaining(String chosung);
@EntityGraph(attributePaths = "building")
List<BuildingNickname> findAllByJasoDecomposeContaining(String jaso);
@EntityGraph(attributePaths = "building")
List<BuildingNickname> findAllByChosungContainingOrderByNickname(String chosung, Pageable pageable);
@EntityGraph(attributePaths = "building")
List<BuildingNickname> findAllByJasoDecomposeContainingOrderByNickname(String jaso, Pageable pageable);
List<BuildingNickname> findByChosungIsNullOrJasoDecomposeIsNull();
List<BuildingNickname> findAllByBuilding(Building building);
List<BuildingNickname> 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<ChatbotBuildingCandidate> 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<ChatbotBuildingCandidate> findChatbotByChosung(@Param("chosung") String chosung, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -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<List<StoredCheckpoint>> 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<Checkpoint> loadCheckpoints(RunnableConfig config) throws Exception {
String value = redisTemplate.opsForValue().get(key(config));
if (value == null || value.isBlank()) {
return new LinkedList<>();
}
refresh(config);
LinkedList<Checkpoint> 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<Checkpoint> checkpoints,
Checkpoint checkpoint) throws Exception {
persist(config, checkpoints);
}

@Override
protected void updatedCheckpoint(RunnableConfig config, LinkedList<Checkpoint> checkpoints,
Checkpoint checkpoint) throws Exception {
persist(config, checkpoints);
}

@Override
protected BaseCheckpointSaver.Tag releaseCheckpoints(RunnableConfig config,
LinkedList<Checkpoint> checkpoints,
String checkpointId) {
redisTemplate.delete(key(config));
return new BaseCheckpointSaver.Tag(threadId(config), checkpoints);
}

@Override
protected BaseCheckpointSaver.Tag releaseCheckpointsOnError(RunnableConfig config,
LinkedList<Checkpoint> checkpoints,
Throwable throwable) {
redisTemplate.delete(key(config));
return new BaseCheckpointSaver.Tag(threadId(config), checkpoints);
}

@Override
public <State extends org.bsc.langgraph4j.state.AgentState>
CompletableFuture<InterruptionMetadata<State>> registerInterruption(
RunnableConfig config, InterruptionMetadata<State> interruption) {
return CompletableFuture.completedFuture(interruption);
}

@Override
public java.util.Optional<BaseCheckpointSaver.Tag> 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<Checkpoint> checkpoints) throws Exception {
List<StoredCheckpoint> 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<String, Object> state, String nodeId, String nextNodeId) {}
}
Original file line number Diff line number Diff line change
@@ -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;
});
}
}
Original file line number Diff line number Diff line change
@@ -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) {
}
}
Original file line number Diff line number Diff line change
@@ -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\"}";
}
};
}
}
Original file line number Diff line number Diff line change
@@ -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<ChatMessageRes> sendMessage(@Valid @RequestBody ChatMessageReq request,
@AuthenticationPrincipal UserDetailsImpl userDetails,
HttpServletRequest httpRequest) {
return CommonResponse.success(chatService.sendMessage(request,
ChatCaller.from(userDetails, httpRequest.getRemoteAddr())));
}
}
Original file line number Diff line number Diff line change
@@ -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) {
}
Loading
Loading