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:
2026-07-19 21:43:44 +07:00
parent c3861d6476
commit cda93365ca
128 changed files with 12721 additions and 6343 deletions

16
.codegraph/.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
KEYCLOAK_CLIENT_SECRET=your_client_secret_here
KEYCLOAK_TOKEN_URI=http://192.168.99.235/auth/realms/ols-cn-sit/protocol/openid-connect/token
LOYALTY_CORE_BASE_URL=http://192.168.99.242:8081
MCP_SERVER_URL=http://localhost:9331
VITE_AGENT_HTTP_URL=http://localhost:9332
VITE_AGENT_WS_URL=ws://localhost:9332

104
.gitignore vendored
View File

@@ -1,39 +1,67 @@
HELP.md # Logs
target/ logs
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store
### Others ###
*.log *.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Node & Frontend
node_modules
dist
dist-ssr
*.local
coverage/
# Environment files
.env
.env.*.local
!.env.example
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Java / Maven
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
.mvn/wrapper/maven-wrapper.jar
# Compiled class file
*.class
# Package Files
*.jar
*.war
*.ear
*.zip
*.tar.gz
*.rar
# Virtual machine crash logs
hs_err_pid*
replay_pid*
# Spring Boot
HELP.md
token.json
openspec
.agent
codegraph
log

8
.oxlintrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

View File

@@ -1,4 +1,31 @@
./mvnw package -Pgen # Loyalty Agent Service
This project consists of a 3-module architecture:
./mvnw compile spring-boot:run 1. `loyalty-mcp-server`: Domain MCP tools and operations
2. `loyalty-agent`: Orchestration and BFF layer
3. Frontend (React/Vite in the root directory)
## Startup Instructions
### 1. Prerequisites
- Java 21+
- Node.js & pnpm
- `.env` file created (see `.env.example`)
### 2. Start Backend Modules
You can use the provided startup script:
```bash
./start-all.sh
```
Or start them individually in separate terminals:
```bash
./mvnw spring-boot:run -pl loyalty-mcp-server
./mvnw spring-boot:run -pl loyalty-agent
```
### 3. Start Frontend
In a new terminal:
```bash
pnpm install
pnpm dev
```

18
components.json Normal file
View File

@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
},
"iconLibrary": "lucide"
}

153
context.md Normal file
View File

@@ -0,0 +1,153 @@
---
trigger: always_on
---
# Project Context: Loyalty Agent Service
This repository is a local development workspace for a loyalty AI assistant. It has a React chat UI at the repository root and two Spring Boot backend modules managed by a parent Maven project.
## Architecture
### Frontend: root directory
- Stack: React 19, Vite, TypeScript, Tailwind CSS, shadcn-style UI primitives, Zustand, `@stomp/stompjs`, `react-markdown`, `remark-gfm`.
- Entry point: `src/main.tsx` renders `src/App.tsx`, which renders `ChatLayout`.
- Dev port: `9333` from `vite.config.ts`.
- Main UI flow:
- `src/store/useChatStore.ts` owns chat state and STOMP connection.
- WebSocket URL is currently hard-coded as `ws://localhost:9332/ws`.
- User messages are published to `/app/chat`.
- Agent events are received from `/user/queue/chat-events`.
- `CampaignList` fetches campaign data from `http://localhost:9332/api/v1/agent/campaigns` and campaign rules from `http://localhost:9332/api/v1/agent/campaigns/{campaignId}/rules`.
### `loyalty-agent`
- Spring Boot application: `dev.sonpx.loyalty.agent.LoyaltyAgentApplication`.
- Runtime port: `9332`.
- Role: BFF and AI orchestration layer for the frontend.
- Key files:
- `controller/AgentController.java`: STOMP chat endpoint and REST campaign proxy endpoints.
- `service/LoyaltyAgentService.java`: builds a Spring AI `ChatClient`, streams LLM output, wraps tool calls, and emits tool status events.
- `config/WebSocketConfig.java`: STOMP endpoint `/ws`, application prefix `/app`, user destination prefix `/user`, broker prefixes `/topic` and `/queue`.
- `config/AgentConfig.java`: OAuth2-enabled `RestClient` for the loyalty core API.
- `controller/ConversationController.java`: simple in-memory conversation/message REST endpoints.
- AI configuration:
- Uses Ollama at `http://localhost:11434`.
- Default model in `application.yml`: `qwen3.5:2b`.
- MCP client connects to `${MCP_SERVER_URL:http://localhost:9331}`.
- Agent event types sent to the UI: `TOKEN`, `TOOL_STATUS`, `DONE`, `ERROR`.
### `loyalty-mcp-server`
- Spring Boot application: `dev.sonpx.loyalty.mcp.McpServerApplication`.
- Runtime port: `9331`.
- Role: MCP tool server for loyalty operations. The agent calls this service through Spring AI MCP SSE.
- MCP endpoints from `application.yml`:
- SSE endpoint: `/sse`
- Message endpoint: `/message`
- Key files:
- `config/McpServerConfig.java`: registers MCP tool objects.
- `tool/LoyaltyAgentTools.java`: member tiers, point pools, campaign creation, static attributes.
- `tool/reward/CampaignTools.java`: campaign listing and campaign draft lifecycle.
- `tool/reward/CampaignRuleTools.java`: campaign rule listing, creation, editing, and draft rule attachment.
- `tool/pool/PointPoolTools.java`: pool and SOP draft tools.
- `draft/DraftSessionManager.java`: in-memory draft sessions keyed by `conversationId`, with a 1-hour TTL.
- `draft/DependencyValidator.java`: validates draft dependencies before tools mutate or submit drafts.
- API clients call the loyalty core service with OAuth2 client credentials.
## External Services And Environment
Create `.env` from `.env.example` for local development.
Important variables:
- `KEYCLOAK_CLIENT_SECRET`
- `KEYCLOAK_TOKEN_URI`
- `LOYALTY_CORE_BASE_URL`
- `MCP_SERVER_URL`
- `VITE_AGENT_HTTP_URL`
- `VITE_AGENT_WS_URL`
Current defaults:
- Keycloak token URI: `http://192.168.99.235/auth/realms/ols-cn-sit/protocol/openid-connect/token`
- Loyalty core base URL: `http://192.168.99.242:8081`
- MCP server URL: `http://localhost:9331`
- Agent HTTP URL: `http://localhost:9332`
- Agent WS URL: `ws://localhost:9332`
Note: some frontend code still uses hard-coded localhost URLs instead of the `VITE_*` variables.
## Local Startup
Use the root script for the full local stack:
```bash
./start-all.sh
```
What the script does:
1. Loads `.env` if present.
2. Kills processes on ports `9331`, `9332`, and `9333`.
3. Starts `loyalty-mcp-server` and waits for port `9331`.
4. Starts a `nodemon` compile watcher for the MCP server.
5. Starts `loyalty-agent` and waits for port `9332`.
6. Starts a `nodemon` compile watcher for the agent.
7. Starts the frontend with `pnpm dev` on port `9333`.
8. Writes logs to `log/`.
Manual startup:
```bash
./mvnw spring-boot:run -pl loyalty-mcp-server
./mvnw spring-boot:run -pl loyalty-agent
pnpm dev
```
The script currently invokes `-Pgen`, but the checked-in POM files do not define a `gen` Maven profile. Do not assume OpenAPI generation is wired unless you verify or add that profile.
## Build, Lint, And Tests
Frontend:
```bash
pnpm install
pnpm lint
pnpm build
```
Backend:
```bash
./mvnw test
./mvnw test -pl loyalty-agent
./mvnw test -pl loyalty-mcp-server
```
Relevant tests:
- `loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/controller/AgentControllerTest.java`
- `loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/domain/AgentEventTest.java`
- `loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/tool/McpOrchestratorTest.java`
- `loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/tool/reward/CampaignRuleMapperTest.java`
- `loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/tool/LoyaltyAgentToolsRealApiTest.java`
`LoyaltyAgentToolsRealApiTest` may depend on real external API availability and credentials.
## Development Guidelines For Agents
- Keep the boundary clear:
- `loyalty-agent` owns frontend-facing chat orchestration and REST proxy endpoints.
- `loyalty-mcp-server` owns tool implementations and loyalty core API access.
- The frontend owns STOMP state, message rendering, chat layout, and campaign context panels.
- Preserve the STOMP contract unless intentionally changing both sides:
- Client publishes to `/app/chat`.
- Server handles `@MessageMapping("/chat")`.
- Server sends events to `/user/queue/chat-events`.
- Preserve `conversationId` propagation. MCP draft tools depend on it to keep draft campaign/pool/rule state in `DraftSessionManager`.
- Tool responses may contain `presentation.content`; the agent system prompt requires displaying that content exactly.
- Reuse existing UI primitives in `src/components/ui` and existing Tailwind conventions.
- Do not introduce persistent storage assumptions. Conversation repositories and draft sessions are currently in memory.
- Do not hard-code new service URLs when a matching environment variable already exists.
- Be careful with ports: MCP is `9331`, agent is `9332`, frontend is `9333`.

View File

@@ -1,38 +0,0 @@
$baseUrl = "http://192.168.99.242:8081"
$specs = @(
@{ name="attribute"; url="/attribute/v3/api-docs" },
@{ name="batch-scheduler"; url="/batch-scheduler/v3/api-docs" },
@{ name="catalogue"; url="/catalogue/v3/api-docs" },
@{ name="customer"; url="/customer/v3/api-docs" },
@{ name="identity"; url="/identity/v3/api-docs" },
@{ name="marketing"; url="/marketing/v3/api-docs" },
@{ name="master"; url="/master/v3/api-docs" },
@{ name="notification"; url="/notification/v3/api-docs" },
@{ name="reward"; url="/reward/v3/api-docs" },
@{ name="transaction"; url="/transaction/v3/api-docs" }
)
$targetDir = "src/main/resources/specs"
if (-not (Test-Path $targetDir)) {
New-Item -ItemType Directory -Force -Path $targetDir | Out-Null
}
foreach ($spec in $specs) {
$fullUrl = $baseUrl + $spec.url
$outputPath = Join-Path $targetDir ($spec.name + ".json")
Write-Host "Downloading $($spec.name) from $fullUrl ..."
try {
Invoke-WebRequest -Uri $fullUrl -OutFile $outputPath -UseBasicParsing
Write-Host "[OK] $($spec.name) saved to $outputPath"
} catch {
Write-Host "[WARNING] Failed to download from $fullUrl, trying alternative path..."
$altUrl = $baseUrl + "/v3/api-docs/" + $spec.name
try {
Invoke-WebRequest -Uri $altUrl -OutFile $outputPath -UseBasicParsing
Write-Host "[OK] $($spec.name) saved to $outputPath (alternative path)"
} catch {
Write-Host "[ERROR] Failed to download $($spec.name)"
}
}
}
Write-Host "Done downloading specs."

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>agent-ui-temp</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6
loyalty-agent/Dockerfile Normal file
View 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
View 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>

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
};
}
});
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -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());
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}

View File

@@ -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()))
);
}
}

View 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

View File

@@ -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());
}
}

View File

@@ -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\""));
}
}

View File

@@ -0,0 +1,6 @@
FROM eclipse-temurin:25-jre-alpine
VOLUME /tmp
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
EXPOSE 8081
ENTRYPOINT ["java","-jar","/app.jar"]

120
loyalty-mcp-server/pom.xml Normal file
View File

@@ -0,0 +1,120 @@
<?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-mcp-server</artifactId>
<name>loyalty-mcp-server</name>
<description>MCP Server for Loyalty API Tools</description>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</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>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,12 @@
package dev.sonpx.loyalty.mcp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class McpServerApplication {
public static void main(String[] args) {
SpringApplication.run(McpServerApplication.class, args);
}
}

View File

@@ -0,0 +1,16 @@
package dev.sonpx.loyalty.mcp.client.api;
import dev.sonpx.loyalty.mcp.client.model.Campaign;
import dev.sonpx.loyalty.mcp.client.model.OperationResult;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.PostExchange;
public interface CampaignApiClient {
@GetExchange("/api/campaign/csr/id/generate")
String generateCampaignId();
@PostExchange("/api/campaign/csr/create-draft")
OperationResult<Campaign> createCampaignDraft(@RequestBody Campaign request);
}

View File

@@ -0,0 +1,12 @@
package dev.sonpx.loyalty.mcp.client.api;
import dev.sonpx.loyalty.mcp.client.model.MemberTier;
import org.springframework.web.service.annotation.GetExchange;
import java.util.List;
public interface MemberTierApiClient {
@GetExchange("/svc/reward/api/decide-tier-view/list?page=0&size=100&status.in=A")
List<MemberTier> listMemberTiers();
}

View File

@@ -0,0 +1,12 @@
package dev.sonpx.loyalty.mcp.client.api;
import dev.sonpx.loyalty.mcp.client.model.PointPool;
import org.springframework.web.service.annotation.GetExchange;
import java.util.List;
public interface PointPoolApiClient {
@GetExchange("/api/pool-definition/all")
List<PointPool> listPointPools();
}

View File

@@ -0,0 +1,7 @@
package dev.sonpx.loyalty.mcp.client.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public record Campaign(String campaignId, String name, String targetTierId, String rewardPoolId, String status) {
}

View File

@@ -0,0 +1,8 @@
package dev.sonpx.loyalty.mcp.client.model;
public record CreateCampaignRequest(String campaignId, String name, String targetTierId, String rewardPoolId) {
public CreateCampaignRequest(String name, String targetTierId, String rewardPoolId) {
this(null, name, targetTierId, rewardPoolId);
}
}

View File

@@ -0,0 +1,12 @@
package dev.sonpx.loyalty.mcp.client.model;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonIgnoreProperties(ignoreUnknown = true)
public record MemberTier(
@JsonProperty("key") @JsonAlias("tierId") String tierId,
@JsonAlias("name") String tierName,
Integer requiredPoints) {
}

View File

@@ -0,0 +1,7 @@
package dev.sonpx.loyalty.mcp.client.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public record OperationResult<T>(String errorCode, String message, T entity) {
}

View File

@@ -0,0 +1,9 @@
package dev.sonpx.loyalty.mcp.client.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.math.BigDecimal;
@JsonIgnoreProperties(ignoreUnknown = true)
public record PointPool(String poolId, String poolName, BigDecimal balance) {
}

View File

@@ -0,0 +1,13 @@
package dev.sonpx.loyalty.mcp.client.model.attribute;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class StaticAttributeDto {
private String attributeId;
private String attributeType;
private String attributeValue;
private String status;
}

View File

@@ -0,0 +1,26 @@
package dev.sonpx.loyalty.mcp.client.model.reward;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import java.util.LinkedHashMap;
import java.util.Map;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class CampaignDto {
private String campaignId;
private String name;
private String targetTierId;
private String rewardPoolId;
private String status;
private String description;
private Map<String, Object> extraData = new LinkedHashMap<>();
@JsonAnySetter
public void addExtraData(String key, Object value) {
this.extraData.put(key, value);
}
}

View File

@@ -0,0 +1,11 @@
package dev.sonpx.loyalty.mcp.client.model.reward;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class CampaignFormulaOneDto {
private Double awardPerTxnAmt;
private Double txnAmt;
}

View File

@@ -0,0 +1,57 @@
package dev.sonpx.loyalty.mcp.client.model.reward;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.Data;
import java.time.OffsetDateTime;
import java.util.List;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class CampaignRuleDto {
private ReferenceData campaignId;
private String ruleId;
private String ruleName;
private RuleTypeEnum ruleType;
private String description;
private OffsetDateTime effectiveFrom;
private OffsetDateTime effectiveTo;
private ReferenceData poolId;
private CampaignFormulaOneDto campaignFormulaOne;
private List<String> formulaSequence;
public enum RuleTypeEnum {
AWD("AWD"),
RED("RED"),
ADJ("ADJ"),
REP("REP"),
MAWD("MAWD"),
CEP("CEP");
private String value;
RuleTypeEnum(String value) {
this.value = value;
}
@JsonValue
public String getValue() {
return value;
}
@Override
public String toString() {
return String.valueOf(value);
}
public static RuleTypeEnum fromValue(String value) {
for (RuleTypeEnum b : RuleTypeEnum.values()) {
if (b.value.equals(value)) {
return b;
}
}
throw new IllegalArgumentException("Unexpected value '" + value + "'");
}
}
}

View File

@@ -0,0 +1,13 @@
package dev.sonpx.loyalty.mcp.client.model.reward;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class OperationResultCampaignRuleDto {
private Boolean success;
private String errorCode;
private String errorMessage;
private CampaignRuleDto data;
}

View File

@@ -0,0 +1,10 @@
package dev.sonpx.loyalty.mcp.client.model.reward;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class ReferenceData {
private String code;
}

View File

@@ -0,0 +1,105 @@
package dev.sonpx.loyalty.mcp.config;
import dev.sonpx.loyalty.mcp.client.api.CampaignApiClient;
import dev.sonpx.loyalty.mcp.client.api.MemberTierApiClient;
import dev.sonpx.loyalty.mcp.client.api.PointPoolApiClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.AuthorityUtils;
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;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/**
* Configures OAuth2-enabled RestClient instances and Spring HTTP interface clients.
*/
@Configuration
public class ApiClientConfig {
@Value("${loyalty.core.base-url}")
private String coreBaseUrl;
@Bean
public RestClient loyaltyRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
return RestClient.builder()
.baseUrl(coreBaseUrl)
.requestInterceptor((request, body, execution) -> {
Authentication principal =
new AnonymousAuthenticationToken(
"key", "loyalty-agent-service",
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) {
throw new IllegalStateException("Failed to authorize loyalty API request", e);
}
return execution.execute(request, body);
})
.build();
}
@Bean
public RestClient rewardRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
return RestClient.builder()
.baseUrl(coreBaseUrl + "/svc/reward")
.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) {
throw new IllegalStateException("Failed to authorize reward API request", e);
}
return execution.execute(request, body);
})
.build();
}
@Bean
public MemberTierApiClient memberTierApiClient(@Qualifier("loyaltyRestClient") RestClient loyaltyRestClient) {
return createClient(loyaltyRestClient, MemberTierApiClient.class);
}
@Bean
public PointPoolApiClient pointPoolApiClient(@Qualifier("rewardRestClient") RestClient rewardRestClient) {
return createClient(rewardRestClient, PointPoolApiClient.class);
}
@Bean
public CampaignApiClient campaignApiClient(@Qualifier("rewardRestClient") RestClient rewardRestClient) {
return createClient(rewardRestClient, CampaignApiClient.class);
}
private <T> T createClient(RestClient restClient, Class<T> clientType) {
HttpServiceProxyFactory factory = HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient))
.build();
return factory.createClient(clientType);
}
}

View File

@@ -0,0 +1,24 @@
package dev.sonpx.loyalty.mcp.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class AsyncTimeoutFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
filterChain.doFilter(request, response);
if (request.isAsyncStarted()) {
// Set async timeout to 24 hours (86400000 ms) to prevent SSE connections from dropping
request.getAsyncContext().setTimeout(86400000L);
}
}
}

View File

@@ -0,0 +1,34 @@
package dev.sonpx.loyalty.mcp.config;
import dev.sonpx.loyalty.mcp.client.api.CampaignApiClient;
import dev.sonpx.loyalty.mcp.client.api.MemberTierApiClient;
import dev.sonpx.loyalty.mcp.client.api.PointPoolApiClient;
import dev.sonpx.loyalty.mcp.tool.LoyaltyAgentTools;
import dev.sonpx.loyalty.mcp.tool.reward.CampaignRuleTools;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
@Configuration
public class McpServerConfig {
@Bean
public LoyaltyAgentTools loyaltyAgentTools(
MemberTierApiClient memberTierApiClient,
PointPoolApiClient pointPoolApiClient,
CampaignApiClient campaignApiClient,
@org.springframework.beans.factory.annotation.Qualifier("loyaltyRestClient") RestClient restClient) {
return new LoyaltyAgentTools(memberTierApiClient, pointPoolApiClient, campaignApiClient, restClient);
}
@Bean
public org.springframework.ai.tool.ToolCallbackProvider loyaltyToolsProvider(
LoyaltyAgentTools loyaltyAgentTools,
CampaignRuleTools campaignRuleTools,
dev.sonpx.loyalty.mcp.tool.reward.CampaignTools campaignTools) {
return org.springframework.ai.tool.method.MethodToolCallbackProvider.builder()
.toolObjects(loyaltyAgentTools, campaignRuleTools, campaignTools)
.build();
}
}

View File

@@ -1,4 +1,4 @@
package dev.sonpx.loyalty.agent.config; package dev.sonpx.loyalty.mcp.config;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;

View File

@@ -0,0 +1,44 @@
package dev.sonpx.loyalty.mcp.draft;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto;
import dev.sonpx.loyalty.mcp.draft.model.DraftSession;
import org.springframework.stereotype.Component;
@Component
public class DependencyValidator {
public void validateCampaignRequiredFields(CampaignDto campaign) {
if (campaign == null) {
throw new IllegalArgumentException("Campaign draft is empty");
}
if (campaign.getCampaignId() == null || campaign.getCampaignId().isBlank()) {
throw new IllegalArgumentException("Campaign ID is required");
}
if (campaign.getName() == null || campaign.getName().isBlank()) {
throw new IllegalArgumentException("Campaign name is required");
}
}
public void validateRulePrerequisites(DraftSession session, String targetCampaignId, String transactionCode, boolean needsPoolId, String poolId) {
if (targetCampaignId == null || targetCampaignId.isBlank()) {
throw new IllegalArgumentException("Target Campaign ID is required to add a rule");
}
// In a real system, we might check if targetCampaignId matches the one in draft,
// or check the Core API if the campaign exists.
if (transactionCode == null || transactionCode.isBlank()) {
throw new IllegalArgumentException("Transaction Code is required");
}
if (needsPoolId && (poolId == null || poolId.isBlank())) {
throw new IllegalArgumentException("Pool ID is required for the selected rule flow");
}
}
public void validateSopPrerequisites(String poolId) {
if (poolId == null || poolId.isBlank()) {
throw new IllegalArgumentException("Existing Pool ID is required to create a Statement Output Pool");
}
}
}

View File

@@ -0,0 +1,58 @@
package dev.sonpx.loyalty.mcp.draft;
import dev.sonpx.loyalty.mcp.draft.model.DraftSession;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
@Component
public class DraftSessionManager {
private final Map<String, DraftSession> sessions = new ConcurrentHashMap<>();
private static final Duration TTL = Duration.ofHours(1);
public DraftSession getSession(String conversationId) {
if (conversationId == null) {
throw new IllegalArgumentException("conversationId cannot be null");
}
cleanupExpiredSessions();
return sessions.compute(conversationId, (id, session) -> {
if (session == null || isExpired(session)) {
return new DraftSession(id);
}
session.setLastUpdated(Instant.now());
return session;
});
}
public void updateSession(String conversationId, Consumer<DraftSession> updater) {
if (conversationId == null) {
throw new IllegalArgumentException("conversationId cannot be null");
}
cleanupExpiredSessions();
sessions.compute(conversationId, (id, session) -> {
if (session == null || isExpired(session)) {
session = new DraftSession(id);
}
updater.accept(session);
session.setLastUpdated(Instant.now());
return session;
});
}
public void removeSession(String conversationId) {
sessions.remove(conversationId);
}
private boolean isExpired(DraftSession session) {
return Instant.now().isAfter(session.getLastUpdated().plus(TTL));
}
private void cleanupExpiredSessions() {
sessions.entrySet().removeIf(entry -> isExpired(entry.getValue()));
}
}

View File

@@ -0,0 +1,14 @@
package dev.sonpx.loyalty.mcp.draft.model;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignRuleDto;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class CampaignDraft {
private CampaignDto campaign = new CampaignDto();
private List<CampaignRuleDto> rules = new ArrayList<>();
}

View File

@@ -0,0 +1,20 @@
package dev.sonpx.loyalty.mcp.draft.model;
import lombok.Data;
import java.time.Instant;
@Data
public class DraftSession {
private String conversationId;
private Instant lastUpdated;
private CampaignDraft campaignDraft;
private PoolDraft poolDraft;
public DraftSession(String conversationId) {
this.conversationId = conversationId;
this.lastUpdated = Instant.now();
this.campaignDraft = new CampaignDraft();
this.poolDraft = new PoolDraft();
}
}

View File

@@ -0,0 +1,11 @@
package dev.sonpx.loyalty.mcp.draft.model;
import lombok.Data;
import java.util.HashMap;
import java.util.Map;
@Data
public class PoolDraft {
private Map<String, Object> poolDefinition = new HashMap<>();
private Map<String, Object> statementOutputPool = new HashMap<>();
}

View File

@@ -0,0 +1,15 @@
package dev.sonpx.loyalty.mcp.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class McpResponseWrapper<T> {
private T data;
private Presentation presentation;
}

View File

@@ -0,0 +1,15 @@
package dev.sonpx.loyalty.mcp.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Presentation {
private String type; // e.g. "markdown"
private String content; // The formatted string
}

View File

@@ -0,0 +1,77 @@
package dev.sonpx.loyalty.mcp.tool;
import dev.sonpx.loyalty.mcp.client.api.CampaignApiClient;
import dev.sonpx.loyalty.mcp.client.api.MemberTierApiClient;
import dev.sonpx.loyalty.mcp.client.api.PointPoolApiClient;
import dev.sonpx.loyalty.mcp.client.model.attribute.StaticAttributeDto;
import dev.sonpx.loyalty.mcp.client.model.Campaign;
import dev.sonpx.loyalty.mcp.client.model.CreateCampaignRequest;
import dev.sonpx.loyalty.mcp.client.model.MemberTier;
import dev.sonpx.loyalty.mcp.client.model.OperationResult;
import dev.sonpx.loyalty.mcp.client.model.PointPool;
import org.springframework.ai.tool.annotation.Tool;
import java.util.List;
import org.springframework.core.ParameterizedTypeReference;
public class LoyaltyAgentTools {
private final MemberTierApiClient memberTierApiClient;
private final PointPoolApiClient pointPoolApiClient;
private final CampaignApiClient campaignApiClient;
private final org.springframework.web.client.RestClient restClient;
public LoyaltyAgentTools(MemberTierApiClient memberTierApiClient, PointPoolApiClient pointPoolApiClient, CampaignApiClient campaignApiClient, org.springframework.web.client.RestClient restClient) {
this.memberTierApiClient = memberTierApiClient;
this.pointPoolApiClient = pointPoolApiClient;
this.campaignApiClient = campaignApiClient;
this.restClient = restClient;
}
@Tool(description = "List all available Member Tiers. Always use this to verify a Tier exists before creating a Campaign.")
public List<MemberTier> listMemberTiers() {
return memberTierApiClient.listMemberTiers();
}
@Tool(description = "List all available Point Pools. Always use this to verify a Pool exists and has sufficient balance before creating a Campaign.")
public List<PointPool> listPointPools() {
return pointPoolApiClient.listPointPools();
}
@Tool(description = "Create a new Campaign. ONLY use this when explicitly asked to create or add a new campaign. Do not use this to fetch information about existing campaigns. This requires a valid targetTierId and rewardPoolId, which MUST be verified beforehand using listMemberTiers and listPointPools.")
public Campaign createCampaign(CreateCampaignRequest request) {
String campaignId = request.campaignId();
if (campaignId == null || campaignId.isBlank()) {
campaignId = campaignApiClient.generateCampaignId();
}
Campaign draft = new Campaign(
campaignId,
request.name(),
request.targetTierId(),
request.rewardPoolId(),
null
);
OperationResult<Campaign> result = campaignApiClient.createCampaignDraft(draft);
if (result == null || result.entity() == null) {
String message = result != null ? result.message() : "empty response";
throw new IllegalStateException("Campaign create-draft did not return an entity: " + message);
}
return result.entity();
}
@Tool(description = "List all available Static Attributes. Use this tool when you need to find configurations, properties, or static values like tier types or currencies.")
public List<StaticAttributeDto> listStaticAttributes() {
try {
String url = "/svc/attribute/api/static-attribute/csr/view/list?sort=lastApproveDate,desc&sort=lastUpdateDate,desc&page=0&size=10&status.in=A";
return restClient.get()
.uri(url)
.retrieve()
.body(new ParameterizedTypeReference<>() {
});
} catch (Exception e) {
throw new RuntimeException("Failed to fetch static attributes", e);
}
}
}

View File

@@ -0,0 +1,20 @@
package dev.sonpx.loyalty.mcp.tool.model.pool;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
public record CreatePoolDefinitionRequest(
@JsonPropertyDescription("Conversation ID from the agent")
String conversationId,
@JsonPropertyDescription("Pool ID (max 10 chars)")
String poolId,
@JsonPropertyDescription("Pool Name")
String poolName,
@JsonPropertyDescription("Pool Type (e.g. STANDARD, PROMOTIONAL)")
String poolType,
@JsonPropertyDescription("Currency Code")
String currencyCode
) {}

View File

@@ -0,0 +1,20 @@
package dev.sonpx.loyalty.mcp.tool.model.pool;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
public record CreateSopRequest(
@JsonPropertyDescription("Conversation ID from the agent")
String conversationId,
@JsonPropertyDescription("Statement Output Pool ID")
String sopId,
@JsonPropertyDescription("Reference Pool ID (must exist)")
String poolId,
@JsonPropertyDescription("SOP Name")
String name,
@JsonPropertyDescription("Statement Type")
String statementType
) {}

View File

@@ -0,0 +1,23 @@
package dev.sonpx.loyalty.mcp.tool.model.reward;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
public record AddRuleToCampaignRequest(
@JsonPropertyDescription("Conversation ID from the agent")
String conversationId,
@JsonPropertyDescription("Target Campaign ID")
String targetCampaignId,
@JsonPropertyDescription("Transaction Code to trigger the rule")
String transactionCode,
@JsonPropertyDescription("Whether this rule flow requires a Pool ID")
boolean needsPoolId,
@JsonPropertyDescription("Pool ID for the rule (if needed)")
String poolId,
@JsonPropertyDescription("Rule details")
CreateCampaignRuleToolRequest ruleDetails
) {}

View File

@@ -0,0 +1,41 @@
package dev.sonpx.loyalty.mcp.tool.model.reward;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import java.time.OffsetDateTime;
public record CreateCampaignRuleToolRequest(
@JsonPropertyDescription("ID of the campaign this rule belongs to")
String campaignId,
@JsonPropertyDescription("Unique identifier for the rule (max 10 chars)")
String ruleId,
@JsonPropertyDescription("Name of the rule")
String ruleName,
@JsonPropertyDescription("Type of the rule (e.g., AWD for Award, RED for Redemption, ADJ for Adjustment)")
String ruleType,
@JsonPropertyDescription("Description of the rule")
String description,
@JsonPropertyDescription("Effective start date of the rule")
OffsetDateTime effectiveFrom,
@JsonPropertyDescription("Effective end date of the rule")
OffsetDateTime effectiveTo,
@JsonPropertyDescription("ID of the Point Pool to be updated by this rule")
String poolId,
@JsonPropertyDescription("Formula One configuration for basic points awarding (points per txn amount)")
FormulaOneConfig formulaOne
) {
public record FormulaOneConfig(
@JsonPropertyDescription("Amount of points awarded per transaction amount")
Double awardPerTxnAmt,
@JsonPropertyDescription("Required transaction amount to receive the award")
Double txnAmt
) {}
}

View File

@@ -0,0 +1,11 @@
package dev.sonpx.loyalty.mcp.tool.model.reward;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ListCampaignRuleToolRequest(
String campaignId,
Integer page,
Integer size
) {
}

View File

@@ -0,0 +1,8 @@
package dev.sonpx.loyalty.mcp.tool.model.reward;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
public record SubmitCampaignDraftRequest(
@JsonPropertyDescription("Conversation ID from the agent")
String conversationId
) {}

View File

@@ -0,0 +1,17 @@
package dev.sonpx.loyalty.mcp.tool.model.reward;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
public record UpdateCampaignDraftRequest(
@JsonPropertyDescription("Conversation ID from the agent")
String conversationId,
@JsonPropertyDescription("Campaign ID")
String campaignId,
@JsonPropertyDescription("Campaign Name")
String name,
@JsonPropertyDescription("Target Tier ID")
String targetTierId
) {}

View File

@@ -0,0 +1,34 @@
package dev.sonpx.loyalty.mcp.tool.model.reward;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import java.time.OffsetDateTime;
public record UpdateCampaignRuleToolRequest(
@JsonPropertyDescription("ID of the campaign this rule belongs to")
String campaignId,
@JsonPropertyDescription("Unique identifier for the rule (max 10 chars)")
String ruleId,
@JsonPropertyDescription("Name of the rule")
String ruleName,
@JsonPropertyDescription("Type of the rule (e.g., AWD for Award, RED for Redemption, ADJ for Adjustment)")
String ruleType,
@JsonPropertyDescription("Description of the rule")
String description,
@JsonPropertyDescription("Effective start date of the rule")
OffsetDateTime effectiveFrom,
@JsonPropertyDescription("Effective end date of the rule")
OffsetDateTime effectiveTo,
@JsonPropertyDescription("ID of the Point Pool to be updated by this rule")
String poolId,
@JsonPropertyDescription("Formula One configuration for basic points awarding (points per txn amount)")
CreateCampaignRuleToolRequest.FormulaOneConfig formulaOne
) {
}

View File

@@ -0,0 +1,86 @@
package dev.sonpx.loyalty.mcp.tool.pool;
import dev.sonpx.loyalty.mcp.draft.DependencyValidator;
import dev.sonpx.loyalty.mcp.draft.DraftSessionManager;
import dev.sonpx.loyalty.mcp.tool.model.pool.CreatePoolDefinitionRequest;
import dev.sonpx.loyalty.mcp.tool.model.pool.CreateSopRequest;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.util.Map;
@Component
public class PointPoolTools {
private final DraftSessionManager draftSessionManager;
private final DependencyValidator dependencyValidator;
private final RestClient restClient;
public PointPoolTools(DraftSessionManager draftSessionManager, DependencyValidator dependencyValidator, @Qualifier("rewardRestClient") RestClient restClient) {
this.draftSessionManager = draftSessionManager;
this.dependencyValidator = dependencyValidator;
this.restClient = restClient;
}
@Tool(description = "Create a Pool Definition. This builds a PoolDefinitionDto and submits it via Core API.")
public Map<String, Object> createPoolDefinition(CreatePoolDefinitionRequest request) {
if (request.conversationId() == null || request.conversationId().isBlank()) {
throw new IllegalArgumentException("conversationId is required");
}
// Store in draft
draftSessionManager.updateSession(request.conversationId(), session -> {
Map<String, Object> poolDef = session.getPoolDraft().getPoolDefinition();
poolDef.put("poolId", request.poolId());
poolDef.put("poolName", request.poolName());
poolDef.put("poolType", request.poolType());
poolDef.put("currencyCode", request.currencyCode());
});
// Submit via Core API
try {
return restClient.post()
.uri("/api/pool-definition/csr/create-draft")
.body(request)
.retrieve()
.body(new ParameterizedTypeReference<>() {});
} catch (Exception e) {
e.printStackTrace();
return Map.of("error", "Failed to create pool definition draft via Core API: " + e.getMessage());
}
}
@Tool(description = "Create a Statement Output Pool (SOP). This builds a StatementOutputPoolDto and requires an existing Pool ID.")
public Map<String, Object> createSop(CreateSopRequest request) {
if (request.conversationId() == null || request.conversationId().isBlank()) {
throw new IllegalArgumentException("conversationId is required");
}
// Validate prerequisite: existing Pool ID
dependencyValidator.validateSopPrerequisites(request.poolId());
// Store in draft
draftSessionManager.updateSession(request.conversationId(), session -> {
Map<String, Object> sop = session.getPoolDraft().getStatementOutputPool();
sop.put("sopId", request.sopId());
sop.put("poolId", request.poolId());
sop.put("name", request.name());
sop.put("statementType", request.statementType());
});
// Submit via Core API
try {
return restClient.post()
.uri("/api/sop/csr/create-draft")
.body(request)
.retrieve()
.body(new ParameterizedTypeReference<>() {});
} catch (Exception e) {
e.printStackTrace();
return Map.of("error", "Failed to create Statement Output Pool draft via Core API: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,77 @@
package dev.sonpx.loyalty.mcp.tool.reward;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignRuleDto;
import dev.sonpx.loyalty.mcp.client.model.reward.ReferenceData;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignFormulaOneDto;
import dev.sonpx.loyalty.mcp.tool.model.reward.CreateCampaignRuleToolRequest;
import dev.sonpx.loyalty.mcp.tool.model.reward.UpdateCampaignRuleToolRequest;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
@Component
public class CampaignRuleMapper {
public CampaignRuleDto toDto(CreateCampaignRuleToolRequest request) {
CampaignRuleDto dto = new CampaignRuleDto();
dto.setCampaignId(toReferenceData(request.campaignId()));
dto.setRuleId(request.ruleId());
dto.setRuleName(request.ruleName());
if (request.ruleType() != null) {
dto.setRuleType(CampaignRuleDto.RuleTypeEnum.fromValue(request.ruleType()));
}
dto.setDescription(request.description());
dto.setEffectiveFrom(request.effectiveFrom());
dto.setEffectiveTo(request.effectiveTo());
dto.setPoolId(toReferenceData(request.poolId()));
if (request.formulaOne() != null) {
CampaignFormulaOneDto formulaOne = new CampaignFormulaOneDto();
formulaOne.setAwardPerTxnAmt(request.formulaOne().awardPerTxnAmt());
formulaOne.setTxnAmt(request.formulaOne().txnAmt());
dto.setCampaignFormulaOne(formulaOne);
dto.setFormulaSequence(new ArrayList<>());
dto.getFormulaSequence().add("1");
}
return dto;
}
public CampaignRuleDto toDto(UpdateCampaignRuleToolRequest request) {
CampaignRuleDto dto = new CampaignRuleDto();
dto.setCampaignId(toReferenceData(request.campaignId()));
dto.setRuleId(request.ruleId());
dto.setRuleName(request.ruleName());
if (request.ruleType() != null) {
dto.setRuleType(CampaignRuleDto.RuleTypeEnum.fromValue(request.ruleType()));
}
dto.setDescription(request.description());
dto.setEffectiveFrom(request.effectiveFrom());
dto.setEffectiveTo(request.effectiveTo());
dto.setPoolId(toReferenceData(request.poolId()));
if (request.formulaOne() != null) {
CampaignFormulaOneDto formulaOne = new CampaignFormulaOneDto();
formulaOne.setAwardPerTxnAmt(request.formulaOne().awardPerTxnAmt());
formulaOne.setTxnAmt(request.formulaOne().txnAmt());
dto.setCampaignFormulaOne(formulaOne);
dto.setFormulaSequence(new ArrayList<>());
dto.getFormulaSequence().add("1");
}
return dto;
}
private ReferenceData toReferenceData(String code) {
if (code == null) {
return null;
}
ReferenceData ref = new ReferenceData();
ref.setCode(code);
return ref;
}
}

View File

@@ -0,0 +1,211 @@
package dev.sonpx.loyalty.mcp.tool.reward;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignRuleDto;
import dev.sonpx.loyalty.mcp.client.model.reward.OperationResultCampaignRuleDto;
import dev.sonpx.loyalty.mcp.draft.DependencyValidator;
import dev.sonpx.loyalty.mcp.draft.DraftSessionManager;
import dev.sonpx.loyalty.mcp.tool.model.reward.AddRuleToCampaignRequest;
import dev.sonpx.loyalty.mcp.tool.model.reward.CreateCampaignRuleToolRequest;
import dev.sonpx.loyalty.mcp.tool.model.reward.UpdateCampaignRuleToolRequest;
import dev.sonpx.loyalty.mcp.tool.model.reward.ListCampaignRuleToolRequest;
import dev.sonpx.loyalty.mcp.model.McpResponseWrapper;
import dev.sonpx.loyalty.mcp.model.Presentation;
import dev.sonpx.loyalty.mcp.util.MarkdownGenerator;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.UriComponentsBuilder;
@Component
public class CampaignRuleTools {
private static final Logger log = LoggerFactory.getLogger(CampaignRuleTools.class);
private final CampaignRuleMapper campaignRuleMapper;
private final RestClient rewardRestClient;
private final DraftSessionManager draftSessionManager;
private final DependencyValidator dependencyValidator;
public CampaignRuleTools(
CampaignRuleMapper campaignRuleMapper,
@Qualifier("rewardRestClient") RestClient rewardRestClient,
DraftSessionManager draftSessionManager,
DependencyValidator dependencyValidator) {
this.campaignRuleMapper = campaignRuleMapper;
this.rewardRestClient = rewardRestClient;
this.draftSessionManager = draftSessionManager;
this.dependencyValidator = dependencyValidator;
}
@Tool(description = "Add Rule to local Campaign Draft. Validates prerequisites (Transaction Code, Pool ID) before adding.")
public Map<String, Object> addRuleToCampaign(AddRuleToCampaignRequest request) {
if (request.conversationId() == null || request.conversationId().isBlank()) {
throw new IllegalArgumentException("conversationId is required");
}
// Validate dependencies
dependencyValidator.validateRulePrerequisites(
draftSessionManager.getSession(request.conversationId()),
request.targetCampaignId(),
request.transactionCode(),
request.needsPoolId(),
request.poolId()
);
// Update local draft
draftSessionManager.updateSession(request.conversationId(), session -> {
CampaignRuleDto rule = campaignRuleMapper.toDto(request.ruleDetails());
dev.sonpx.loyalty.mcp.client.model.reward.ReferenceData ref = new dev.sonpx.loyalty.mcp.client.model.reward.ReferenceData();
ref.setCode(request.targetCampaignId());
rule.setCampaignId(ref);
session.getCampaignDraft().getRules().add(rule);
});
return Map.of("status", "Rule added to Campaign draft successfully", "ruleId", request.ruleDetails().ruleId());
}
@Tool(description = "Create a new Campaign Rule for an existing Campaign. Supports basic properties and Formula One configuration for points per transaction amount.")
public OperationResultCampaignRuleDto createCampaignRule(CreateCampaignRuleToolRequest request) {
CampaignRuleDto dto = campaignRuleMapper.toDto(request);
try {
return rewardRestClient.post()
.uri("/api/campaign-rule/csr/create")
.body(dto)
.retrieve()
.body(OperationResultCampaignRuleDto.class);
} catch (Exception e) {
log.error("Failed to create Campaign Rule", e);
throw new RuntimeException("Failed to create Campaign Rule: " + e.getMessage(), e);
}
}
@Tool(description = "Edit an existing Campaign Rule. You must provide all required fields (campaignId, ruleId, ruleName, etc) as this will update the entire rule configuration.")
public OperationResultCampaignRuleDto editCampaignRule(UpdateCampaignRuleToolRequest request) {
CampaignRuleDto dto = campaignRuleMapper.toDto(request);
try {
return rewardRestClient.patch()
.uri("/api/campaign-rule/csr/edit")
.body(dto)
.retrieve()
.body(OperationResultCampaignRuleDto.class);
} catch (Exception e) {
log.error("Failed to edit Campaign Rule", e);
throw new RuntimeException("Failed to edit Campaign Rule: " + e.getMessage(), e);
}
}
@Tool(description = "List Campaign Rules, optionally filtered by campaignId. Use this tool to get a list of campaign rules. If the response contains a `presentation.content` field, display its content exactly as provided to the user. Use `data` only for your internal reasoning.")
public McpResponseWrapper<List<CampaignRuleDto>> listCampaignRules(
@org.springframework.lang.Nullable String campaignId,
@org.springframework.lang.Nullable Integer page,
@org.springframework.lang.Nullable Integer size) {
int pageNum = page != null ? page : 0;
int sizeNum = size != null ? size : 20;
try {
String jsonResponse = rewardRestClient.get()
.uri(uriBuilder -> {
uriBuilder.path("/api/campaign-rule/csr/list")
.queryParam("page", pageNum)
.queryParam("size", sizeNum);
if (campaignId != null && !campaignId.isEmpty()) {
uriBuilder.queryParam("campaignId.equals", campaignId);
}
return uriBuilder.build();
})
.retrieve()
.body(String.class);
// Fix missing timezone in date-time strings from backend (e.g. "2026-06-25T16:16:01" -> "2026-06-25T16:16:01Z")
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})\"", "\"$1Z\"");
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]+)\"", "\"$1Z\"");
// Try to map it manually
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
mapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
mapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
try {
CampaignRuleDto[] rules = mapper.readValue(jsonResponse, CampaignRuleDto[].class);
return wrapCampaignRules(rules != null ? List.of(rules) : List.of());
} catch (Exception parseEx) {
System.out.println("Failed to parse as array. Trying to extract from pageable format...");
com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(jsonResponse);
if (root.has("content")) {
CampaignRuleDto[] rules = mapper.treeToValue(root.get("content"), CampaignRuleDto[].class);
return wrapCampaignRules(rules != null ? List.of(rules) : List.of());
} else if (root.has("data")) {
CampaignRuleDto[] rules = mapper.treeToValue(root.get("data"), CampaignRuleDto[].class);
return wrapCampaignRules(rules != null ? List.of(rules) : List.of());
}
throw parseEx;
}
} catch (Exception e) {
log.error("Failed to list Campaign Rules", e);
throw new RuntimeException("Failed to list Campaign Rules: " + e.getMessage(), e);
}
}
@Tool(description = "List Campaign Rules by Campaign Name. It finds the campaign by name and returns its rules. Use this tool when the user provides a campaign name and asks for its campaign rules. If the response contains a `presentation.content` field, display its content exactly as provided to the user. Use `data` only for your internal reasoning.")
public McpResponseWrapper<List<CampaignRuleDto>> listCampaignRulesByCampaignName(String campaignName) {
try {
String jsonResponse = rewardRestClient.get()
.uri(uriBuilder -> uriBuilder
.path("/api/campaign/csr/list")
.queryParam("name.contains", campaignName)
.queryParam("page", 0)
.queryParam("size", 1)
.build()
)
.retrieve()
.body(String.class);
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})\"", "\"$1Z\"");
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]+)\"", "\"$1Z\"");
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
mapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
mapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto[] campaigns = null;
try {
campaigns = mapper.readValue(jsonResponse, dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto[].class);
} catch (Exception parseEx) {
com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(jsonResponse);
if (root.has("content")) {
campaigns = mapper.treeToValue(root.get("content"), dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto[].class);
} else if (root.has("data")) {
campaigns = mapper.treeToValue(root.get("data"), dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto[].class);
} else {
throw parseEx;
}
}
if (campaigns == null || campaigns.length == 0) {
return wrapCampaignRules(List.of());
}
String campaignId = campaigns[0].getCampaignId();
return listCampaignRules(campaignId, 0, 100);
} catch (Exception e) {
log.error("Failed to list Campaign Rules by Campaign Name", e);
throw new RuntimeException("Failed to list Campaign Rules by Campaign Name: " + e.getMessage(), e);
}
}
private McpResponseWrapper<List<CampaignRuleDto>> wrapCampaignRules(List<CampaignRuleDto> data) {
StringBuilder markdown = new StringBuilder();
if (data.isEmpty()) {
markdown.append("No campaign rules found.");
} else {
for (CampaignRuleDto r : data) {
markdown.append(MarkdownGenerator.generateCampaignRuleMarkdown(r)).append("\n");
}
}
return new McpResponseWrapper<>(data, new Presentation("markdown", markdown.toString()));
}
}

View File

@@ -0,0 +1,150 @@
package dev.sonpx.loyalty.mcp.tool.reward;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto;
import dev.sonpx.loyalty.mcp.draft.DependencyValidator;
import dev.sonpx.loyalty.mcp.draft.DraftSessionManager;
import dev.sonpx.loyalty.mcp.draft.model.DraftSession;
import dev.sonpx.loyalty.mcp.tool.model.reward.SubmitCampaignDraftRequest;
import dev.sonpx.loyalty.mcp.tool.model.reward.UpdateCampaignDraftRequest;
import dev.sonpx.loyalty.mcp.model.McpResponseWrapper;
import dev.sonpx.loyalty.mcp.model.Presentation;
import dev.sonpx.loyalty.mcp.util.MarkdownGenerator;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Component
public class CampaignTools {
private static final Logger log = LoggerFactory.getLogger(CampaignTools.class);
private final RestClient rewardRestClient;
private final DraftSessionManager draftSessionManager;
private final DependencyValidator dependencyValidator;
public CampaignTools(
@Qualifier("rewardRestClient") RestClient rewardRestClient,
DraftSessionManager draftSessionManager,
DependencyValidator dependencyValidator) {
this.rewardRestClient = rewardRestClient;
this.draftSessionManager = draftSessionManager;
this.dependencyValidator = dependencyValidator;
}
@Tool(description = "List or Search Campaigns, optionally filtered by campaignId, name, or status. ALWAYS use this tool when the user asks for information, details, or status of an existing campaign. If the response contains a `presentation.content` field, display its content exactly as provided to the user. Use `data` only for your internal reasoning.")
public McpResponseWrapper<List<CampaignDto>> listCampaigns(
@org.springframework.lang.Nullable String campaignId,
@org.springframework.lang.Nullable String name,
@org.springframework.lang.Nullable String status,
@org.springframework.lang.Nullable Integer page,
@org.springframework.lang.Nullable Integer size) {
int pageNum = page != null ? page : 0;
int sizeNum = size != null ? size : 20;
try {
String jsonResponse = rewardRestClient.get()
.uri(uriBuilder -> {
uriBuilder.path("/api/campaign/csr/list")
.queryParam("page", pageNum)
.queryParam("size", sizeNum);
if (campaignId != null && !campaignId.isEmpty()) {
uriBuilder.queryParam("campaignId.equals", campaignId);
}
if (name != null && !name.isEmpty()) {
uriBuilder.queryParam("name.contains", name);
}
if (status != null && !status.isEmpty()) {
uriBuilder.queryParam("status.equals", status);
}
return uriBuilder.build();
})
.retrieve()
.body(String.class);
// Fix missing timezone in date-time strings from backend (e.g. "2026-06-25T16:16:01" -> "2026-06-25T16:16:01Z")
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})\"", "\"$1Z\"");
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]+)\"", "\"$1Z\"");
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
mapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
mapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
CampaignDto[] campaigns = null;
try {
campaigns = mapper.readValue(jsonResponse, CampaignDto[].class);
} catch (Exception parseEx) {
com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(jsonResponse);
if (root.has("content")) {
campaigns = mapper.treeToValue(root.get("content"), CampaignDto[].class);
} else if (root.has("data")) {
campaigns = mapper.treeToValue(root.get("data"), CampaignDto[].class);
} else {
throw parseEx;
}
}
List<CampaignDto> data = campaigns != null ? List.of(campaigns) : List.of();
StringBuilder markdown = new StringBuilder();
if (data.isEmpty()) {
markdown.append("No campaigns found.");
} else {
for (CampaignDto c : data) {
markdown.append(MarkdownGenerator.generateCampaignMarkdown(c)).append("\n");
}
}
return new McpResponseWrapper<>(data, new Presentation("markdown", markdown.toString()));
} catch (Exception e) {
log.error("Failed to list Campaigns", e);
throw new RuntimeException("Failed to list Campaigns: " + e.getMessage(), e);
}
}
@Tool(description = "Update local Campaign Draft.")
public Map<String, Object> updateCampaignDraft(UpdateCampaignDraftRequest request) {
if (request.conversationId() == null || request.conversationId().isBlank()) {
throw new IllegalArgumentException("conversationId is required");
}
draftSessionManager.updateSession(request.conversationId(), session -> {
CampaignDto campaign = session.getCampaignDraft().getCampaign();
if (request.campaignId() != null) campaign.setCampaignId(request.campaignId());
if (request.name() != null) campaign.setName(request.name());
if (request.targetTierId() != null) campaign.setTargetTierId(request.targetTierId());
});
return Map.of("status", "Campaign draft updated successfully", "campaignId", request.campaignId());
}
@Tool(description = "Submit the completed CampaignDraft to Core API.")
public Map<String, Object> submitCampaignDraft(SubmitCampaignDraftRequest request) {
if (request.conversationId() == null || request.conversationId().isBlank()) {
throw new IllegalArgumentException("conversationId is required");
}
DraftSession session = draftSessionManager.getSession(request.conversationId());
CampaignDto campaign = session.getCampaignDraft().getCampaign();
// Validate
dependencyValidator.validateCampaignRequiredFields(campaign);
// Submit via Core API
try {
return rewardRestClient.post()
.uri("/api/campaign/csr/create-draft")
.body(campaign)
.retrieve()
.body(new ParameterizedTypeReference<>() {});
} catch (Exception e) {
log.error("Failed to submit Campaign draft via Core API", e);
return Map.of("error", "Failed to submit Campaign draft via Core API: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,60 @@
package dev.sonpx.loyalty.mcp.util;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignRuleDto;
public class MarkdownGenerator {
public static String generateCampaignMarkdown(CampaignDto campaign) {
if (campaign == null) {
return "No campaign data available.";
}
StringBuilder sb = new StringBuilder();
sb.append("### 🏆 Campaign Details\n\n");
sb.append("| Field | Value |\n");
sb.append("| :--- | :--- |\n");
sb.append("| **Campaign ID** | ").append(campaign.getCampaignId()).append(" |\n");
sb.append("| **Name** | ").append(campaign.getName()).append(" |\n");
sb.append("| **Status** | ").append(campaign.getStatus() != null ? campaign.getStatus() : "N/A").append(" |\n");
sb.append("| **Target Tier ID** | ").append(campaign.getTargetTierId() != null ? campaign.getTargetTierId() : "N/A").append(" |\n");
sb.append("| **Reward Pool ID** | ").append(campaign.getRewardPoolId() != null ? campaign.getRewardPoolId() : "N/A").append(" |\n");
if (campaign.getDescription() != null) {
sb.append("| **Description** | ").append(campaign.getDescription()).append(" |\n");
}
if (campaign.getExtraData() != null && !campaign.getExtraData().isEmpty()) {
for (java.util.Map.Entry<String, Object> entry : campaign.getExtraData().entrySet()) {
String key = entry.getKey();
String formattedKey = key.substring(0, 1).toUpperCase() + key.substring(1).replaceAll("([A-Z])", " $1");
sb.append("| **").append(formattedKey).append("** | ").append(entry.getValue()).append(" |\n");
}
}
return sb.toString();
}
public static String generateCampaignRuleMarkdown(CampaignRuleDto rule) {
if (rule == null) {
return "No campaign rule data available.";
}
String campaignCodeStr = (rule.getCampaignId() != null && rule.getCampaignId().getCode() != null) ? rule.getCampaignId().getCode() : "N/A";
String poolCodeStr = (rule.getPoolId() != null && rule.getPoolId().getCode() != null) ? rule.getPoolId().getCode() : "N/A";
StringBuilder sb = new StringBuilder();
sb.append("### 📜 Campaign Rule Details\n\n");
sb.append("| Field | Value |\n");
sb.append("| :--- | :--- |\n");
sb.append("| **Rule ID** | ").append(rule.getRuleId()).append(" |\n");
sb.append("| **Rule Name** | ").append(rule.getRuleName()).append(" |\n");
sb.append("| **Rule Type** | ").append(rule.getRuleType() != null ? rule.getRuleType().getValue() : "N/A").append(" |\n");
sb.append("| **Campaign ID** | ").append(campaignCodeStr).append(" |\n");
sb.append("| **Pool ID** | ").append(poolCodeStr).append(" |\n");
sb.append("| **Description** | ").append(rule.getDescription() != null ? rule.getDescription() : "N/A").append(" |\n");
sb.append("| **Effective From** | ").append(rule.getEffectiveFrom() != null ? rule.getEffectiveFrom().toString() : "N/A").append(" |\n");
sb.append("| **Effective To** | ").append(rule.getEffectiveTo() != null ? rule.getEffectiveTo().toString() : "N/A").append(" |\n");
return sb.toString();
}
}

View File

@@ -0,0 +1,37 @@
server:
port: 9331
shutdown: immediate
spring:
mvc:
async:
request-timeout: 3600000
application:
name: loyalty-mcp-server
ai:
mcp:
server:
sse-endpoint: /sse
sse-message-endpoint: /message
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

View File

@@ -0,0 +1,40 @@
package dev.sonpx.loyalty.mcp.test;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignRuleDto;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;
public class TestParsing {
public static void main(String[] args) throws Exception {
String jsonResponse = new String(Files.readAllBytes(Paths.get("rules.json")));
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})\"", "\"$1Z\"");
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]+)\"", "\"$1Z\"");
System.out.println("Transformed JSON: " + jsonResponse.substring(0, Math.min(200, jsonResponse.length())));
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
try {
CampaignRuleDto[] rules = mapper.readValue(jsonResponse, CampaignRuleDto[].class);
System.out.println("Parsed directly as array, length: " + rules.length);
} catch (Exception parseEx) {
System.out.println("Failed to parse as array: " + parseEx.getMessage());
com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(jsonResponse);
if (root.has("content")) {
CampaignRuleDto[] rules = mapper.treeToValue(root.get("content"), CampaignRuleDto[].class);
System.out.println("Parsed from content, length: " + rules.length);
} else if (root.has("data")) {
CampaignRuleDto[] rules = mapper.treeToValue(root.get("data"), CampaignRuleDto[].class);
System.out.println("Parsed from data, length: " + rules.length);
} else {
throw parseEx;
}
}
}
}

View File

@@ -0,0 +1,93 @@
package dev.sonpx.loyalty.mcp.tool;
import dev.sonpx.loyalty.mcp.client.api.CampaignApiClient;
import dev.sonpx.loyalty.mcp.client.api.MemberTierApiClient;
import dev.sonpx.loyalty.mcp.client.api.PointPoolApiClient;
import dev.sonpx.loyalty.mcp.client.model.Campaign;
import dev.sonpx.loyalty.mcp.client.model.CreateCampaignRequest;
import dev.sonpx.loyalty.mcp.client.model.MemberTier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.springframework.http.HttpMethod.GET;
import static org.springframework.http.HttpMethod.POST;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class LoyaltyAgentToolsRealApiTest {
private MockRestServiceServer loyaltyServer;
private MockRestServiceServer rewardServer;
private LoyaltyAgentTools tools;
@BeforeEach
void setUp() {
RestClient.Builder loyaltyRestClientBuilder = RestClient.builder().baseUrl("http://core.test");
loyaltyServer = MockRestServiceServer.bindTo(loyaltyRestClientBuilder).build();
RestClient loyaltyRestClient = loyaltyRestClientBuilder.build();
RestClient.Builder rewardRestClientBuilder = RestClient.builder().baseUrl("http://reward.test");
rewardServer = MockRestServiceServer.bindTo(rewardRestClientBuilder).build();
RestClient rewardRestClient = rewardRestClientBuilder.build();
MemberTierApiClient memberTierApiClient = createClient(loyaltyRestClient, MemberTierApiClient.class);
PointPoolApiClient pointPoolApiClient = createClient(rewardRestClient, PointPoolApiClient.class);
CampaignApiClient campaignApiClient = createClient(rewardRestClient, CampaignApiClient.class);
tools = new LoyaltyAgentTools(memberTierApiClient, pointPoolApiClient, campaignApiClient, loyaltyRestClient);
}
@Test
void listMemberTiersCallsRealEndpoint() {
loyaltyServer.expect(requestTo("http://core.test/svc/reward/api/decide-tier-view/list?page=0&size=100&status.in=A"))
.andExpect(method(GET))
.andRespond(withSuccess("[{\"key\":\"TIER_GOLD\",\"code\":\"GOLD\",\"name\":\"Gold\"}]", MediaType.APPLICATION_JSON));
MemberTier tier = tools.listMemberTiers().getFirst();
assertEquals("TIER_GOLD", tier.tierId());
assertEquals("Gold", tier.tierName());
loyaltyServer.verify();
}
@Test
void listPointPoolsCallsRealEndpoint() {
rewardServer.expect(requestTo("http://reward.test/api/pool-definition/all"))
.andExpect(method(GET))
.andRespond(withSuccess("[{\"poolId\":\"POOL_1\",\"poolName\":\"Main Pool\"}]", MediaType.APPLICATION_JSON));
assertEquals("POOL_1", tools.listPointPools().getFirst().poolId());
rewardServer.verify();
}
@Test
void createCampaignGeneratesIdAndUnwrapsOperationResultEntity() {
rewardServer.expect(requestTo("http://reward.test/api/campaign/csr/id/generate"))
.andExpect(method(GET))
.andRespond(withSuccess("CAMP_1", MediaType.TEXT_PLAIN));
rewardServer.expect(requestTo("http://reward.test/api/campaign/csr/create-draft"))
.andExpect(method(POST))
.andRespond(withSuccess(
"{\"message\":\"OK\",\"entity\":{\"campaignId\":\"CAMP_1\",\"name\":\"Summer\",\"status\":\"DRAFT\"}}",
MediaType.APPLICATION_JSON));
Campaign campaign = tools.createCampaign(new CreateCampaignRequest("Summer", "TIER_GOLD", "POOL_1"));
assertEquals("CAMP_1", campaign.campaignId());
assertEquals("DRAFT", campaign.status());
rewardServer.verify();
}
private static <T> T createClient(RestClient restClient, Class<T> clientType) {
HttpServiceProxyFactory factory = HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient))
.build();
return factory.createClient(clientType);
}
}

View File

@@ -0,0 +1,112 @@
package dev.sonpx.loyalty.mcp.tool;
import dev.sonpx.loyalty.mcp.draft.DependencyValidator;
import dev.sonpx.loyalty.mcp.draft.DraftSessionManager;
import dev.sonpx.loyalty.mcp.draft.model.DraftSession;
import dev.sonpx.loyalty.mcp.tool.model.pool.CreatePoolDefinitionRequest;
import dev.sonpx.loyalty.mcp.tool.model.pool.CreateSopRequest;
import dev.sonpx.loyalty.mcp.tool.model.reward.AddRuleToCampaignRequest;
import dev.sonpx.loyalty.mcp.tool.model.reward.CreateCampaignRuleToolRequest;
import dev.sonpx.loyalty.mcp.tool.model.reward.SubmitCampaignDraftRequest;
import dev.sonpx.loyalty.mcp.tool.model.reward.UpdateCampaignDraftRequest;
import dev.sonpx.loyalty.mcp.tool.pool.PointPoolTools;
import dev.sonpx.loyalty.mcp.tool.reward.CampaignRuleMapper;
import dev.sonpx.loyalty.mcp.tool.reward.CampaignRuleTools;
import dev.sonpx.loyalty.mcp.tool.reward.CampaignTools;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import java.time.OffsetDateTime;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import static org.springframework.http.HttpMethod.POST;
class McpOrchestratorTest {
private DraftSessionManager sessionManager;
private DependencyValidator dependencyValidator;
private MockRestServiceServer mockServer;
private PointPoolTools pointPoolTools;
private CampaignTools campaignTools;
private CampaignRuleTools campaignRuleTools;
@BeforeEach
void setUp() {
sessionManager = new DraftSessionManager();
dependencyValidator = new DependencyValidator();
RestClient.Builder restClientBuilder = RestClient.builder().baseUrl("http://reward.test");
mockServer = MockRestServiceServer.bindTo(restClientBuilder).build();
RestClient restClient = restClientBuilder.build();
pointPoolTools = new PointPoolTools(sessionManager, dependencyValidator, restClient);
campaignTools = new CampaignTools(restClient, sessionManager, dependencyValidator);
campaignRuleTools = new CampaignRuleTools(new CampaignRuleMapper(), restClient, sessionManager, dependencyValidator);
}
@Test
void testFullDependencyGraphAndErrorPropagation() {
String convId = "test-conv-123";
mockServer.expect(requestTo("http://reward.test/api/pool-definition/csr/create-draft"))
.andExpect(method(POST))
.andRespond(withSuccess("{\"poolId\":\"POOL-1\"}", MediaType.APPLICATION_JSON));
mockServer.expect(requestTo("http://reward.test/api/sop/csr/create-draft"))
.andExpect(method(POST))
.andRespond(withSuccess("{\"sopId\":\"SOP-1\"}", MediaType.APPLICATION_JSON));
mockServer.expect(requestTo("http://reward.test/api/campaign/csr/create-draft"))
.andExpect(method(POST))
.andRespond(withSuccess("{\"campaignId\":\"CAMP-1\",\"status\":\"DRAFT_CREATED\"}", MediaType.APPLICATION_JSON));
mockServer.expect(requestTo("http://reward.test/api/campaign/csr/create-draft"))
.andExpect(method(POST))
.andRespond(withStatus(HttpStatus.BAD_REQUEST).body("400 Bad Request"));
// 1. Create Pool Definition
CreatePoolDefinitionRequest poolReq = new CreatePoolDefinitionRequest(convId, "POOL-1", "Test Pool", "STANDARD", "USD");
Map<String, Object> poolRes = pointPoolTools.createPoolDefinition(poolReq);
assertEquals("POOL-1", poolRes.get("poolId"));
// 2. Create SOP
CreateSopRequest sopReq = new CreateSopRequest(convId, "SOP-1", "POOL-1", "Test SOP", "ACCRUAL");
Map<String, Object> sopRes = pointPoolTools.createSop(sopReq);
assertEquals("SOP-1", sopRes.get("sopId"));
// 3. Create Campaign Draft
UpdateCampaignDraftRequest updateCampReq = new UpdateCampaignDraftRequest(convId, "CAMP-1", "My Campaign", "TIER-1");
campaignTools.updateCampaignDraft(updateCampReq);
// 4. Add Rule to Campaign (Valid)
CreateCampaignRuleToolRequest ruleDetails = new CreateCampaignRuleToolRequest("CAMP-1", "RULE-1", "My Rule", "AWD", "Desc", OffsetDateTime.now(), OffsetDateTime.now(), "POOL-1", null);
AddRuleToCampaignRequest addRuleReq = new AddRuleToCampaignRequest(convId, "CAMP-1", "TXN-CODE-1", true, "POOL-1", ruleDetails);
Map<String, Object> ruleRes = campaignRuleTools.addRuleToCampaign(addRuleReq);
assertEquals("RULE-1", ruleRes.get("ruleId"));
// 5. Submit Campaign Draft
SubmitCampaignDraftRequest submitCampReq = new SubmitCampaignDraftRequest(convId);
Map<String, Object> submitCampRes = campaignTools.submitCampaignDraft(submitCampReq);
assertEquals("CAMP-1", submitCampRes.get("campaignId"));
// Test Error Propagation: Missing Pool ID for SOP
CreateSopRequest badSopReq = new CreateSopRequest(convId, "SOP-2", "", "Bad SOP", "ACCRUAL");
assertThrows(IllegalArgumentException.class, () -> pointPoolTools.createSop(badSopReq));
// Test Error Propagation: Add Rule missing required Pool ID
AddRuleToCampaignRequest badRuleReq = new AddRuleToCampaignRequest(convId, "CAMP-1", "TXN-CODE-2", true, "", ruleDetails);
assertThrows(IllegalArgumentException.class, () -> campaignRuleTools.addRuleToCampaign(badRuleReq));
// Test Error propagation from Core API
Map<String, Object> errorRes = campaignTools.submitCampaignDraft(submitCampReq);
assertTrue(((String)errorRes.get("error")).contains("400 Bad Request"));
mockServer.verify();
}
}

View File

@@ -0,0 +1,83 @@
package dev.sonpx.loyalty.mcp.tool.reward;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignRuleDto;
import dev.sonpx.loyalty.mcp.tool.model.reward.CreateCampaignRuleToolRequest;
import dev.sonpx.loyalty.mcp.tool.model.reward.UpdateCampaignRuleToolRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.OffsetDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CampaignRuleMapperTest {
private CampaignRuleMapper mapper;
@BeforeEach
void setUp() {
mapper = new CampaignRuleMapper();
}
@Test
void testToDto_CreateRequest_WithFormulaOne() {
CreateCampaignRuleToolRequest request = new CreateCampaignRuleToolRequest(
"CAMPAIGN-123",
"RULE-1",
"My Rule",
"AWD",
"Description",
OffsetDateTime.now(),
OffsetDateTime.now().plusDays(10),
"POOL-ABC",
new CreateCampaignRuleToolRequest.FormulaOneConfig(10.0, 100.0)
);
CampaignRuleDto dto = mapper.toDto(request);
assertNotNull(dto);
assertNotNull(dto.getCampaignId());
assertEquals("CAMPAIGN-123", dto.getCampaignId().getCode());
assertEquals("RULE-1", dto.getRuleId());
assertEquals("My Rule", dto.getRuleName());
assertEquals(CampaignRuleDto.RuleTypeEnum.AWD, dto.getRuleType());
assertNotNull(dto.getPoolId());
assertEquals("POOL-ABC", dto.getPoolId().getCode());
assertNotNull(dto.getCampaignFormulaOne());
assertEquals(10.0, dto.getCampaignFormulaOne().getAwardPerTxnAmt());
assertEquals(100.0, dto.getCampaignFormulaOne().getTxnAmt());
assertEquals(1, dto.getFormulaSequence().size());
assertEquals("1", dto.getFormulaSequence().get(0));
}
@Test
void testToDto_UpdateRequest_WithoutFormulaOne() {
UpdateCampaignRuleToolRequest request = new UpdateCampaignRuleToolRequest(
"CAMPAIGN-456",
"RULE-2",
"My Updated Rule",
"RED",
"Updated Description",
null,
null,
"POOL-XYZ",
null
);
CampaignRuleDto dto = mapper.toDto(request);
assertNotNull(dto);
assertNotNull(dto.getCampaignId());
assertEquals("CAMPAIGN-456", dto.getCampaignId().getCode());
assertEquals("RULE-2", dto.getRuleId());
assertEquals("My Updated Rule", dto.getRuleName());
assertEquals(CampaignRuleDto.RuleTypeEnum.RED, dto.getRuleType());
assertNotNull(dto.getPoolId());
assertEquals("POOL-XYZ", dto.getPoolId().getCode());
assertNull(dto.getCampaignFormulaOne());
assertTrue(dto.getFormulaSequence() == null || dto.getFormulaSequence().isEmpty());
}
}

0
mvnw vendored Normal file → Executable file
View File

41
package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "agent-ui-temp",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.2.2",
"@radix-ui/react-scroll-area": "^1.2.14",
"@radix-ui/react-slot": "^1.3.0",
"@stomp/stompjs": "^7.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.20",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"autoprefixer": "^10.5.4",
"oxlint": "^1.71.0",
"postcss": "^8.5.19",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}

229
plan/master_plan.md Normal file
View File

@@ -0,0 +1,229 @@
# Master Plan: Loyalty Agent Service
_Focus hiện tại: MCP Orchestrator cho Reward Core API, dựa trên `loyalty-mcp-server/src/main/resources/specs/reward.json`._
Core Reward API đang expose **11 CSR resources chính** theo cùng một pattern Maker-Checker:
1. `Transaction Category`
2. `Transaction Code`
3. `Statement Output Pool (SOP)`
4. `Pool Definition`
5. `Pool Conversion Rate`
6. `Currency Rate`
7. `Event Maintenance`
8. `Deduction Sequences`
9. `Counter Definition`
10. `Campaign`
11. `Campaign Rule`
Ngoài nhóm CSR trên, spec còn có các endpoint hỗ trợ rất quan trọng cho Agent như `id/generate`, `id/check`, `list`, `count`, `history`, `list-by-ids`, `list-by-codes`, `list-by-pool`, `precision-by-pools`, `find-default`, `exists-default`, `verifyCriteria`, `amount-to-use-view`, `decide-tier-view`.
Vì Core API không sửa được, Loyalty Agent Service/MCP Server sẽ đóng vai trò **orchestrator + DTO builder + validator**: hướng dẫn user điền thông tin, tra cứu dữ liệu phụ thuộc, build DTO hợp lệ, validate trước, rồi mới gọi endpoint CSR tương ứng.
---
## Phase 1: Reward MCP Orchestrator
_Mục tiêu: biến Reward OpenAPI thành bộ MCP tools dùng được trong hội thoại, ưu tiên luồng tạo Campaign/Rule/Pool end-to-end._
### 1.1. Foundation Tools dùng chung
Các resource CSR có pattern giống nhau, nên cần build một lớp client/tool generator hoặc wrapper dùng chung thay vì viết thủ công từng API.
- `generate_id(resource)`: gọi `/api/{resource}/csr/id/generate`.
- `check_id(resource, id)`: gọi `/api/{resource}/csr/id/check/{id}`.
- `get_by_id(resource, id)` hoặc `get_by_record_no(resource, recordNo)`.
- `list(resource, criteria, page, size, sort)`.
- `count(resource, criteria)`.
- `create_draft(resource, dto)`.
- `edit_draft(resource, dto)`.
- `approve/reject/bulk-approve/bulk-reject` chỉ expose sau, vì đây là hành động nhạy cảm.
- `history(resource, criteria)``rejection_reason(recordNo)` dùng để giải thích trạng thái Maker-Checker cho user.
Nguyên tắc: trong giai đoạn đầu, Agent ưu tiên tạo draft, không tự approve.
### 1.2. DTO Builder & Validation Layer
MCP Server cần giữ local draft theo conversation/session để user có thể build DTO từng bước.
- Mỗi draft có `draftId`, `resource`, `dto`, `missingRequiredFields`, `warnings`, `lastCoreValidation`.
- Không đưa các field `readOnly` vào request: `status`, `lastUpdateBy`, `lastUpdateDate`, `lastApproveBy`, `lastApproveDate`, `lastUpdateByName`, `lastApproveByName`.
- Validate required fields theo schema trước khi gọi Core API.
- Validate enum, maxLength/minLength, date/date-time format.
- Với field `$ref: ReferenceData`, Agent phải tra cứu hoặc xác nhận object reference, không chỉ nhét string trần nếu Core client yêu cầu object.
- Với nested DTO trong `CampaignRuleDto`, build theo template theo `ruleType` thay vì hỏi tất cả field.
Required fields quan trọng theo spec:
- `CampaignDto`: `campaignId`, `name`.
- `CampaignRuleDto`: `campaignId`, `ruleId`, `ruleName`.
- `PoolDefinitionDto`: `poolId`, `poolName`.
- `TransactionCodeDto`: `code`, `description`.
- `TransactionCategoryDto`: `code`, `name`.
- `PoolConversionRateDto`: `code`, `description`.
- `CurrencyRateDto`: `pcrCode`, `effectiveFrom`, `effectiveTo`, `buyRate`, `sellRate`.
- `EventMaintenanceDto`: `eventId`, `description`, `occursNext`.
- `DeductionSequencesDto`: `sequenceId`, `sequenceName`, `effectiveFrom`, `effectiveTo`, `isDefault`.
- `CounterDefinitionDto`: `counterId`, `counterName`, `effectiveFrom`, `effectiveTo`, `entity`, `bucketPeriodUnit`, `whatToCount`, `resetType`, `resetValue`, `updateStateWhen`, `lateValuePosting`.
### 1.3. Dependency Graph thực tế
Không hard-code rằng Campaign Rule nối trực tiếp với Event. Trong schema hiện tại, `CampaignRuleDto` không có `eventId`; nó nối chủ yếu qua:
- `campaignId`
- `poolId` / `subPoolId`
- `campaignTcLinkages`
- `campaignCriteria`
- `campaignFormula*`
- `campaignRuleSchedule`
- `marketingRewardRequest`
- reward content theo channel `SMS`, `EMAIL`, `NOTIFY`
Dependency graph đề xuất:
1. **Reference/Lookup layer**
- `amount-to-use-view/list`
- `decide-tier-view/list`
- transaction code lookup: `/api/transaction-code/list-by-codes`
- pool lookup: `/api/pool-definition/list-by-ids`, `/api/pool-definition/precision-by-pools`
- deduction sequence default: `/api/deduction-seq/find-default`
2. **Parameter layer**
- `Transaction Category`
- `Transaction Code`
- `Pool Conversion Rate`
- `Currency Rate`
3. **Pool layer**
- `Statement Output Pool`
- `Pool Definition`
- optional `Counter Definition` nếu rule dùng cap/limit/counter formula
4. **Campaign layer**
- `Campaign`
5. **Rule layer**
- `Campaign Rule`
- validate criteria bằng `/api/campaign-rule/campaign-criteria/verify`
- kiểm tra liên kết bằng `/api/campaign-rule/list-by-pool`, `/api/campaign-rule/rule-linked-by-transaction-code`, `/api/campaign-rule/find-matching-rules`
6. **Standalone/scheduled support**
- `Event Maintenance`
- `Deduction Sequences`
### 1.4. Scope triển khai Phase 1
Không làm cả 11 resource ngang nhau ngay. Ưu tiên theo giá trị end-to-end:
**Milestone 1: Read/Lookup Tools**
- Implement tool list/search cho `Campaign`, `Campaign Rule`, `Pool Definition`, `Transaction Code`.
- Implement `generate_id``check_id` cho các resource chính.
- Implement helper lookup: `list-by-codes`, `list-by-ids`, `precision-by-pools`, `amount-to-use-view/list`, `decide-tier-view/list`.
- Output tool phải trả dữ liệu ngắn gọn để Agent dùng tiếp, không dump DTO lớn.
**Milestone 2: Pool Setup**
- Tool tạo draft `PoolConversionRateDto`.
- Tool tạo draft `PoolDefinitionDto`.
- Tool tạo draft `StatementOutputPoolDto` nếu workflow statement cần SOP.
- Validate expiry policy: `FD`, `NE`, `ARD`, `MF`, `QF`, `YF`, `SP`, `AOM`.
- Validate pool type: `BPT`, `CR`, `GFT`, `MI`, `LDR`.
**Milestone 3: Campaign Setup**
- Tool tạo draft `CampaignDto`.
- Agent tự generate/check `campaignId` nếu user chưa có mã.
- Giữ template tối thiểu: `campaignId`, `name`, `ownerName`, `description`, `campaignType`, `effectiveFrom`, `effectiveTo`.
- Không yêu cầu user nhập `numOfRule`; field này nên derive/hiển thị từ Core nếu có.
**Milestone 4: Campaign Rule Setup**
- Tool tạo draft `CampaignRuleDto`.
- Template theo `ruleType`: `AWD`, `RED`, `IRED`, `ADJ`, `CEP`, `REP`, `TEP`, `MAWD`.
- Với earning/award flow, ưu tiên support:
- `campaignId`
- `ruleId`, `ruleName`
- `poolId`
- `campaignTcLinkages`
- `campaignCriteria`
- `campaignFormulaSetting`
- một trong các `campaignFormulaOne/Two/Four/Five/Six/Eight/Ten/Eleven`
- `campaignAwardLimits` nếu có cap/counter
- Gọi `verifyCriteria` trước khi submit draft nếu user có nhập criteria expression.
**Milestone 5: Approval Visibility**
- Tool xem trạng thái draft, history, rejection reason.
- Tool giải thích record đang ở trạng thái nào và cần maker/checker làm gì tiếp theo.
- Chưa expose approve/reject mặc định; chỉ bật khi có phân quyền và xác nhận rõ.
---
## Phase 2: Memory & Conversation State
_Mục tiêu: Agent nhớ chính xác DTO đang build, các reference đã chọn, và trạng thái Maker-Checker._
- Lưu conversation memory và local draft state trong DB.
- Context locking theo draft: khi user đang build `CampaignRuleDto`, Agent không tự chuyển sang draft khác trừ khi user xác nhận.
- Cho phép nested flow có kiểm soát: nếu đang build rule nhưng thiếu pool, Agent có thể tạo sub-draft `PoolDefinitionDto`, hoàn tất xong quay lại rule.
- Lưu các reference đã tạo/chọn gần nhất: `campaignId`, `ruleId`, `poolId`, `transactionCode`, `counterId`, `pcrCode`.
- Khi user nói "dùng pool vừa tạo", Agent phải resolve từ memory thành reference rõ ràng và hỏi lại nếu có nhiều pool gần đây.
- Mỗi draft cần có audit trail: user intent, fields collected, validation result, endpoint đã gọi, response recordNo/status.
---
## Phase 3: UI/Dashboard hỗ trợ Agent
_Mục tiêu: giảm hội thoại dài bằng UI chuyên biệt cho DTO lớn._
- **Draft Inspector:** hiển thị DTO hiện tại, missing fields, warnings, Core response.
- **Dependency Viewer:** hiển thị Campaign -> Campaign Rule -> Pool -> Transaction Code/Counter/Formula.
- **Interactive Forms:** form động sinh từ schema cho các DTO lớn như `CampaignRuleDto`.
- **Criteria Builder:** UI hỗ trợ build và verify `campaignCriteria.criteria`.
- **Formula Builder:** UI chọn formula type và chỉ hiện field liên quan.
- **Maker-Checker Timeline:** show draft/history/rejection reason để user biết đang chờ bước nào.
- Dùng WebSocket hiện tại để sync draft state real-time giữa chat và UI.
---
## Phase 4: Auto-Pilot & Simulation
_Mục tiêu: từ intent tự nhiên tạo được campaign/rule/pool hoàn chỉnh nhưng vẫn kiểm soát được rủi ro._
- Intent template: "Tạo campaign nhân đôi điểm sinh nhật", "Tạo campaign hoàn tiền 5%", "Tạo rule tặng điểm theo tier".
- Agent tự lập execution plan trước khi gọi API: cần tạo/check resource nào, endpoint nào, payload summary nào.
- Auto-create draft theo thứ tự phụ thuộc, dừng lại khi thiếu field bắt buộc hoặc Core reject.
- Simulation trước approve:
- sample transaction
- matched transaction code
- pool impacted
- formula result
- cap/limit/counter impact
- Human confirmation bắt buộc trước các hành động tạo nhiều draft hoặc gửi approve/reject.
---
## Nguyên tắc triển khai
- OpenAPI spec là source of truth; không đoán DTO field ngoài schema.
- Tool output phải ngắn, có cấu trúc, dễ đưa lại vào Agent context.
- Mutating tools phải idempotent ở tầng Agent/MCP nếu có thể: có `draftId`, correlation id, request summary.
- Mọi create/edit draft đều chạy local validation trước, rồi mới gọi Core.
- Với action nhạy cảm (`approve`, `reject`, `bulk-*`, `delete`), bắt buộc xác nhận rõ từ user.
- Không để Agent tự tạo/chỉnh quá nhiều resource ngầm nếu chưa trình bày execution plan.
---
## Backlog kỹ thuật gần nhất
- [ ] Parse `reward.json` thành metadata nội bộ: resources, schemas, required fields, enums, readOnly fields, endpoints.
- [ ] Implement generic Reward API client cho CSR pattern.
- [ ] Implement MCP tools read-only cho Campaign/Rule/Pool/Transaction Code.
- [ ] Implement local draft store cho `CampaignDto`, `PoolDefinitionDto`, `CampaignRuleDto`.
- [ ] Implement validator dùng OpenAPI schema.
- [ ] Implement create-draft flow cho Pool Definition.
- [ ] Implement create-draft flow cho Campaign.
- [ ] Implement create-draft flow cho Campaign Rule với `verifyCriteria`.
- [ ] Add integration tests dùng mock Reward Core API.
- [ ] Add UI Draft Inspector sau khi backend flow ổn định.

2649
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

317
pom.xml
View File

@@ -7,93 +7,28 @@
<parent> <parent>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId> <artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version> <version>4.0.0</version>
<relativePath/> <!-- lookup parent from repository --> <relativePath/> <!-- lookup parent from repository -->
</parent> </parent>
<groupId>dev.sonpx.loyalty</groupId> <groupId>dev.sonpx.loyalty</groupId>
<artifactId>loyalty-agent-service</artifactId> <artifactId>loyalty-agent-service-parent</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>0.0.1-SNAPSHOT</version>
<name>loyalty-agent-service</name> <packaging>pom</packaging>
<description>AI Agent Service to orchestrate Loyalty core system</description> <name>loyalty-agent-service-parent</name>
<description>Parent POM for Loyalty Agent Service</description>
<modules>
<module>loyalty-agent</module>
<module>loyalty-mcp-server</module>
</modules>
<properties> <properties>
<java.version>25</java.version> <java.version>25</java.version>
<spring-ai.version>2.0.0</spring-ai.version> <spring-ai.version>2.0.0</spring-ai.version>
<lombok.version>1.18.38</lombok.version>
</properties> </properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</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>
<optional>true</optional>
</dependency>
<!-- Springdoc OpenAPI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.7.0</version>
</dependency>
<!-- Dependencies for generated OpenAPI clients -->
<dependency>
<groupId>org.openapitools</groupId>
<artifactId>jackson-databind-nullable</artifactId>
<version>0.2.6</version>
</dependency>
<dependency>
<groupId>jakarta.annotation</groupId>
<artifactId>jakarta.annotation-api</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.14</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpmime</artifactId>
<version>4.5.14</version>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>
<dependency> <dependency>
@@ -106,236 +41,6 @@
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>
<build>
<plugins>
<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>
<profiles>
<profile>
<id>gen</id>
<build>
<plugins>
<!-- OpenAPI Generator Plugin -->
<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<version>7.23.0</version>
<configuration>
<skipValidateSpec>true</skipValidateSpec>
<typeMappings>
<MultiValueMapStringString>org.springframework.util.MultiValueMap</MultiValueMapStringString>
</typeMappings>
<importMappings>
<MultiValueMapStringString>org.springframework.util.MultiValueMap</MultiValueMapStringString>
</importMappings>
<nameMappings>user_name=user_name_raw,setAttributeId=set_attribute_id_val</nameMappings>
</configuration>
<executions>
<execution>
<id>generate-attribute</id>
<goals><goal>generate</goal></goals>
<configuration>
<inputSpec>${project.basedir}/src/main/resources/specs/attribute.json</inputSpec>
<generatorName>java</generatorName>
<library>native</library>
<apiPackage>dev.sonpx.loyalty.agent.client.api.attribute</apiPackage>
<modelPackage>dev.sonpx.loyalty.agent.client.model.attribute</modelPackage>
<generateApiTests>false</generateApiTests>
<generateModelTests>false</generateModelTests>
<configOptions>
<useJakartaEe>true</useJakartaEe>
<useSpringBoot3>true</useSpringBoot3>
</configOptions>
</configuration>
</execution>
<execution>
<id>generate-catalogue</id>
<goals><goal>generate</goal></goals>
<configuration>
<inputSpec>${project.basedir}/src/main/resources/specs/catalogue.json</inputSpec>
<generatorName>java</generatorName>
<library>native</library>
<apiPackage>dev.sonpx.loyalty.agent.client.api.catalogue</apiPackage>
<modelPackage>dev.sonpx.loyalty.agent.client.model.catalogue</modelPackage>
<generateApiTests>false</generateApiTests>
<generateModelTests>false</generateModelTests>
<configOptions>
<useJakartaEe>true</useJakartaEe>
<useSpringBoot3>true</useSpringBoot3>
</configOptions>
</configuration>
</execution>
<execution>
<id>generate-customer</id>
<goals><goal>generate</goal></goals>
<configuration>
<inputSpec>${project.basedir}/src/main/resources/specs/customer.json</inputSpec>
<generatorName>java</generatorName>
<library>native</library>
<apiPackage>dev.sonpx.loyalty.agent.client.api.customer</apiPackage>
<modelPackage>dev.sonpx.loyalty.agent.client.model.customer</modelPackage>
<generateApiTests>false</generateApiTests>
<generateModelTests>false</generateModelTests>
<configOptions>
<useJakartaEe>true</useJakartaEe>
<useSpringBoot3>true</useSpringBoot3>
</configOptions>
</configuration>
</execution>
<execution>
<id>generate-identity</id>
<goals><goal>generate</goal></goals>
<configuration>
<inputSpec>${project.basedir}/src/main/resources/specs/identity.json</inputSpec>
<generatorName>java</generatorName>
<library>native</library>
<apiPackage>dev.sonpx.loyalty.agent.client.api.identity</apiPackage>
<modelPackage>dev.sonpx.loyalty.agent.client.model.identity</modelPackage>
<generateApiTests>false</generateApiTests>
<generateModelTests>false</generateModelTests>
<configOptions>
<useJakartaEe>true</useJakartaEe>
<useSpringBoot3>true</useSpringBoot3>
</configOptions>
</configuration>
</execution>
<execution>
<id>generate-marketing</id>
<goals><goal>generate</goal></goals>
<configuration>
<inputSpec>${project.basedir}/src/main/resources/specs/marketing.json</inputSpec>
<generatorName>java</generatorName>
<library>native</library>
<apiPackage>dev.sonpx.loyalty.agent.client.api.marketing</apiPackage>
<modelPackage>dev.sonpx.loyalty.agent.client.model.marketing</modelPackage>
<generateApiTests>false</generateApiTests>
<generateModelTests>false</generateModelTests>
<configOptions>
<useJakartaEe>true</useJakartaEe>
<useSpringBoot3>true</useSpringBoot3>
</configOptions>
</configuration>
</execution>
<execution>
<id>generate-master</id>
<goals><goal>generate</goal></goals>
<configuration>
<inputSpec>${project.basedir}/src/main/resources/specs/master.json</inputSpec>
<generatorName>java</generatorName>
<library>native</library>
<apiPackage>dev.sonpx.loyalty.agent.client.api.master</apiPackage>
<modelPackage>dev.sonpx.loyalty.agent.client.model.master</modelPackage>
<generateApiTests>false</generateApiTests>
<generateModelTests>false</generateModelTests>
<configOptions>
<useJakartaEe>true</useJakartaEe>
<useSpringBoot3>true</useSpringBoot3>
</configOptions>
</configuration>
</execution>
<execution>
<id>generate-reward</id>
<goals><goal>generate</goal></goals>
<configuration>
<inputSpec>${project.basedir}/src/main/resources/specs/reward.json</inputSpec>
<generatorName>java</generatorName>
<library>native</library>
<apiPackage>dev.sonpx.loyalty.agent.client.api.reward</apiPackage>
<modelPackage>dev.sonpx.loyalty.agent.client.model.reward</modelPackage>
<generateApiTests>false</generateApiTests>
<generateModelTests>false</generateModelTests>
<configOptions>
<useJakartaEe>true</useJakartaEe>
<useSpringBoot3>true</useSpringBoot3>
</configOptions>
</configuration>
</execution>
<execution>
<id>generate-transaction</id>
<goals><goal>generate</goal></goals>
<configuration>
<inputSpec>${project.basedir}/src/main/resources/specs/transaction.json</inputSpec>
<generatorName>java</generatorName>
<library>native</library>
<apiPackage>dev.sonpx.loyalty.agent.client.api.transaction</apiPackage>
<modelPackage>dev.sonpx.loyalty.agent.client.model.transaction</modelPackage>
<generateApiTests>false</generateApiTests>
<generateModelTests>false</generateModelTests>
<configOptions>
<useJakartaEe>true</useJakartaEe>
<useSpringBoot3>true</useSpringBoot3>
</configOptions>
</configuration>
</execution>
</executions>
</plugin>
<!-- Replacer plugin to fix missing import of MultiValueMapStringString in openapi generated code -->
<plugin>
<groupId>com.google.code.maven-replacer-plugin</groupId>
<artifactId>replacer</artifactId>
<version>1.5.3</version>
<executions>
<execution>
<phase>process-sources</phase>
<goals>
<goal>replace</goal>
</goals>
</execution>
</executions>
<configuration>
<includes>
<include>${project.basedir}/target/generated-sources/openapi/src/main/java/**/*Api.java</include>
</includes>
<replacements>
<replacement>
<token>(?&lt;!\.)MultiValueMapStringString</token>
<value>dev.sonpx.loyalty.agent.client.model.transaction.MultiValueMapStringString</value>
</replacement>
</replacements>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
<repositories> <repositories>
<repository> <repository>
<id>spring-milestones</id> <id>spring-milestones</id>

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

184
src/App.css Normal file
View File

@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

9
src/App.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { ChatLayout } from '@/components/chat/ChatLayout'
function App() {
return (
<ChatLayout />
)
}
export default App

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,109 @@
import { useEffect, useState } from 'react';
interface Campaign {
id: string;
name: string;
status: string;
startDate?: string;
endDate?: string;
}
export function CampaignList() {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [loading, setLoading] = useState(true);
const [selectedCampaignId, setSelectedCampaignId] = useState<string | null>(null);
const [rules, setRules] = useState<any[]>([]);
const [loadingRules, setLoadingRules] = useState(false);
useEffect(() => {
fetch('http://localhost:9332/api/v1/agent/campaigns')
.then((res) => res.json())
.then((data) => {
if (Array.isArray(data)) setCampaigns(data);
else if (data && Array.isArray(data.content)) setCampaigns(data.content);
else if (data && Array.isArray(data.data)) setCampaigns(data.data);
else setCampaigns([]);
})
.catch((err) => {
console.error('Failed to fetch campaigns', err);
setCampaigns([]);
})
.finally(() => setLoading(false));
}, []);
const handleSelectCampaign = (id: string) => {
if (selectedCampaignId === id) {
setSelectedCampaignId(null);
setRules([]);
return;
}
setSelectedCampaignId(id);
setLoadingRules(true);
fetch(`http://localhost:9332/api/v1/agent/campaigns/${id}/rules`)
.then((res) => res.json())
.then((data) => {
if (Array.isArray(data)) setRules(data);
else if (data && Array.isArray(data.content)) setRules(data.content);
else if (data && Array.isArray(data.data)) setRules(data.data);
else setRules([]);
})
.catch((err) => {
console.error('Failed to fetch rules', err);
setRules([]);
})
.finally(() => setLoadingRules(false));
};
if (loading) {
return <div className="p-4 text-center text-sm text-muted-foreground">Loading campaigns...</div>;
}
if (campaigns.length === 0) {
return <div className="p-4 text-center text-sm text-muted-foreground">No campaigns found.</div>;
}
return (
<div className="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar">
{campaigns.map((camp) => (
<div key={camp.id} className="space-y-2">
<div
onClick={() => handleSelectCampaign(camp.id)}
className={`glass-card rounded-xl p-4 text-sm group cursor-pointer relative overflow-hidden transition-all duration-200 ${selectedCampaignId === camp.id ? 'ring-2 ring-primary/50' : ''}`}>
<div className="absolute inset-0 bg-primary/5 opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="relative z-10">
<div className="font-semibold text-foreground text-base mb-1 group-hover:text-primary transition-colors">{camp.name || 'Unnamed Campaign'}</div>
<div className="flex justify-between items-center text-xs text-muted-foreground mt-3">
<span className="flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${camp.status === 'A' ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]' : 'bg-rose-500'}`} />
{camp.status === 'A' ? 'Active' : camp.status}
</span>
<code className="bg-background/50 px-1.5 py-0.5 rounded text-[10px] uppercase">{camp.id}</code>
</div>
</div>
</div>
{selectedCampaignId === camp.id && (
<div className="pl-4 pr-2 space-y-2 border-l-2 border-primary/20 ml-2 animate-in slide-in-from-top-2">
{loadingRules ? (
<div className="text-xs text-muted-foreground italic">Loading rules...</div>
) : rules.length === 0 ? (
<div className="text-xs text-muted-foreground italic">No rules found.</div>
) : (
rules.map((rule, idx) => (
<div key={rule.id || idx} className="bg-background/40 rounded-lg p-3 text-xs border border-border/50">
<div className="font-medium text-foreground mb-1">{rule.name || 'Unnamed Rule'}</div>
<div className="text-muted-foreground line-clamp-2">{rule.description || 'No description'}</div>
{rule.status && (
<div className="mt-2 text-[10px] uppercase tracking-wider text-muted-foreground/80">Status: {rule.status}</div>
)}
</div>
))
)}
</div>
)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { useState } from 'react'
import type { KeyboardEvent } from 'react'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { useChatStore } from '@/store/useChatStore'
import { SendHorizontal } from 'lucide-react'
export function ChatBottombar() {
const [input, setInput] = useState('')
const sendMessage = useChatStore((state) => state.sendMessage)
const isConnected = useChatStore((state) => state.isConnected)
const isConnecting = useChatStore((state) => state.isConnecting)
const handleSend = () => {
if (input.trim() && isConnected) {
sendMessage(input.trim())
setInput('')
}
}
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
return (
<div className="p-4 bg-background border-t">
<div className="max-w-3xl mx-auto flex items-end gap-2 relative">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message..."
className="min-h-[60px] max-h-[200px] resize-none"
disabled={!isConnected}
/>
<Button
size="icon"
className="h-[60px] w-[60px] shrink-0"
onClick={handleSend}
disabled={!input.trim() || !isConnected}
>
<SendHorizontal className="w-6 h-6" />
</Button>
</div>
{!isConnected && !isConnecting && (
<div className="text-center text-destructive text-sm mt-2">
Disconnected from agent server. Retrying...
</div>
)}
{isConnecting && (
<div className="text-center text-muted-foreground text-sm mt-2 animate-pulse">
Connecting to agent server...
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,48 @@
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { cn } from '@/lib/utils'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type { Message } from '@/store/useChatStore'
export function ChatBubble({ message }: { message: Message }) {
const isAgent = message.role === 'agent'
return (
<div className={cn("flex w-full mt-4 space-x-3 max-w-3xl mx-auto", isAgent ? "justify-start" : "justify-end")}>
{isAgent && (
<Avatar className="w-8 h-8">
<AvatarFallback>AG</AvatarFallback>
</Avatar>
)}
<div className="flex flex-col gap-1 max-w-[80%]">
{message.toolStatus && (
<span className="text-xs text-muted-foreground animate-pulse italic">
{message.toolStatus}
</span>
)}
<div
className={cn(
"p-3 rounded-md text-sm shadow-sm",
isAgent ? "bg-muted text-foreground" : "bg-primary text-primary-foreground"
)}
>
{isAgent ? (
<div className="prose dark:prose-invert max-w-none text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content}
</ReactMarkdown>
{message.isStreaming && <span className="inline-block w-2 h-4 bg-current animate-pulse ml-1 align-middle" />}
</div>
) : (
<span className="whitespace-pre-wrap">{message.content}</span>
)}
</div>
</div>
{!isAgent && (
<Avatar className="w-8 h-8">
<AvatarFallback>U</AvatarFallback>
</Avatar>
)}
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { MessageSquare } from 'lucide-react';
interface ChatSession {
id: string;
title: string;
date: string;
}
const MOCK_SESSIONS: ChatSession[] = [
{ id: '1', title: 'Phân tích Campaign Noel 2026', date: 'Vừa xong' },
{ id: '2', title: 'Tại sao Rule giảm giá không hoạt động?', date: '2 giờ trước' },
{ id: '3', title: 'Tạo Campaign sinh nhật khách hàng', date: 'Hôm qua' },
{ id: '4', title: 'Kiểm tra điểm thưởng KH VIP', date: 'Hôm qua' },
{ id: '5', title: 'Lỗi đồng bộ dữ liệu CRM', date: '3 ngày trước' },
];
export function ChatHistoryList() {
return (
<div className="flex-1 overflow-y-auto p-4 space-y-2 custom-scrollbar">
{MOCK_SESSIONS.map((session) => (
<div
key={session.id}
className="p-3 text-sm group cursor-pointer relative overflow-hidden rounded-lg hover:bg-white/5 transition-colors border border-transparent hover:border-white/10"
>
<div className="flex items-start gap-3">
<MessageSquare className="w-4 h-4 mt-0.5 text-muted-foreground group-hover:text-primary transition-colors shrink-0" />
<div className="flex-1 overflow-hidden">
<div className="truncate font-medium text-foreground/90 group-hover:text-foreground transition-colors">
{session.title}
</div>
<div className="text-xs text-muted-foreground mt-1">
{session.date}
</div>
</div>
</div>
</div>
))}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More