refactor: migrate loyalty-agent and loyalty-mcp-server components to a new modular structure with updated specs and frontend integration.
This commit is contained in:
6
loyalty-agent/Dockerfile
Normal file
6
loyalty-agent/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
||||
FROM eclipse-temurin:25-jre-alpine
|
||||
VOLUME /tmp
|
||||
ARG JAR_FILE=target/*.jar
|
||||
COPY ${JAR_FILE} app.jar
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["java","-jar","/app.jar"]
|
||||
151
loyalty-agent/pom.xml
Normal file
151
loyalty-agent/pom.xml
Normal file
@@ -0,0 +1,151 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.sonpx.loyalty</groupId>
|
||||
<artifactId>loyalty-agent-service-parent</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>loyalty-agent</artifactId>
|
||||
<name>loyalty-agent-service</name>
|
||||
<description>AI Agent Service to orchestrate Loyalty core system</description>
|
||||
|
||||
<dependencies>
|
||||
|
||||
<!-- Spring AI MCP Client -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-mcp-client</artifactId>
|
||||
</dependency>
|
||||
<!-- Spring Boot Web -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- WebSocket -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring AI Ollama -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-model-ollama</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- WebClient (needed for generated API client if library is webclient) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- OAuth2 Client -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-oauth2-client</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- MapStruct -->
|
||||
<dependency>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
<artifactId>mapstruct</artifactId>
|
||||
<version>1.6.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Boot DevTools -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-devtools</artifactId>
|
||||
<scope>runtime</scope>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Springdoc OpenAPI -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>2.7.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Testing -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
<artifactId>mapstruct-processor</artifactId>
|
||||
<version>1.6.2</version>
|
||||
</path>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<!-- Add generated sources to compile path even when not running gen profile -->
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>build-helper-maven-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>add-source</id>
|
||||
<phase>generate-sources</phase>
|
||||
<goals>
|
||||
<goal>add-source</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<sources>
|
||||
<source>${project.build.directory}/generated-sources/openapi/src/main/java</source>
|
||||
</sources>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,12 @@
|
||||
package dev.sonpx.loyalty.agent;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class LoyaltyAgentApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(LoyaltyAgentApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package dev.sonpx.loyalty.agent.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
@Configuration
|
||||
public class AgentConfig {
|
||||
|
||||
@Value("${loyalty.core.base-url}")
|
||||
private String coreBaseUrl;
|
||||
|
||||
@Bean
|
||||
public RestClient coreRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
|
||||
return RestClient.builder()
|
||||
.baseUrl(coreBaseUrl)
|
||||
.requestInterceptor((request, body, execution) -> {
|
||||
org.springframework.security.core.Authentication principal =
|
||||
new org.springframework.security.authentication.AnonymousAuthenticationToken(
|
||||
"key", "loyalty-agent-service",
|
||||
org.springframework.security.core.authority.AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
|
||||
OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest
|
||||
.withClientRegistrationId("keycloak")
|
||||
.principal(principal)
|
||||
.build();
|
||||
try {
|
||||
OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
|
||||
if (authorizedClient != null && authorizedClient.getAccessToken() != null) {
|
||||
request.getHeaders().setBearerAuth(authorizedClient.getAccessToken().getTokenValue());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return execution.execute(request, body);
|
||||
})
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package dev.sonpx.loyalty.agent.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.authorizeHttpRequests(authz -> authz
|
||||
.requestMatchers("/api/v1/agent/**", "/api/v1/conversations/**", "/ws/**", "/error").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.oauth2Login(Customizer.withDefaults())
|
||||
.oauth2Client(Customizer.withDefaults());
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package dev.sonpx.loyalty.agent.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
|
||||
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
@Override
|
||||
public void configureMessageBroker(MessageBrokerRegistry config) {
|
||||
config.enableSimpleBroker("/topic", "/queue");
|
||||
config.setApplicationDestinationPrefixes("/app");
|
||||
config.setUserDestinationPrefix("/user");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
registry.addEndpoint("/ws")
|
||||
.setAllowedOriginPatterns("*")
|
||||
.setHandshakeHandler(new org.springframework.web.socket.server.support.DefaultHandshakeHandler() {
|
||||
@Override
|
||||
protected java.security.Principal determineUser(org.springframework.http.server.ServerHttpRequest request, org.springframework.web.socket.WebSocketHandler wsHandler, java.util.Map<String, Object> attributes) {
|
||||
return new java.security.Principal() {
|
||||
private final String id = java.util.UUID.randomUUID().toString();
|
||||
@Override
|
||||
public String getName() {
|
||||
return id;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package dev.sonpx.loyalty.agent.controller;
|
||||
|
||||
import dev.sonpx.loyalty.agent.service.LoyaltyAgentService;
|
||||
import org.springframework.messaging.handler.annotation.Header;
|
||||
import org.springframework.messaging.handler.annotation.MessageMapping;
|
||||
import org.springframework.messaging.handler.annotation.Payload;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.CrossOrigin;
|
||||
|
||||
@RestController
|
||||
@CrossOrigin(origins = "*")
|
||||
public class AgentController {
|
||||
|
||||
private final LoyaltyAgentService agentService;
|
||||
|
||||
private final org.springframework.web.client.RestClient coreRestClient;
|
||||
|
||||
public AgentController(LoyaltyAgentService agentService, org.springframework.web.client.RestClient coreRestClient) {
|
||||
this.agentService = agentService;
|
||||
this.coreRestClient = coreRestClient;
|
||||
}
|
||||
|
||||
public record ChatRequest(String conversationId, String messageId, String prompt, String activeCampaignId) {}
|
||||
|
||||
@MessageMapping("/chat")
|
||||
public void chat(@Payload ChatRequest request, java.security.Principal principal) {
|
||||
agentService.chat(request, principal.getName());
|
||||
}
|
||||
|
||||
@GetMapping("/api/v1/agent/campaigns")
|
||||
public Object getCampaigns(
|
||||
@org.springframework.web.bind.annotation.RequestParam(defaultValue = "0") int page,
|
||||
@org.springframework.web.bind.annotation.RequestParam(defaultValue = "50") int size,
|
||||
@org.springframework.web.bind.annotation.RequestParam(required = false) String status,
|
||||
@org.springframework.web.bind.annotation.RequestParam(required = false) String name) {
|
||||
|
||||
String url = String.format("/svc/reward/api/campaign/csr/list?page=%d&size=%d", page, size);
|
||||
if (status != null && !status.isEmpty()) url += "&status=" + status;
|
||||
if (name != null && !name.isEmpty()) url += "&name=" + name;
|
||||
|
||||
String response = coreRestClient.get()
|
||||
.uri(url)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
return standardizeResponse(response);
|
||||
}
|
||||
|
||||
@GetMapping("/api/v1/agent/campaigns/{campaignId}/rules")
|
||||
public Object getCampaignRules(@org.springframework.web.bind.annotation.PathVariable("campaignId") String campaignId) {
|
||||
String response = coreRestClient.get()
|
||||
.uri("/svc/reward/api/campaign-rule/csr/list?page=0&size=100&campaignId.equals=" + campaignId)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
return standardizeResponse(response);
|
||||
}
|
||||
|
||||
Object standardizeResponse(String jsonStr) {
|
||||
try {
|
||||
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(jsonStr);
|
||||
com.fasterxml.jackson.databind.JsonNode arrayNode = root;
|
||||
if (root.has("content") && root.get("content").isArray()) arrayNode = root.get("content");
|
||||
else if (root.has("data") && root.get("data").isArray()) arrayNode = root.get("data");
|
||||
|
||||
return mapper.convertValue(arrayNode, new com.fasterxml.jackson.core.type.TypeReference<java.util.List<java.util.Map<String, Object>>>() {});
|
||||
} catch (Exception e) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package dev.sonpx.loyalty.agent.controller;
|
||||
|
||||
import dev.sonpx.loyalty.agent.domain.Conversation;
|
||||
import dev.sonpx.loyalty.agent.domain.Message;
|
||||
import dev.sonpx.loyalty.agent.repository.ConversationRepository;
|
||||
import dev.sonpx.loyalty.agent.repository.MessageRepository;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/conversations")
|
||||
@CrossOrigin(origins = "*")
|
||||
public class ConversationController {
|
||||
|
||||
private final ConversationRepository conversationRepository;
|
||||
private final MessageRepository messageRepository;
|
||||
|
||||
public ConversationController(ConversationRepository conversationRepository, MessageRepository messageRepository) {
|
||||
this.conversationRepository = conversationRepository;
|
||||
this.messageRepository = messageRepository;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<Conversation> createConversation() {
|
||||
Conversation conversation = new Conversation();
|
||||
conversation.setId(UUID.randomUUID().toString());
|
||||
conversation.setCreatedAt(Instant.now());
|
||||
conversation.setUpdatedAt(Instant.now());
|
||||
Conversation saved = conversationRepository.save(conversation);
|
||||
return ResponseEntity.ok(saved);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/messages")
|
||||
public ResponseEntity<List<Message>> getMessages(@PathVariable("id") String id) {
|
||||
List<Message> messages = messageRepository.findByConversationIdOrderByCreatedAtAsc(id);
|
||||
return ResponseEntity.ok(messages);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package dev.sonpx.loyalty.agent.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record AgentEvent(String type, String messageId, String content, String tool, String status) {
|
||||
public static AgentEvent token(String messageId, String content) {
|
||||
return new AgentEvent("TOKEN", messageId, content, null, null);
|
||||
}
|
||||
public static AgentEvent toolStatus(String messageId, String tool, String status) {
|
||||
return new AgentEvent("TOOL_STATUS", messageId, null, tool, status);
|
||||
}
|
||||
public static AgentEvent done(String messageId) {
|
||||
return new AgentEvent("DONE", messageId, null, null, null);
|
||||
}
|
||||
public static AgentEvent error(String messageId, String error) {
|
||||
return new AgentEvent("ERROR", messageId, error, null, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package dev.sonpx.loyalty.agent.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Conversation {
|
||||
private String id;
|
||||
private Instant createdAt;
|
||||
private Instant updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package dev.sonpx.loyalty.agent.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Message {
|
||||
private String id;
|
||||
private String conversationId;
|
||||
private String role;
|
||||
private String content;
|
||||
private Instant createdAt;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package dev.sonpx.loyalty.agent.repository;
|
||||
|
||||
import dev.sonpx.loyalty.agent.domain.Conversation;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.List;
|
||||
|
||||
public interface ConversationRepository {
|
||||
Conversation save(Conversation conversation);
|
||||
Optional<Conversation> findById(String id);
|
||||
List<Conversation> findAll();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package dev.sonpx.loyalty.agent.repository;
|
||||
|
||||
import dev.sonpx.loyalty.agent.domain.Conversation;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Repository
|
||||
public class InMemoryConversationRepository implements ConversationRepository {
|
||||
private final Map<String, Conversation> store = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Conversation save(Conversation conversation) {
|
||||
store.put(conversation.getId(), conversation);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Conversation> findById(String id) {
|
||||
return Optional.ofNullable(store.get(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Conversation> findAll() {
|
||||
return new ArrayList<>(store.values());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package dev.sonpx.loyalty.agent.repository;
|
||||
|
||||
import dev.sonpx.loyalty.agent.domain.Message;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Repository
|
||||
public class InMemoryMessageRepository implements MessageRepository {
|
||||
private final Map<String, Message> store = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Message save(Message message) {
|
||||
store.put(message.getId(), message);
|
||||
return message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Message> findByConversationIdOrderByCreatedAtAsc(String conversationId) {
|
||||
return store.values().stream()
|
||||
.filter(m -> conversationId.equals(m.getConversationId()))
|
||||
.sorted(Comparator.comparing(Message::getCreatedAt))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package dev.sonpx.loyalty.agent.repository;
|
||||
|
||||
import dev.sonpx.loyalty.agent.domain.Message;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface MessageRepository {
|
||||
Message save(Message message);
|
||||
List<Message> findByConversationIdOrderByCreatedAtAsc(String conversationId);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package dev.sonpx.loyalty.agent.service;
|
||||
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.tool.ToolCallbackProvider;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class LoyaltyAgentService {
|
||||
|
||||
private final org.springframework.ai.chat.model.ChatModel chatModel;
|
||||
private final List<ToolCallbackProvider> toolProviders;
|
||||
private final org.springframework.messaging.simp.SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
public LoyaltyAgentService(org.springframework.ai.chat.model.ChatModel chatModel, List<ToolCallbackProvider> toolProviders, org.springframework.messaging.simp.SimpMessagingTemplate messagingTemplate) {
|
||||
this.chatModel = chatModel;
|
||||
this.toolProviders = toolProviders;
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
}
|
||||
|
||||
public void chat(dev.sonpx.loyalty.agent.controller.AgentController.ChatRequest request, String sessionId) {
|
||||
String prompt = request.prompt();
|
||||
if (request.activeCampaignId() != null && !request.activeCampaignId().isEmpty()) {
|
||||
prompt = "Context: Active Campaign ID is " + request.activeCampaignId() + ".\n\n" + prompt;
|
||||
}
|
||||
if (request.conversationId() != null && !request.conversationId().isEmpty()) {
|
||||
prompt = "System Context: The current conversationId (session ID) is " + request.conversationId() + ". You MUST provide this conversationId as a parameter to any tool that requires it.\n\n" + prompt;
|
||||
}
|
||||
|
||||
List<org.springframework.ai.tool.ToolCallback> wrappedTools = new java.util.ArrayList<>();
|
||||
for (ToolCallbackProvider provider : toolProviders) {
|
||||
for (org.springframework.ai.tool.ToolCallback tool : provider.getToolCallbacks()) {
|
||||
wrappedTools.add(new org.springframework.ai.tool.ToolCallback() {
|
||||
@Override
|
||||
public org.springframework.ai.tool.definition.ToolDefinition getToolDefinition() {
|
||||
return tool.getToolDefinition();
|
||||
}
|
||||
@Override
|
||||
public String call(String toolInput) {
|
||||
messagingTemplate.convertAndSendToUser(sessionId, "/queue/chat-events", dev.sonpx.loyalty.agent.domain.AgentEvent.toolStatus(request.messageId(), tool.getToolDefinition().name(), "running"));
|
||||
try {
|
||||
return tool.call(toolInput);
|
||||
} finally {
|
||||
messagingTemplate.convertAndSendToUser(sessionId, "/queue/chat-events", dev.sonpx.loyalty.agent.domain.AgentEvent.toolStatus(request.messageId(), tool.getToolDefinition().name(), "done"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ChatClient chatClient = ChatClient.builder(chatModel)
|
||||
.defaultSystem("""
|
||||
You are an expert Loyalty System AI Assistant.
|
||||
Your primary job is to orchestrate operations for our Loyalty core system.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. DO NOT create a model.client.dev.sonpx.loyalty.agent.Campaign unless you verify that Member Tiers and Point Pools exist first.
|
||||
2. Use the `listMemberTiers` tool to check for valid tier IDs.
|
||||
3. Use the `listPointPools` tool to check for valid pool IDs and ensure they have a sufficient balance.
|
||||
4. Use the `listStaticAttributes` tool when asked about configurations, properties, or static values.
|
||||
5. Only use the `createCampaign` tool once verification is complete.
|
||||
6. Always answer the user in the language they used (e.g., Vietnamese).
|
||||
7. If a tool response contains a `presentation.content` field, you MUST display its content exactly as provided to the user. Do not summarize or alter its formatting. Use the `data` field only for your internal reasoning.
|
||||
8. When asked for information, details, or status of an existing campaign, ALWAYS use the `listCampaigns` tool. DO NOT use `createCampaign` unless explicitly asked to create or add a new one.
|
||||
""")
|
||||
.defaultTools(wrappedTools)
|
||||
.build();
|
||||
|
||||
chatClient.prompt()
|
||||
.user(prompt)
|
||||
.stream()
|
||||
.content()
|
||||
.doOnComplete(() -> messagingTemplate.convertAndSendToUser(sessionId, "/queue/chat-events", dev.sonpx.loyalty.agent.domain.AgentEvent.done(request.messageId())))
|
||||
.subscribe(
|
||||
content -> {
|
||||
if (content != null && !content.isEmpty()) {
|
||||
messagingTemplate.convertAndSendToUser(sessionId, "/queue/chat-events", dev.sonpx.loyalty.agent.domain.AgentEvent.token(request.messageId(), content));
|
||||
}
|
||||
},
|
||||
error -> messagingTemplate.convertAndSendToUser(sessionId, "/queue/chat-events", dev.sonpx.loyalty.agent.domain.AgentEvent.error(request.messageId(), error.getMessage()))
|
||||
);
|
||||
}
|
||||
}
|
||||
47
loyalty-agent/src/main/resources/application.yml
Normal file
47
loyalty-agent/src/main/resources/application.yml
Normal file
@@ -0,0 +1,47 @@
|
||||
server:
|
||||
port: 9332
|
||||
|
||||
spring:
|
||||
mvc:
|
||||
async:
|
||||
request-timeout: 3600000
|
||||
application:
|
||||
name: loyalty-agent-service
|
||||
ai:
|
||||
ollama:
|
||||
base-url: http://localhost:11434
|
||||
chat:
|
||||
options:
|
||||
model: qwen3.5:2b
|
||||
temperature: 0.3
|
||||
mcp:
|
||||
client:
|
||||
request-timeout: 60000
|
||||
enabled: true
|
||||
type: sync
|
||||
sse:
|
||||
connections:
|
||||
loyalty:
|
||||
url: ${MCP_SERVER_URL:http://localhost:9331}
|
||||
security:
|
||||
oauth2:
|
||||
client:
|
||||
registration:
|
||||
keycloak:
|
||||
client-id: ols-cli
|
||||
client-secret: ${KEYCLOAK_CLIENT_SECRET:1jV8NSAeybIqmUK0AWhI8hgdo1q5itj7}
|
||||
authorization-grant-type: client_credentials
|
||||
provider:
|
||||
keycloak:
|
||||
token-uri: ${KEYCLOAK_TOKEN_URI:http://192.168.99.235/auth/realms/ols-cn-sit/protocol/openid-connect/token}
|
||||
|
||||
loyalty:
|
||||
core:
|
||||
base-url: ${LOYALTY_CORE_BASE_URL:http://192.168.99.242:8081}
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: warn
|
||||
org.springframework.ai: INFO
|
||||
org.springframework.web: INFO
|
||||
dev.sonpx.loyalty: DEBUG
|
||||
@@ -0,0 +1,34 @@
|
||||
package dev.sonpx.loyalty.agent.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AgentControllerTest {
|
||||
|
||||
private final AgentController controller = new AgentController(null, null);
|
||||
|
||||
@Test
|
||||
void testStandardizeResponse_Array() {
|
||||
String json = "[{\"id\":\"1\"}]";
|
||||
Object result = controller.standardizeResponse(json);
|
||||
assertTrue(result instanceof java.util.List);
|
||||
assertEquals(1, ((java.util.List<?>) result).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testStandardizeResponse_ContentWrapped() {
|
||||
String json = "{\"content\":[{\"id\":\"1\"}], \"page\":0}";
|
||||
Object result = controller.standardizeResponse(json);
|
||||
assertTrue(result instanceof java.util.List);
|
||||
assertEquals(1, ((java.util.List<?>) result).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testStandardizeResponse_DataWrapped() {
|
||||
String json = "{\"data\":[{\"id\":\"1\"}]}";
|
||||
Object result = controller.standardizeResponse(json);
|
||||
assertTrue(result instanceof java.util.List);
|
||||
assertEquals(1, ((java.util.List<?>) result).size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package dev.sonpx.loyalty.agent.domain;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AgentEventTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void testTokenEventSerialization() throws Exception {
|
||||
AgentEvent event = AgentEvent.token("msg-123", "Hello");
|
||||
String json = objectMapper.writeValueAsString(event);
|
||||
|
||||
assertTrue(json.contains("\"type\":\"TOKEN\""));
|
||||
assertTrue(json.contains("\"messageId\":\"msg-123\""));
|
||||
assertTrue(json.contains("\"content\":\"Hello\""));
|
||||
assertFalse(json.contains("\"tool\""));
|
||||
assertFalse(json.contains("\"status\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testToolStatusEventSerialization() throws Exception {
|
||||
AgentEvent event = AgentEvent.toolStatus("msg-123", "searchTool", "running");
|
||||
String json = objectMapper.writeValueAsString(event);
|
||||
|
||||
assertTrue(json.contains("\"type\":\"TOOL_STATUS\""));
|
||||
assertTrue(json.contains("\"messageId\":\"msg-123\""));
|
||||
assertTrue(json.contains("\"tool\":\"searchTool\""));
|
||||
assertTrue(json.contains("\"status\":\"running\""));
|
||||
assertFalse(json.contains("\"content\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDoneEventSerialization() throws Exception {
|
||||
AgentEvent event = AgentEvent.done("msg-123");
|
||||
String json = objectMapper.writeValueAsString(event);
|
||||
|
||||
assertTrue(json.contains("\"type\":\"DONE\""));
|
||||
assertTrue(json.contains("\"messageId\":\"msg-123\""));
|
||||
assertFalse(json.contains("\"content\""));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user