feat: expand MCP server functionality with customer, catalogue, marketing, and user tools and integrate new health indicators and configuration models.

This commit is contained in:
2026-08-11 16:31:52 +07:00
parent 9a26c6ce2d
commit 8ecd27dd0e
188 changed files with 5672 additions and 983 deletions

View File

@@ -1,7 +1,47 @@
# ========================================
# Loyalty Agent Service — Environment Variables
# Copy to .env and fill in real values
# ========================================
# --- MCP Server (loyalty-mcp-server :9331) ---
KEYCLOAK_CLIENT_ID=ols-cli
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
KEYCLOAK_TOKEN_URI=http://your-keycloak-host/auth/realms/your-realm/protocol/openid-connect/token
LOYALTY_CORE_BASE_URL=http://localhost:8081
# --- Agent (loyalty-agent :9332) ---
MCP_SERVER_URL=http://localhost:9331/sse
# Database
SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5433/loyalty_agent
SPRING_DATASOURCE_USERNAME=postgres
SPRING_DATASOURCE_PASSWORD=your_db_password_here
# Ollama (LLM)
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=qwen3.5:4b
# Agent model routing (optional overrides)
CHAT_OLLAMA_BASE_URL=http://localhost:11434
CHAT_MODEL=qwen2.5:1.5b
CHAT_MODEL_TEMPERATURE=0.7
CHAT_MODEL_NUM_CTX=8192
CHAT_MODEL_DISABLE_THINKING=true
QUERY_OLLAMA_BASE_URL=http://localhost:11434
QUERY_MODEL=qwen3.5:4b
QUERY_MODEL_TEMPERATURE=0.3
QUERY_MODEL_NUM_CTX=32768
QUERY_MODEL_DISABLE_THINKING=false
CREATOR_OLLAMA_BASE_URL=http://localhost:11434
CREATOR_MODEL=qwen3.5:4b
CREATOR_MODEL_TEMPERATURE=0.3
CREATOR_MODEL_NUM_CTX=32768
CREATOR_MODEL_DISABLE_THINKING=false
AGENT_REQUEST_TIMEOUT=120
# --- Frontend (React/Vite :9333) ---
VITE_AGENT_HTTP_URL=http://localhost:9332
VITE_AGENT_WS_URL=ws://localhost:9332

170
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,170 @@
# Architecture — Loyalty Agent Service
## System Overview
AI-powered assistant for managing a Loyalty Platform, built on the **Model Context Protocol (MCP)**.
```text
┌──────────────────┐
│ React/Vite UI │
│ :9333 │
└────────┬─────────┘
│ WebSocket / STOMP
▼
┌──────────────────┐
│ Loyalty Agent │
│ :9332 │
│ │
│ LLM Orchestrator │
│ MCP Client │
│ Conversation │
│ PostgreSQL │
└────────┬─────────┘
│ MCP SSE (/sse, /message)
▼
┌──────────────────┐
│ Loyalty MCP │
│ Server :9331 │
│ │
│ 25+ MCP Tools │
└────────┬─────────┘
│ REST + OAuth2 (Client Credentials)
▼
┌──────────────────┐
│ Loyalty Core API │
│ :8081 │
└──────────────────┘
Keycloak ────── OAuth2 Token Provider
PostgreSQL ──── Conversation Persistence
Ollama ──────── Local LLM (Qwen3.5:4b)
```
## Technology Stack
| Technology | Version | Purpose |
|----------------|---------|----------------------------|
| Java | 25 | Runtime |
| Spring Boot | 4.0.0 | Framework |
| Spring AI | 2.0.0 | LLM & MCP integration |
| Ollama | latest | Local LLM hosting |
| Qwen | 3.5:4b | Primary LLM model |
| PostgreSQL | 15+ | Conversation persistence |
| Keycloak | latest | OAuth2/OIDC provider |
| React + Vite | 19/6 | Frontend UI |
## Module Architecture
### loyalty-mcp-server (`:9331`)
MCP Server exposing Loyalty Platform capabilities as MCP tools via SSE transport.
**Responsibilities:**
- Register 25+ MCP tools (campaign, rule, customer, transaction, etc.)
- Validate tool inputs
- Proxy requests to Loyalty Core API with OAuth2 client credentials
- Return structured `Result<T>` with agent instructions for LLM formatting
- Handle errors (sanitize stack traces, structured error responses)
**Key Classes:**
- `CampaignTools` / `CampaignRuleTools` — Primary tool registration
- `CampaignService` / `CampaignRuleService` — Business logic
- `RestClientConfig` — OAuth2 REST client factory
- `Result<T>` — Standard response wrapper with `_agent_instruction`
### loyalty-agent (`:9332`)
AI Agent / BFF — Orchestrates LLM, MCP, conversations, and WebSocket.
**Responsibilities:**
- Accept user messages via WebSocket/STOMP
- Classify intent (regex-based, zero LLM latency)
- Route to appropriate executor (SimpleAgent, Workflow, DirectChat)
- Stream LLM responses back to frontend
- Persist conversations to PostgreSQL
**Key Architecture:**
```text
AgentController (@MessageMapping /chat)
↓
LoyaltyAgentService
↓
AgentOrchestrator
├── Active Workflow? → WorkflowExecutor (multi-turn creation)
├── IntentClassifier → QUERY → SimpleAgentExecutor (ReAct + tools)
├── IntentClassifier → CONVERSATION → SimpleAgentExecutor (direct chat)
└── IntentClassifier → CREATE_* → WorkflowExecutor
```
**Intent Classification (regex-based):**
| Intent | Trigger Examples |
|--------|-----------------|
| `QUERY` | "tìm chiến dịch", "danh sách rule" |
| `CONVERSATION` | "xin chào", "cảm ơn" |
| `CREATE_CAMPAIGN` | "tạo chiến dịch mới" |
| `CREATE_RULE` | "thêm thể lệ" |
**Tool Scope Enforcement:**
| Scope | Allowed Operations |
|-------|-------------------|
| `READ_ONLY` | search, get, find, count, check |
| `CREATE_CAMPAIGN` | READ_ONLY + createCampaign + generateCampaignId |
| `CREATE_RULE` | READ_ONLY + createRule + generateRuleId |
### Frontend (`:9333`)
React/Vite conversational UI with STOMP WebSocket.
**Key Features:**
- STOMP over WebSocket for real-time messaging
- Token streaming (character-by-character LLM output)
- Tool execution status indicators
- Conversation management (create, list, delete, rename)
- Offline message queue with exponential backoff reconnect
## Security Architecture
```text
Frontend ──(WebSocket)──> Agent ──(MCP SSE)──> MCP Server ──(OAuth2)──> Core API
│
▼
Keycloak
```
**Principles:**
1. **LLM is never an authorization boundary** — tool scope is enforced by `AgentToolScope` before tools reach the LLM
2. **OAuth2 Client Credentials** — MCP Server authenticates to Core API via Keycloak
3. **Tool result sanitization** — `ToolInterceptor` strips Java stack traces
4. **Input validation** — All tool parameters validated before downstream calls
## Data Flow
### Chat Request Flow
```text
1. User types message in React UI
2. STOMP message → /app/chat → AgentController
3. AgentController → LoyaltyAgentService.chat()
4. AgentOrchestrator classifies intent
5. Executor starts LLM streaming with tools
6. LLM selects tool → ToolInterceptor wraps & calls MCP
7. MCP Server executes tool → calls Core API
8. Tool result → LLM reasons → generates response
9. Response streamed back via STOMP → /user/queue/chat-events
10. React UI renders tokens in real-time
```
## Resilience Patterns
| Pattern | Implementation |
|---------|---------------|
| MCP auto-reconnect | `ToolInterceptor.attemptReconnect()` |
| WebSocket reconnect | `StompService` exponential backoff + jitter |
| Request timeout | `SimpleAgentExecutor` configurable via `agent.request-timeout-seconds` |
| Tool result truncation | `ToolInterceptor` caps results > 4000 chars |
| Error sanitization | `ToolInterceptor` strips stack traces |
| Graceful shutdown | `server.shutdown: graceful` on both modules |
| LLM search noise filtering | `SearchUtils.sanitizeSearch()` |

165
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,165 @@
# Deployment Guide — Loyalty Agent Service
## Prerequisites
- Java 25+ (JRE)
- PostgreSQL 15+
- Ollama with `qwen3.5:4b` and `qwen2.5:1.5b` models
- Keycloak with OAuth2 Client Credentials configured
- Loyalty Core API running (`:8081`)
## Environment Variables
Copy `.env.example` to `.env` and configure all required values:
```bash
cp .env.example .env
# Edit .env with your production values
```
### Required Variables
| Variable | Service | Description |
|----------|---------|-------------|
| `KEYCLOAK_CLIENT_SECRET` | MCP Server | Keycloak client secret |
| `KEYCLOAK_TOKEN_URI` | MCP Server | Keycloak token endpoint |
| `LOYALTY_CORE_BASE_URL` | MCP Server | Loyalty Core API URL |
| `SPRING_DATASOURCE_PASSWORD` | Agent | PostgreSQL password |
| `MCP_SERVER_URL` | Agent | MCP SSE endpoint |
### Optional Variables (with defaults)
| Variable | Default | Description |
|----------|---------|-------------|
| `KEYCLOAK_CLIENT_ID` | `ols-cli` | OAuth2 client ID |
| `SPRING_DATASOURCE_URL` | `jdbc:postgresql://localhost:5433/loyalty_agent` | DB URL |
| `SPRING_DATASOURCE_USERNAME` | `postgres` | DB username |
| `OLLAMA_BASE_URL` | `http://localhost:11434` | Ollama URL |
| `OLLAMA_MODEL` | `qwen3.5:4b` | Primary LLM model |
| `AGENT_REQUEST_TIMEOUT` | `120` | LLM request timeout (seconds) |
## Building
```bash
# Build both modules
./mvnw clean package -DskipTests
# Run tests
./mvnw test
```
## Running Locally
### Quick Start
```bash
./start-all.sh
```
### Manual Start
```bash
# 1. Start MCP Server (:9331)
cd loyalty-mcp-server
java -jar target/*.jar
# 2. Start Agent (:9332)
cd loyalty-agent
java -jar target/*.jar
# 3. Start Frontend (:9333)
npm run dev
```
## Docker Deployment
### Build Images
```bash
# MCP Server
cd loyalty-mcp-server
docker build -t loyalty-mcp-server:latest .
# Agent
cd loyalty-agent
docker build -t loyalty-agent:latest .
```
### Run Containers
```bash
# MCP Server
docker run -d --name loyalty-mcp-server \
-p 9331:9331 \
-e KEYCLOAK_CLIENT_SECRET=... \
-e KEYCLOAK_TOKEN_URI=... \
-e LOYALTY_CORE_BASE_URL=... \
loyalty-mcp-server:latest
# Agent
docker run -d --name loyalty-agent \
-p 9332:9332 \
-e SPRING_DATASOURCE_PASSWORD=... \
-e MCP_SERVER_URL=http://loyalty-mcp-server:9331/sse \
loyalty-agent:latest
```
## Health Checks
Both services expose health endpoints via Spring Boot Actuator:
| Endpoint | Purpose |
|----------|---------|
| `/actuator/health` | Overall health |
| `/actuator/health/liveness` | Liveness probe (is the process alive?) |
| `/actuator/health/readiness` | Readiness probe (can it handle requests?) |
| `/actuator/info` | Service info |
### Kubernetes Probes
```yaml
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 9332
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 9332
initialDelaySeconds: 30
periodSeconds: 10
```
## JVM Configuration
Production JVM flags (included in Dockerfile):
```bash
java \
-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-Djava.security.egd=file:/dev/./urandom \
-jar app.jar
```
## Port Summary
| Service | Port |
|---------|------|
| Loyalty MCP Server | 9331 |
| Loyalty Agent | 9332 |
| Frontend (dev) | 9333 |
| Loyalty Core API | 8081 |
## Troubleshooting
### Common Issues
1. **MCP Connection Timeout**: Increase `spring.ai.mcp.client.request-timeout` (default: 30000ms)
2. **LLM Not Responding**: Check Ollama is running: `curl http://localhost:11434/api/tags`
3. **OAuth2 Token Error**: Verify Keycloak credentials and token-uri
4. **DB Migration Failed**: Check Flyway logs, ensure PostgreSQL is accessible
5. **WebSocket Disconnect**: Frontend auto-reconnects with exponential backoff

143
README.md
View File

@@ -1,31 +1,146 @@
# Loyalty Agent Service
This project consists of a 3-module architecture:
1. `loyalty-mcp-server`: Domain MCP tools and operations
2. `loyalty-agent`: Orchestration and BFF layer
3. Frontend (React/Vite in the root directory)
A production-ready AI-powered assistant for managing a Loyalty platform, built on the **Model Context Protocol (MCP)**.
## Architecture
```
Frontend (React/Vite :9333)
↓ WebSocket (STOMP)
loyalty-agent (:9332) ← Orchestrator + BFF
↓ MCP SSE Transport (/sse, /message)
loyalty-mcp-server (:9331) ← MCP Tool Server
↓ REST + OAuth2 (Keycloak)
Loyalty Core API (:8081) ← Backend Services
```
### Modules
| Module | Port | Role |
|--------|------|------|
| `loyalty-mcp-server` | 9331 | MCP Server — exposes 25 tools via SSE transport |
| `loyalty-agent` | 9332 | AI Agent — orchestrates LLM + MCP tools |
| Frontend (root) | 9333 | React/Vite chat UI |
## Technology Stack
- **Java 25**, Spring Boot 4.0.0, Spring AI 2.x
- **MCP Transport**: SSE (WebMVC) via `spring-ai-starter-mcp-server-webmvc`
- **LLM**: Ollama (qwen3.5:4b, configurable)
- **Auth**: Keycloak OAuth2 Client Credentials
- **Persistence**: PostgreSQL (agent conversation history)
- **Frontend**: React + Vite + WebSocket (STOMP)
## MCP Capabilities
### Tools (25 registered)
| Category | Tools |
|----------|-------|
| Campaign | `searchCampaigns`, `countCampaigns`, `getCampaignById`, `getCampaignsByIds`, `generateCampaignId`, `checkCampaignId`, `createCampaign` |
| Campaign Rule | `searchRules`, `countRules`, `getRuleById`, `getRulesByIds`, `generateRuleId`, `checkRuleId`, `createRule`, `findMatchingRules` |
| Pool Definition | `getPoolDefinitionById`, `getPoolDefinitionsByIds`, `searchPoolDefinitions` |
| Counter | `getCounterDefinitionById`, `getCounterDefinitionsByIds` |
| Deduction Sequence | `getDeductionSequenceById`, `getDefaultDeductionSequence` |
| Transaction Code | `getTransactionCode`, `getTransactionCodes`, `checkTransactionCodeExists` |
### Resources & Prompts
- Resources, resource templates, prompts, and completions capabilities are enabled via Spring AI autoconfiguration
- Currently no custom resources or prompts defined (the autoconfiguration registers empty capability declarations)
### Transport
- **SSE**: `/sse` endpoint for server-sent events
- **Message**: `/message` endpoint for client-to-server messages
- **Keep-alive**: 30-second interval configured
## Startup Instructions
### 1. Prerequisites
- Java 21+
### Prerequisites
- Java 25+
- Node.js & pnpm
- `.env` file created (see `.env.example`)
- PostgreSQL (for agent persistence)
- `.env` file created (see `.env` for template)
### 2. Start Backend Modules
You can use the provided startup script:
### Start Backend Modules
```bash
# Start both modules with one script:
./start-all.sh
```
Or start them individually in separate terminals:
```bash
# Or start individually:
./mvnw spring-boot:run -pl loyalty-mcp-server
./mvnw spring-boot:run -pl loyalty-agent
```
### 3. Start Frontend
In a new terminal:
### Start Frontend
```bash
pnpm install
pnpm dev
```
### Configuration
Key environment variables (see `.env`):
| Variable | Description |
|----------|-------------|
| `LOYALTY_CORE_BASE_URL` | Base URL for Loyalty Core API |
| `KEYCLOAK_TOKEN_URI` | Keycloak token endpoint |
| `KEYCLOAK_CLIENT_ID` | OAuth2 client ID |
| `KEYCLOAK_CLIENT_SECRET` | OAuth2 client secret |
| `SPRING_DATASOURCE_URL` | PostgreSQL connection for agent |
| `OLLAMA_BASE_URL` | Ollama LLM endpoint |
## Error Handling
- **AOP Aspect** (`GlobalToolExceptionHandlerAspect`): Wraps all `@McpTool` methods, catches exceptions and returns safe `Result.failure()` responses
- **REST Exception Handler** (`GlobalMcpExceptionHandler`): Catches transport-level exceptions with safe error response
- **Agent-side** (`ToolInterceptor`): Sanitizes error responses, auto-reconnects on dropped SSE connections, truncates large results
## Security
- OAuth2 Client Credentials flow for backend API authentication
- No internal stack traces or credentials exposed to MCP client
- Input validation on all tool parameters
- Response body logging limited to debug level with truncation
## Running Tests
```bash
# All tests
./mvnw test
# MCP server tests only
./mvnw test -pl loyalty-mcp-server
# Agent tests only
./mvnw test -pl loyalty-agent
```
## Health Checks
Both services expose Spring Boot Actuator health endpoints:
```
GET /actuator/health — Overall health
GET /actuator/health/liveness — Kubernetes liveness probe
GET /actuator/health/readiness — Kubernetes readiness probe
```
## Additional Tools (Domain Services)
| Category | Tools |
|----------|-------|
| App Param | `searchAppParam`, `getAppParamById` |
| Dynamic Attribute | `searchDynamicAttribute`, `getDynamicAttributeById` |
| Journey | `searchJourney`, `getJourneyById` |
| User | `searchUser`, `getUserById` |
| Customer | `searchCustomers`, `getCustomerById` |
| Catalogue | `searchCatalogues`, `getCatalogueById` |
| Transaction | `searchTransactions`, `getTransactionById` |
## Further Documentation
- [ARCHITECTURE.md](ARCHITECTURE.md) — System architecture, data flow, security model
- [DEPLOYMENT.md](DEPLOYMENT.md) — Production deployment guide
- [.env.example](.env.example) — Environment variable template

View File

@@ -14,6 +14,7 @@ Technology Stack: Java 25, Spring Boot 4.x, Spring AI 2.x, Spring WebSocket (STO
## 1. Primary Roles & Responsibilities
`loyalty-agent` acts as the **BFF (Backend-For-Frontend)** and **AI Orchestration Layer**:
- **BFF & WebSocket Server**: Manages WebSocket/STOMP connections with the Frontend (`src/`), receiving chat requests and streaming LLM responses back to the UI in real time.
- **AI Orchestrator**: Interacts with the Ollama LLM (`qwen3.5:4b`) via Spring AI `ChatClient`, routing user queries, managing prompt templates, and handling conversation state.
- **MCP Client**: Connects to `loyalty-mcp-server` via the MCP SSE protocol (`http://localhost:9331/sse`) to dynamically load and invoke loyalty business tools.
@@ -24,17 +25,20 @@ Technology Stack: Java 25, Spring Boot 4.x, Spring AI 2.x, Spring WebSocket (STO
## 2. Protocol & Real-time Chat Messaging Flow (WebSocket STOMP)
### WebSocket Configuration (`config/WebSocketConfig.java`)
- **STOMP Endpoint**: `/ws` (Supports SockJS fallback)
- **Application Destination Prefix**: `/app`
- **User Destination Prefix**: `/user`
- **Broker Prefixes**: `/topic`, `/queue`
### Messaging Flow
1. **Client Request**: Frontend sends a message to `/app/chat` with payload `{ content: string, conversationId: string }`.
2. **Server Processing**: `AgentController` receives the message and delegates it to `LoyaltyAgentService`, which processes it via Spring AI `ChatClient`.
3. **Event Streaming**: The server pushes events (`AgentEvent`) back to the client on `/user/queue/chat-events`.
### Event Types Sent to UI (`AgentEvent` Types)
- **`TOKEN`**: Text stream chunks returned in real time from the LLM. The UI appends these chunks sequentially for a typing effect.
- **`TOOL_STATUS`**: Status update when executing a tool (tool name, status `RUNNING` / `COMPLETED` / `FAILED`, arguments/results). The UI renders an animated indicator badge.
- **`DONE`**: Signals that the LLM has finished responding to the current request.
@@ -51,7 +55,7 @@ Technology Stack: Java 25, Spring Boot 4.x, Spring AI 2.x, Spring WebSocket (STO
- URL: `${MCP_SERVER_URL:http://localhost:9331/sse}`
- Transport: SSE (Server-Sent Events)
- **Loyalty Core Integration**:
- Base URL: `${LOYALTY_CORE_BASE_URL:http://192.168.99.242:8081}`
- Base URL: `${LOYALTY_CORE_BASE_URL:http://192.168.99.88:8081}`
- Authentication: Keycloak OAuth2 Client Credentials (`KEYCLOAK_TOKEN_URI`, `KEYCLOAK_CLIENT_SECRET`)
---

View File

@@ -14,6 +14,7 @@ Technology Stack: Java 25, Spring Boot 4.x, Spring AI 2.x MCP Server (`spring-ai
## 1. Primary Roles & Responsibilities
`loyalty-mcp-server` operates as a standalone **MCP Tool Server**:
- **Expose Tools to Agent**: Encapsulates loyalty business capabilities into standardized Model Context Protocol (MCP) tools.
- **MCP SSE Transport**: Exposes an SSE endpoint (`/sse`) and a Message endpoint (`/message`) for `loyalty-agent` to connect and execute tools remotely.
- **Loyalty Core API Integration**: Directly calls the Loyalty Core Backend API to query and manipulate domain entities (Campaigns, Rules, Point Pools, Members, Attributes...).
@@ -26,18 +27,43 @@ Technology Stack: Java 25, Spring Boot 4.x, Spring AI 2.x MCP Server (`spring-ai
Tools are registered using the `@McpTool` annotation in the `dev.sonpx.loyalty.mcp.tool` package.
### A. Campaign Management (`CampaignTools.java`)
- **`searchCampaigns(search)`**: Searches campaigns by keyword (name/code). Returns a list of `CampaignSummaryDto` (basic info: name, code, owner, type, schedule).
- **`countCampaigns(search)`**: Returns the total count of campaigns.
- **`searchCampaigns(request)`**: Searches campaigns by criteria (name, code, owner, type). Returns a list of `CampaignSummaryDto`.
- **`countCampaigns(request)`**: Returns the total count of campaigns matching criteria.
- **`getCampaignById(id)` / `getCampaignsByIds(ids)`**: Fetches details for one or multiple campaigns by ID.
- **`generateCampaignId()` / `checkCampaignId(id)`**: Generates a new campaign ID or verifies ID validity.
- **`createCampaign(Campaign dto)`**: Creates a new loyalty campaign.
### B. Campaign Rule Management (`CampaignRuleTools.java`)
- **`searchRules(search)`**: Searches campaign rules/terms by keyword (name/code). Returns a list of `CampaignRuleSummaryDto` (includes reward formula, parent campaign, rule type).
- **`countRules(search)`**: Returns the total count of rules.
- **`getRuleById(id)` / `getRulesByIds(ids)`**: Fetches details for one or multiple rules by ID (reward formula, conditions, limits, schedule).
- **`searchRules(request)`**: Searches campaign rules by criteria (name, campaign, type, dates). Returns `CampaignRuleSummaryDto`.
- **`countRules(request)`**: Returns the total count of rules matching criteria.
- **`getRuleById(id)` / `getRulesByIds(ids)`**: Fetches details for one or multiple rules by ID.
- **`generateRuleId()` / `checkRuleId(id)`**: Generates a new rule ID or checks ID validity.
- **`createRule(CampaignRule dto)`**: Creates a new campaign rule associated with a campaign.
- **`createRule(CampaignRule dto)`**: Creates a new campaign rule.
- **`findMatchingRules(ruleType, transactionCode, transactionDate, postDate, languageCode)`**: Finds rules matching a specific transaction.
### C. Pool Definition (`PoolDefinitionTools.java`)
- **`getPoolDefinitionById(poolId)`**: Fetches details for a Point Pool.
- **`getPoolDefinitionsByIds(ids)`**: Fetches multiple pools by IDs.
- **`searchPoolDefinitions(request)`**: Searches pools by criteria.
### D. Counter Definition (`CounterDefinitionTools.java`)
- **`getCounterDefinitionById(counterId)`**: Fetches details for a Counter.
- **`getCounterDefinitionsByIds(ids)`**: Fetches multiple counters by IDs.
### E. Deduction Sequence (`DeductionSequenceTools.java`)
- **`getDeductionSequenceById(deductionSeqId)`**: Fetches a deduction sequence config.
- **`getDefaultDeductionSequence()`**: Fetches the system default deduction sequence.
### F. Transaction Code (`TransactionCodeTools.java`)
- **`getTransactionCode(transactionCode)`**: Fetches details for a transaction code.
- **`getTransactionCodes(transactionCodes)`**: Fetches multiple transaction codes.
- **`checkTransactionCodeExists(transactionCode)`**: Checks if a transaction code exists.
---
@@ -45,7 +71,7 @@ Tools are registered using the `@McpTool` annotation in the `dev.sonpx.loyalty.m
- **OAuth2 Client Credentials Authentication**:
- Automatically fetches Bearer tokens from Keycloak (`KEYCLOAK_TOKEN_URI`, `KEYCLOAK_CLIENT_SECRET`).
- Attaches authorization headers to requests sent to `LOYALTY_CORE_BASE_URL` (`http://192.168.99.242:8081`).
- Attaches authorization headers to requests sent to `LOYALTY_CORE_BASE_URL` (`http://192.168.99.88:8081`).
- **OpenAPI Specs Registry**:
- Located at `src/main/resources/specs/`:
- `master.json`, `marketing.json`, `reward.json`, `customer.json`, `attribute.json`, `catalogue.json`, `transaction.json`, `identity.json`.
@@ -56,13 +82,16 @@ Tools are registered using the `@McpTool` annotation in the `dev.sonpx.loyalty.m
## 4. Response Wrapping & Exception Handling
### Standardized Response Wrapper
All tools return `Result<T>`:
- `success`: boolean
- `data`: T (Payload data)
- `message`: Summary status message or error details
- `presentation`: Object containing `content` (Markdown string intended for user-facing display)
- `data`: T (Payload data, null on validation errors)
- `_agent_instruction`: String (Instructions for the LLM on how to present the data, or `VALIDATION_ERROR: ...` for validation failures)
The `_agent_instruction` field is transformed by the agent's `ToolResultPresentationProcessor` into `agent_instruction` before being sent to the LLM.
### Exception Handling (`aspect/` & `exception/`)
- `GlobalToolExceptionHandlerAspect`: AOP aspect that intercepts exceptions across all `@McpTool` methods.
- `GlobalMcpExceptionHandler`: Catches Core API failures (HTTP 4xx, 5xx, timeouts) and formats them into clean `Result.failure(...)` objects for the LLM without crashing the invocation stream.

View File

@@ -35,7 +35,7 @@ An AI Assistant system designed to manage and operate Loyalty business domain ta
▼
┌──────────────────────────────────┐
│ Loyalty Core API (Spring Boot) │
│ 192.168.99.242:8081 │
│ 192.168.99.88:8081 │
└──────────────────────────────────┘
```
@@ -65,12 +65,15 @@ Each application has a dedicated context file containing detailed architecture d
## 3. Quick Startup & Commands
### Launch Entire Stack (Root Script)
```bash
./start-all.sh
```
*Automatically starts MCP Server (`9331`), Agent Service (`9332`), and Frontend (`9333`).*
_Automatically starts MCP Server (`9331`), Agent Service (`9332`), and Frontend (`9333`)._
### Manual Application Startup
```bash
# Terminal 1: MCP Server
./mvnw spring-boot:run -pl loyalty-mcp-server
@@ -83,6 +86,7 @@ pnpm dev
```
### Build & Test Commands
- **Frontend**: `pnpm install` | `pnpm lint` | `pnpm build`
- **Backend (Spring Boot)**: `./mvnw test` (Run all) | `./mvnw test -pl loyalty-agent` | `./mvnw test -pl loyalty-mcp-server`
@@ -91,9 +95,9 @@ pnpm dev
## 4. Ports & Environment Variables Reference
| Component | Port | Default Endpoint | Key Environment Variables |
| :--- | :--- | :--- | :--- |
| :--------------------- | :------ | :------------------------------------------------ | :--------------------------------------------- |
| **Frontend UI** | `9333` | `http://localhost:9333` | `VITE_AGENT_HTTP_URL`, `VITE_AGENT_WS_URL` |
| **Loyalty Agent** | `9332` | `http://localhost:9332`, `ws://localhost:9332/ws` | `MCP_SERVER_URL`, `LOYALTY_CORE_BASE_URL` |
| **Loyalty MCP Server** | `9331` | `http://localhost:9331/sse` | `KEYCLOAK_TOKEN_URI`, `KEYCLOAK_CLIENT_SECRET` |
| **Ollama LLM** | `11434` | `http://192.168.99.10:11434` | Defined in `loyalty-agent/application.yml` |
| **Loyalty Core API** | `8081` | `http://192.168.99.242:8081` | `LOYALTY_CORE_BASE_URL` |
| **Loyalty Core API** | `8081` | `http://192.168.99.88:8081` | `LOYALTY_CORE_BASE_URL` |

View File

@@ -1,6 +1,23 @@
FROM eclipse-temurin:25-jre-alpine
# Non-root user for security
RUN addgroup -S app && adduser -S app -G app
VOLUME /tmp
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
EXPOSE 8080
ENTRYPOINT ["java","-jar","/app.jar"]
RUN chown app:app /app.jar
USER app
EXPOSE 9332
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=60s \
CMD wget --quiet --tries=1 --spider http://localhost:9332/actuator/health/liveness || exit 1
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"-Djava.security.egd=file:/dev/./urandom", \
"-jar", "/app.jar"]

View File

@@ -65,6 +65,12 @@
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Actuator for Health Checks -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- PostgreSQL Driver -->
<dependency>
<groupId>org.postgresql</groupId>

View File

@@ -12,6 +12,8 @@ public class AppConfig {
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
ObjectMapper mapper = new ObjectMapper();
mapper.findAndRegisterModules();
return mapper;
}
}

View File

@@ -0,0 +1,30 @@
package dev.sonpx.loyalty.agent.config;
import org.slf4j.MDC;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.stereotype.Component;
@Component
public class MdcChannelInterceptor implements ChannelInterceptor {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor != null && accessor.getSessionId() != null) {
MDC.put("sessionId", accessor.getSessionId());
}
// Extract conversationId from STOMP headers if available
if (accessor != null && accessor.getFirstNativeHeader("conversationId") != null) {
MDC.put("conversationId", accessor.getFirstNativeHeader("conversationId"));
}
return message;
}
@Override
public void afterSendCompletion(Message<?> message, MessageChannel channel, boolean sent, Exception ex) {
MDC.clear();
}
}

View File

@@ -6,6 +6,11 @@ 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;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableWebSecurity
@@ -15,10 +20,29 @@ public class SecurityConfig {
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeHttpRequests(authz -> authz
.requestMatchers("/actuator/health/**", "/actuator/info").permitAll()
.requestMatchers("/api/v1/agent/**", "/api/v1/conversations/**", "/ws/**", "/error").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(List.of(
"http://localhost:*",
"http://127.0.0.1:*"
));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}

View File

@@ -3,7 +3,9 @@ package dev.sonpx.loyalty.agent.config;
import java.security.Principal;
import java.util.Map;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.WebSocketHandler;
@@ -14,8 +16,16 @@ import org.springframework.web.socket.server.support.DefaultHandshakeHandler;
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final MdcChannelInterceptor mdcChannelInterceptor;
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(mdcChannelInterceptor);
}
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue");

View File

@@ -4,13 +4,13 @@ import dev.sonpx.loyalty.agent.domain.ChatRequest;
import dev.sonpx.loyalty.agent.service.LoyaltyAgentService;
import java.security.Principal;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@CrossOrigin(origins = "*")
@RequiredArgsConstructor
public class AgentController {
@@ -18,6 +18,10 @@ public class AgentController {
@MessageMapping("/chat")
public void chat(@Payload ChatRequest request, Principal principal) {
if (request == null || request.prompt() == null || request.prompt().isBlank()) {
log.warn("Received empty or null chat request from session: {}", principal.getName());
return;
}
agentService.chat(request, principal.getName());
}

View File

@@ -5,6 +5,7 @@ import dev.sonpx.loyalty.agent.domain.Message;
import dev.sonpx.loyalty.agent.dto.ConversationSummaryDto;
import dev.sonpx.loyalty.agent.repository.ConversationRepository;
import dev.sonpx.loyalty.agent.repository.MessageRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
@@ -16,22 +17,16 @@ import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@RequiredArgsConstructor
@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;
}
@GetMapping
@Transactional
@Transactional(readOnly = true)
public ResponseEntity<List<ConversationSummaryDto>> getConversations() {
List<Conversation> conversations = conversationRepository.findAllByOrderByUpdatedAtDesc();
List<ConversationSummaryDto> dtos = new ArrayList<>();
@@ -39,9 +34,8 @@ public class ConversationController {
for (Conversation c : conversations) {
List<Message> messages = messageRepository.findByConversationIdOrderByCreatedAtAsc(c.getId());
// Clean up empty conversations from DB (except if it's the only one and newly created)
// Skip empty conversations (don't delete in GET — side-effect-free)
if (messages.isEmpty()) {
conversationRepository.delete(c);
continue;
}
@@ -58,10 +52,9 @@ public class ConversationController {
}
}
String preview = "";
Message lastMsg = messages.getLast();
String content = lastMsg.getContent();
preview = content.length() > 100 ? content.substring(0, 100) + "..." : content;
String preview = content.length() > 100 ? content.substring(0, 100) + "..." : content;
dtos.add(ConversationSummaryDto.builder()
.id(c.getId())
@@ -100,9 +93,15 @@ public class ConversationController {
if (newTitle == null || newTitle.isBlank()) {
return ResponseEntity.badRequest().build();
}
// Enforce max title length
String sanitizedTitle = newTitle.trim();
if (sanitizedTitle.length() > 255) {
sanitizedTitle = sanitizedTitle.substring(0, 255);
}
final String finalTitle = sanitizedTitle;
return conversationRepository.findById(id).map(conv -> {
conv.setTitle(newTitle.trim());
conv.setTitle(finalTitle);
conv.setUpdatedAt(Instant.now());
Conversation saved = conversationRepository.save(conv);
@@ -134,5 +133,3 @@ public class ConversationController {
return ResponseEntity.noContent().build();
}
}

View File

@@ -17,6 +17,18 @@ public enum Intent {
/** Create a new rule (multi-turn workflow) */
RULE_CREATE,
/** Query pool definitions */
POOL_QUERY,
/** Query customers, points, tiers */
CUSTOMER_QUERY,
/** Query transactions */
TRANSACTION_QUERY,
/** Query reference data (transaction codes, counters, deduction sequences, catalogues, app params, dynamic attributes, journeys) */
REFERENCE_DATA_QUERY,
/** General conversation — greetings, about, help */
CONVERSATION,

View File

@@ -25,12 +25,14 @@ public class IntentClassifier {
// CREATE intents first (higher priority than query)
PATTERNS.put(Intent.RULE_CREATE, List.of(
Pattern.compile("(?i)(tạo|thêm|add|tạo mới|thêm mới|setup|cấu hình mới)\\s*.*(rule|thể lệ|quy tắc|quy chế)"),
Pattern.compile("(?i)(rule|thể lệ|quy tắc)\\s*.*(tạo|thêm|mới)")
Pattern.compile("(?i)(rule|thể lệ|quy tắc)\\s*.*(tạo|thêm|mới)"),
Pattern.compile("(?i)(create|new|setup)\\s+.*(rule|regulation)")
));
PATTERNS.put(Intent.CAMPAIGN_CREATE, List.of(
Pattern.compile("(?i)(tạo|thêm|add|tạo mới|thêm mới|setup)\\s*.*(chiến dịch|campaign)"),
Pattern.compile("(?i)(chiến dịch|campaign)\\s*.*(tạo|thêm|mới)")
Pattern.compile("(?i)(chiến dịch|campaign)\\s*.*(tạo|thêm|mới)"),
Pattern.compile("(?i)(create|new|setup)\\s+.*(campaign)")
));
// QUERY intents
@@ -38,14 +40,44 @@ public class IntentClassifier {
Pattern.compile("(?i)(danh sách|tìm|xem|liệt kê|có bao nhiêu|mấy|đếm|show|list)\\s*.*(rule|thể lệ|quy tắc|quy chế)"),
Pattern.compile("(?i)(rule|thể lệ|quy tắc|quy chế)\\s*.*(nào|gì|nào đó|chi tiết|detail)"),
Pattern.compile("(?i)(thể lệ|rule|quy tắc|điều kiện|công thức thưởng|cách tính điểm|cách tính thưởng)"),
Pattern.compile("(?i)(chi tiết|detail|thông tin)\\s*.*(rule|thể lệ)")
Pattern.compile("(?i)(chi tiết|detail|thông tin)\\s*.*(rule|thể lệ)"),
Pattern.compile("(?i)(find|get|show|list|search)\\s+.*(rule|regulation)")
));
PATTERNS.put(Intent.CAMPAIGN_QUERY, List.of(
Pattern.compile("(?i)(danh sách|tìm|xem|liệt kê|có bao nhiêu|mấy|đếm|show|list)\\s*.*(chiến dịch|campaign)"),
Pattern.compile("(?i)(chiến dịch|campaign)\\s*.*(nào|gì|nào đó|chi tiết|detail)"),
Pattern.compile("(?i)(chi tiết|detail|thông tin)\\s*.*(chiến dịch|campaign)"),
Pattern.compile("(?i)(campaign|chiến dịch)")
Pattern.compile("(?i)(campaign|chiến dịch)"),
Pattern.compile("(?i)(find|get|show|list|search)\\s+.*(campaign)")
));
// POOL queries
PATTERNS.put(Intent.POOL_QUERY, List.of(
Pattern.compile("(?i)(danh sách|tìm|xem|liệt kê|show|list|chi tiết)\\s*.*(pool|quỹ điểm)"),
Pattern.compile("(?i)(pool|quỹ điểm)\\s*.*(nào|gì|chi tiết|detail)"),
Pattern.compile("(?i)(find|get|show|list|search)\\s+.*(pool)")
));
// CUSTOMER queries
PATTERNS.put(Intent.CUSTOMER_QUERY, List.of(
Pattern.compile("(?i)(danh sách|tìm|xem|liệt kê|tra cứu|show|list|thông tin)\\s*.*(khách hàng|customer|thẻ VIP|hạng thẻ)"),
Pattern.compile("(?i)(khách hàng|customer|thẻ VIP|hạng thẻ)\\s*.*(nào|gì|chi tiết|detail|tìm|tra cứu)"),
Pattern.compile("(?i)(điểm thưởng|loyalty points|điểm tích lũy|số điểm)\\s*(của|khách|customer)?"),
Pattern.compile("(?i)(find|get|show|list|search)\\s+.*(customer|member)")
));
// TRANSACTION queries
PATTERNS.put(Intent.TRANSACTION_QUERY, List.of(
Pattern.compile("(?i)(danh sách|tìm|xem|liệt kê|show|list|lịch sử)\\s*.*(giao dịch|transaction)"),
Pattern.compile("(?i)(giao dịch|transaction)\\s*.*(nào|gì|gần đây|chi tiết|detail|lịch sử)"),
Pattern.compile("(?i)(find|get|show|list|search)\\s+.*(transaction)")
));
// REFERENCE DATA queries (catch-all for catalogue, transaction codes, counters, etc.)
PATTERNS.put(Intent.REFERENCE_DATA_QUERY, List.of(
Pattern.compile("(?i)(danh sách|tìm|xem|liệt kê|show|list)\\s*.*(mã giao dịch|transaction code|counter|bộ đếm|deduction|khấu trừ|catalogue|danh mục|tham số|app param|thuộc tính|dynamic attribute|journey|hành trình)"),
Pattern.compile("(?i)(mã giao dịch|transaction code|counter|bộ đếm|deduction|khấu trừ|catalogue|danh mục|tham số|app param|thuộc tính|dynamic attribute|journey|hành trình)\\s*.*(nào|gì|chi tiết|detail)")
));
// CONVERSATION intents (lowest priority)

View File

@@ -29,6 +29,9 @@ public class AgentProperties {
/** Characters per token estimate for Vietnamese text */
private int charsPerToken = 2;
/** Timeout in seconds for streaming LLM responses */
private int requestTimeoutSeconds = 120;
/** Agent-specific model routing. Defaults preserve the existing single-model setup. */
private Models models = new Models();

View File

@@ -57,7 +57,8 @@ public enum AgentToolScope {
}
if (normalized.startsWith("search") || normalized.contains("_search")
|| normalized.startsWith("count") || normalized.contains("_count")
|| normalized.startsWith("get") || normalized.contains("_get")) {
|| normalized.startsWith("get") || normalized.contains("_get")
|| normalized.startsWith("find") || normalized.contains("_find")) {
return READ;
}
return OTHER;

View File

@@ -1,6 +1,7 @@
package dev.sonpx.loyalty.agent.core.executor;
import dev.sonpx.loyalty.agent.core.classifier.Intent;
import dev.sonpx.loyalty.agent.core.config.AgentProperties;
import dev.sonpx.loyalty.agent.core.memory.AgentMemoryManager;
import dev.sonpx.loyalty.agent.core.orchestrator.PromptBuilder;
import dev.sonpx.loyalty.agent.domain.ChatRequest;
@@ -12,7 +13,9 @@ import org.springframework.ai.tool.ToolCallback;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.TimeoutException;
/**
* Single-pass ReAct agent executor.
@@ -24,6 +27,7 @@ import java.util.List;
* - Memory advisor is always attached
* - Streams response from the start (no waiting for intermediate tasks)
* - Error events always sent to user
* - Configurable timeout to prevent runaway LLM sessions
*/
@Slf4j
@Component
@@ -36,6 +40,7 @@ public class SimpleAgentExecutor {
private final AgentMemoryManager memoryManager;
private final PromptBuilder promptBuilder;
private final AgentPersistenceService persistenceService;
private final AgentProperties agentProperties;
public SimpleAgentExecutor(@Qualifier("queryAgentChatClient") ChatClient queryChatClient,
@Qualifier("chatAgentChatClient") ChatClient chatChatClient,
@@ -43,7 +48,8 @@ public class SimpleAgentExecutor {
AgentEventPublisher eventPublisher,
AgentMemoryManager memoryManager,
PromptBuilder promptBuilder,
AgentPersistenceService persistenceService) {
AgentPersistenceService persistenceService,
AgentProperties agentProperties) {
this.queryChatClient = queryChatClient;
this.chatChatClient = chatChatClient;
this.toolInterceptor = toolInterceptor;
@@ -51,6 +57,7 @@ public class SimpleAgentExecutor {
this.memoryManager = memoryManager;
this.promptBuilder = promptBuilder;
this.persistenceService = persistenceService;
this.agentProperties = agentProperties;
}
/**
@@ -71,6 +78,8 @@ public class SimpleAgentExecutor {
persistenceService.saveUserMessage(conversationKey, request.messageId(), request.prompt());
StringBuilder responseAccumulator = new StringBuilder();
Duration timeout = Duration.ofSeconds(agentProperties.getRequestTimeoutSeconds());
queryChatClient.prompt()
.advisors(memoryManager.getAdvisor())
.advisors(a -> a
@@ -81,6 +90,7 @@ public class SimpleAgentExecutor {
.tools(wrappedTools)
.stream()
.content()
.timeout(timeout)
.doOnComplete(() -> {
log.info("SimpleAgent execution completed for conversation: {}, connection: {}", conversationKey, connectionSessionId);
persistenceService.saveAssistantMessage(conversationKey, responseAccumulator.toString());
@@ -95,10 +105,7 @@ public class SimpleAgentExecutor {
},
error -> {
log.error("Error during SimpleAgent execution for connection {}: {}", connectionSessionId, error.getMessage(), error);
String errorMessage = "Xin lỗi, đã xảy ra lỗi khi xử lý yêu cầu. Vui lòng thử lại.";
if (isConnectionError(error)) {
errorMessage = "Hiện tại dịch vụ AI (Ollama) đang tạm thời gián đoạn. Vui lòng thử lại sau.";
}
String errorMessage = resolveErrorMessage(error);
eventPublisher.error(connectionSessionId, request.messageId(), errorMessage);
}
);
@@ -112,19 +119,6 @@ public class SimpleAgentExecutor {
}
}
private boolean isConnectionError(Throwable t) {
if (t == null) return false;
Throwable current = t;
while (current != null) {
String name = current.getClass().getName();
if (name.contains("ConnectException") || name.contains("SocketException") || name.contains("ResourceAccessException")) {
return true;
}
current = current.getCause();
}
return false;
}
/**
* Execute a direct chat without tools (for CONVERSATION intent).
* No tools are loaded → faster response, less token usage.
@@ -138,6 +132,8 @@ public class SimpleAgentExecutor {
persistenceService.saveUserMessage(conversationKey, request.messageId(), request.prompt());
StringBuilder responseAccumulator = new StringBuilder();
Duration timeout = Duration.ofSeconds(agentProperties.getRequestTimeoutSeconds());
chatChatClient.prompt()
.advisors(memoryManager.getAdvisor())
.advisors(a -> a
@@ -147,6 +143,7 @@ public class SimpleAgentExecutor {
.user(userPrompt)
.stream()
.content()
.timeout(timeout)
.doOnComplete(() -> {
log.info("DirectChat completed for conversation: {}, connection: {}", conversationKey, connectionSessionId);
persistenceService.saveAssistantMessage(conversationKey, responseAccumulator.toString());
@@ -161,12 +158,32 @@ public class SimpleAgentExecutor {
},
error -> {
log.error("Error during DirectChat execution for connection {}: {}", connectionSessionId, error.getMessage(), error);
String errorMessage = "Xin lỗi, đã xảy ra lỗi. Vui lòng thử lại.";
if (isConnectionError(error)) {
errorMessage = "Hiện tại dịch vụ AI (Ollama) đang tạm thời gián đoạn. Vui lòng thử lại sau.";
}
String errorMessage = resolveErrorMessage(error);
eventPublisher.error(connectionSessionId, request.messageId(), errorMessage);
}
);
}
private String resolveErrorMessage(Throwable error) {
if (error instanceof TimeoutException) {
return "Yêu cầu đã vượt quá thời gian cho phép. Vui lòng thử lại với câu hỏi ngắn gọn hơn.";
}
if (isConnectionError(error)) {
return "Hiện tại dịch vụ AI (Ollama) đang tạm thời gián đoạn. Vui lòng thử lại sau.";
}
return "Xin lỗi, đã xảy ra lỗi khi xử lý yêu cầu. Vui lòng thử lại.";
}
private boolean isConnectionError(Throwable t) {
if (t == null) return false;
Throwable current = t;
while (current != null) {
String name = current.getClass().getName();
if (name.contains("ConnectException") || name.contains("SocketException") || name.contains("ResourceAccessException")) {
return true;
}
current = current.getCause();
}
return false;
}
}

View File

@@ -4,8 +4,10 @@ import dev.sonpx.loyalty.agent.core.config.AgentProperties;
import dev.sonpx.loyalty.agent.exception.UnrecoverableToolExecutionException;
import dev.sonpx.loyalty.agent.service.AgentEventPublisher;
import dev.sonpx.loyalty.agent.service.ToolResultPresentationProcessor;
import io.micrometer.core.instrument.MeterRegistry;
import io.modelcontextprotocol.client.McpSyncClient;
import lombok.RequiredArgsConstructor;
import org.slf4j.MDC;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.ai.tool.ToolCallback;
@@ -38,6 +40,7 @@ public class ToolInterceptor {
private final ToolResultPresentationProcessor toolResultProcessor;
private final AgentProperties agentProperties;
private final ObjectProvider<List<McpSyncClient>> mcpSyncClientsProvider;
private final MeterRegistry meterRegistry;
public List<ToolCallback> getWrappedTools(String sessionId, String messageId) {
return getWrappedTools(sessionId, messageId, AgentToolScope.ALL);
@@ -69,7 +72,9 @@ public class ToolInterceptor {
public String call(String toolInput) {
String toolName = getToolDefinition().name();
eventPublisher.toolRunning(sessionId, messageId, toolName);
long startTime = System.nanoTime();
try {
MDC.put("toolName", toolName);
String result = executeWithRetry(tool, toolInput, toolName);
// Unrecoverable raw system error responses (e.g. 404 HTML, connection reset)
@@ -93,10 +98,13 @@ public class ToolInterceptor {
} catch (UnrecoverableToolExecutionException e) {
throw e;
} catch (Exception e) {
log.error("Tool {} threw unrecoverable exception: {}", toolName, e.getMessage(), e);
throw new UnrecoverableToolExecutionException(
"Lỗi hệ thống không thể phục hồi khi gọi tool '" + toolName + "': " + e.getMessage(), e);
log.warn("Tool {} threw business/system exception: {}", toolName, e.getMessage());
// Instead of crashing the stream, return the error to the LLM
return "{\"error\": \"Lỗi khi gọi tool '" + toolName + "': " + e.getMessage() + "\"}";
} finally {
MDC.remove("toolName");
long duration = System.nanoTime() - startTime;
meterRegistry.timer("agent.tool.execution", "tool", toolName).record(duration, java.util.concurrent.TimeUnit.NANOSECONDS);
eventPublisher.toolDone(sessionId, messageId, toolName);
}
}
@@ -164,8 +172,7 @@ public class ToolInterceptor {
String msg = current.getMessage();
if (msg != null) {
String lower = msg.toLowerCase();
if (lower.contains("404")
|| lower.contains("session not found")
if (lower.contains("session not found")
|| lower.contains("session invalid")
|| lower.contains("connection reset")
|| lower.contains("connection refused")
@@ -182,7 +189,6 @@ public class ToolInterceptor {
if (result == null) return false;
String lower = result.toLowerCase();
return lower.contains("404 session not found")
|| lower.contains("http status 404")
|| lower.contains("connection refused")
|| lower.contains("eofexception");
}

View File

@@ -61,7 +61,7 @@ public class AgentOrchestrator {
log.info("Starting ConversationWorkflow for: {}", intent);
workflowExecutor.startWorkflow(intent, request, connectionSessionId, conversationKey);
}
case CAMPAIGN_QUERY, RULE_QUERY, UNKNOWN -> {
case CAMPAIGN_QUERY, RULE_QUERY, POOL_QUERY, CUSTOMER_QUERY, TRANSACTION_QUERY, REFERENCE_DATA_QUERY, UNKNOWN -> {
log.info("Routing to SimpleAgent with tools");
simpleAgent.executeWithTools(request, connectionSessionId, conversationKey, intent);
}

View File

@@ -32,12 +32,53 @@ public class PromptBuilder {
private static final String TOOL_GUIDANCE = """
HƯỚNG DẪN CHỌN TOOL VÀ TRUYỀN THAM SỐ:
- Hỏi xem/lấy danh sách chiến dịch chung → dùng searchCampaigns. KHÔNG truyền từ khóa chung ("chiến dịch", "danh sách", "campaign") vào param `search`, hãy để `search` = null hoặc rỗng. Chỉ truyền `search` khi tìm kiếm tên/mã chiến dịch cụ thể.
- Hỏi xem/lấy danh sách thể lệ/rule chung → dùng searchRules. KHÔNG truyền từ khóa chung ("rule", "thể lệ", "danh sách") vào param `search`, hãy để `search` = null hoặc rỗng.
[Chiến dịch]
- Hỏi xem/lấy danh sách chiến dịch chung → dùng searchCampaigns. KHÔNG truyền từ khóa chung ("chiến dịch", "danh sách", "campaign") vào filter, hãy để null. Chỉ truyền filter khi tìm kiếm tên/mã cụ thể.
- Đếm số lượng chiến dịch → dùng countCampaigns
- Lấy chi tiết 1 chiến dịch theo ID → dùng getCampaignById
- Lấy nhiều chiến dịch theo danh sách ID → dùng getCampaignsByIds
- Kiểm tra mã chiến dịch → dùng checkCampaignId
[Thể lệ / Rule]
- Hỏi xem danh sách thể lệ/rule → dùng searchRules. Để filter null nếu lấy tất cả.
- Đếm số lượng rule → dùng countRules
- Kiểm tra mã → dùng checkCampaignId hoặc checkRuleId
- Lấy chi tiết → dùng getCampaignById hoặc getRuleById
- Lấy chi tiết 1 rule theo ID → dùng getRuleById
- Lấy nhiều rule theo danh sách ID → dùng getRulesByIds
- Kiểm tra mã rule → dùng checkRuleId
- Tìm rule phù hợp với giao dịch → dùng findMatchingRules (cần: ruleType, transactionCode, transactionDate, postDate, languageCode)
[Pool điểm]
- Tìm kiếm pool → dùng searchPools
- Lấy chi tiết pool theo ID → dùng getPoolDefinitionById
- Lấy nhiều pool theo ID → dùng getPoolDefinitionsByIds
[Khách hàng]
- Tìm kiếm khách hàng → dùng searchCustomers
- Lấy chi tiết khách hàng theo ID → dùng getCustomerById
[Giao dịch]
- Tìm kiếm giao dịch → dùng searchTransactions
- Lấy chi tiết giao dịch theo ID → dùng getTransactionById
[Dữ liệu tham chiếu]
- Lấy mã giao dịch theo mã → dùng getTransactionCode
- Lấy nhiều mã giao dịch → dùng getTransactionCodes
- Kiểm tra mã giao dịch tồn tại → dùng checkTransactionCodeExists
- Lấy bộ đếm theo ID → dùng getCounterDefinitionById
- Lấy nhiều bộ đếm → dùng getCounterDefinitionsByIds
- Lấy trình tự khấu trừ theo ID → dùng getDeductionSequenceById
- Lấy trình tự khấu trừ mặc định → dùng getDefaultDeductionSequence
- Tìm danh mục/catalogue → dùng searchCatalogues
- Lấy chi tiết catalogue → dùng getCatalogueById
- Tìm tham số hệ thống → dùng searchAppParam
- Lấy chi tiết tham số → dùng getAppParamById
- Tìm thuộc tính động → dùng searchDynamicAttribute
- Lấy chi tiết thuộc tính → dùng getDynamicAttributeById
- Tìm hành trình khách hàng → dùng searchJourney
- Lấy chi tiết hành trình → dùng getJourneyById
- Tìm người dùng hệ thống → dùng searchUser
- Lấy chi tiết người dùng → dùng getUserById
Bạn đang ở Query Agent: KHÔNG tạo mới dữ liệu. Nếu người dùng muốn tạo chiến dịch/rule,
hãy nói họ xác nhận yêu cầu tạo để chuyển sang workflow tạo.""";
@@ -78,11 +119,4 @@ public class PromptBuilder {
return String.join("\n", context) + "\n\n" + prompt;
}
/**
* @deprecated Use {@link #buildUserPrompt(ChatRequest)} instead.
*/
@Deprecated
public String build(ChatRequest request) {
return buildUserPrompt(request);
}
}

View File

@@ -35,7 +35,7 @@ public class WorkflowDataExtractor {
default -> "Thông tin bổ sung";
};
extracted.put(phaseKey, userInput.trim());
extractKeyValues(userInput, extracted);
extractKeyValues(userInput, extracted, state.getWorkflowType());
extractKnownFields(state.getWorkflowType(), userInput, extracted);
return extracted;
}
@@ -47,14 +47,23 @@ public class WorkflowDataExtractor {
if (state.getWorkflowType() == Intent.RULE_CREATE && state.getCurrentPhase() == WorkflowState.Phase.REWARD_CONFIG) {
return true;
}
return userInput.trim().length() >= 20 || state.getCollectedData().size() >= 3;
Map<String, Object> data = state.getCollectedData();
if (state.getWorkflowType() == Intent.CAMPAIGN_CREATE && state.getCurrentPhase() == WorkflowState.Phase.BASIC_INFO) {
return data.containsKey("Tên chiến dịch") && data.containsKey("Loại chiến dịch") && data.containsKey("Thời gian hiệu lực");
} else if (state.getWorkflowType() == Intent.RULE_CREATE && state.getCurrentPhase() == WorkflowState.Phase.BASIC_INFO) {
return data.containsKey("Tên rule") && data.containsKey("Campaign ID") && data.containsKey("Loại rule") && data.containsKey("Thời gian hiệu lực");
}
private void extractKeyValues(String userInput, Map<String, Object> extracted) {
return userInput.trim().length() >= 20 || data.size() >= 3;
}
private void extractKeyValues(String userInput, Map<String, Object> extracted, Intent workflowType) {
for (String line : userInput.split("\\R")) {
Matcher matcher = KEY_VALUE.matcher(line);
if (matcher.matches()) {
String key = normalizeKey(matcher.group(1));
key = contextualizeKey(key, workflowType);
String value = matcher.group(2).trim();
if (StringUtils.hasText(key) && StringUtils.hasText(value)) {
extracted.put(key, value);
@@ -102,4 +111,24 @@ public class WorkflowDataExtractor {
.replaceAll("\\s+", " ")
.replaceAll("^[0-9.)\\s-]+", "");
}
private String contextualizeKey(String key, Intent workflowType) {
String lower = key.toLowerCase().trim();
if (lower.equals("tên") || lower.equals("name") || lower.equals("tên hiển thị")) {
return workflowType == Intent.CAMPAIGN_CREATE ? "Tên chiến dịch" : "Tên rule";
}
if (lower.equals("mô tả") || lower.equals("description")) {
return workflowType == Intent.CAMPAIGN_CREATE ? "Mô tả chiến dịch" : "Mô tả rule";
}
if (lower.equals("loại") || lower.equals("type")) {
return workflowType == Intent.CAMPAIGN_CREATE ? "Loại chiến dịch" : "Loại rule";
}
if (lower.equals("hiệu lực") || lower.equals("thời gian") || lower.equals("ngày")) {
return "Thời gian hiệu lực";
}
if (lower.equals("owner") || lower.equals("người sở hữu") || lower.equals("phòng ban")) {
return "Owner";
}
return key;
}
}

View File

@@ -30,6 +30,15 @@ import java.util.regex.Pattern;
* - LLM is used to extract data from natural language and generate responses
* - Tool calls only happen in the final EXECUTE phase
* - User can cancel at any time
*
* KNOWN LIMITATION — In-Memory Workflow State:
* WorkflowState is stored in a ConcurrentHashMap and lost on server restart.
* This is acceptable because:
* 1. Workflows are short-lived (typically 2-5 minutes for campaign/rule creation)
* 2. Server restarts during active workflows are rare in production
* 3. Persisting to PostgreSQL would require schema migration, JSON serialization
* of collectedData, and recovery logic — complexity not justified by the risk
* 4. If state is lost, the user receives a friendly message to restart the workflow
*/
@Slf4j
@Component
@@ -102,7 +111,7 @@ public class WorkflowExecutor {
String systemPrompt = getPhasePrompt(state);
String userPrompt = request.prompt() != null ? request.prompt() : "Bắt đầu tạo";
streamResponse(systemPrompt, userPrompt, connectionSessionId, conversationKey, request.messageId(), false);
streamResponse(systemPrompt, userPrompt, connectionSessionId, conversationKey, request.messageId(), true, AgentToolScope.READ_ONLY, null, null);
}
/**
@@ -112,7 +121,11 @@ public class WorkflowExecutor {
public void continueWorkflow(ChatRequest request, String connectionSessionId, String conversationKey) {
WorkflowState state = activeWorkflows.get(conversationKey);
if (state == null || !state.isActive()) {
log.warn("No active workflow for conversation: {}", conversationKey);
log.warn("No active workflow for conversation: {} (state may have been lost on restart)", conversationKey);
streamResponse(
"Phiên tạo trước đó đã hết hạn hoặc bị mất do hệ thống khởi động lại. "
+ "Hãy thông báo cho người dùng rằng phiên tạo đã kết thúc và gợi ý họ bắt đầu lại nếu cần.",
request.prompt(), connectionSessionId, conversationKey, request.messageId(), false);
return;
}
@@ -154,14 +167,14 @@ public class WorkflowExecutor {
if (!dataExtractor.hasEnoughForNextPhase(state, request.prompt())) {
streamResponse(WorkflowPromptBuilder.missingInfoPrompt(state),
request.prompt(), connectionSessionId, conversationKey, request.messageId(), false);
request.prompt(), connectionSessionId, conversationKey, request.messageId(), true, AgentToolScope.READ_ONLY, null, null);
return;
}
String systemPrompt = WorkflowPromptBuilder.extractDataPrompt(state);
// LLM extracts data and confirms — no tools needed yet
streamResponse(systemPrompt, request.prompt(), connectionSessionId, conversationKey, request.messageId(), false);
// LLM extracts data, validates IDs with tools, and confirms
streamResponse(systemPrompt, request.prompt(), connectionSessionId, conversationKey, request.messageId(), true, AgentToolScope.READ_ONLY, null, null);
// Advance phase after LLM responds
// The LLM will indicate if data is complete; user's next message drives phase transition
@@ -184,12 +197,12 @@ public class WorkflowExecutor {
if (!dataExtractor.hasEnoughForNextPhase(state, request.prompt())) {
streamResponse(WorkflowPromptBuilder.missingInfoPrompt(state),
request.prompt(), connectionSessionId, conversationKey, request.messageId(), false);
request.prompt(), connectionSessionId, conversationKey, request.messageId(), true, AgentToolScope.READ_ONLY, null, null);
return;
}
String systemPrompt = WorkflowPromptBuilder.extractDataPrompt(state);
streamResponse(systemPrompt, request.prompt(), connectionSessionId, conversationKey, request.messageId(), false);
streamResponse(systemPrompt, request.prompt(), connectionSessionId, conversationKey, request.messageId(), true, AgentToolScope.READ_ONLY, null, null);
// Advance to CONFIRMING
state.advancePhase();
@@ -252,7 +265,7 @@ public class WorkflowExecutor {
private void streamResponse(String systemPrompt, String userPrompt,
String connectionSessionId, String conversationKey,
String messageId, boolean withTools) {
streamResponse(systemPrompt, userPrompt, connectionSessionId, conversationKey, messageId, withTools, AgentToolScope.ALL, null, null);
streamResponse(systemPrompt, userPrompt, connectionSessionId, conversationKey, messageId, withTools, AgentToolScope.READ_ONLY, null, null);
}
private void streamResponse(String systemPrompt, String userPrompt,
@@ -278,10 +291,25 @@ public class WorkflowExecutor {
builder.stream()
.content()
.doOnComplete(() -> {
persistenceService.saveAssistantMessage(conversationKey, responseAccumulator.toString());
String fullResponse = responseAccumulator.toString();
persistenceService.saveAssistantMessage(conversationKey, fullResponse);
if (onComplete != null) {
// Check if this was an execution phase with tools
if (withTools) {
if (fullResponse.contains("[ERROR]") || isExecutionError(fullResponse)) {
log.warn("Tool execution reported error for conversation: {}", conversationKey);
if (onError != null) {
onError.run();
}
} else {
log.info("Tool execution succeeded for conversation: {}", conversationKey);
onComplete.run();
}
} else {
onComplete.run();
}
}
eventPublisher.done(connectionSessionId, messageId);
})
.subscribe(
@@ -301,4 +329,20 @@ public class WorkflowExecutor {
}
);
}
/**
* Heuristic error detection for cases where the LLM forgets the [ERROR] prefix.
* Checks for Vietnamese error keywords that indicate a failed tool execution.
*/
private boolean isExecutionError(String response) {
if (response == null || response.isEmpty()) return false;
String lower = response.toLowerCase();
// Only match if response does NOT contain [SUCCESS] — avoid false positives
if (lower.contains("[success]")) return false;
return lower.contains("lỗi xác thực")
|| lower.contains("lỗi từ core api")
|| lower.contains("lỗi hệ thống")
|| lower.contains("không thể tạo")
|| lower.contains("thất bại")
|| lower.contains("đã tồn tại");
}
}

View File

@@ -51,12 +51,24 @@ public class WorkflowPromptBuilder {
return """
Người dùng đã xác nhận tạo chiến dịch. Hãy thực hiện:
1. Gọi tool `generateCampaignId` để tạo mã chiến dịch
2. Gọi tool `createCampaign` với thông tin sau:
1. Gọi tool `generateCampaignId` để tạo mã chiến dịch (nếu chưa có)
2. Gọi tool `createCampaign` với dữ liệu đã thu thập.
HƯỚNG DẪN MAP DỮ LIỆU sang JSON:
- Tên chiến dịch → trường "name"
- Mô tả → trường "description"
- Loại chiến dịch: Base → "B", Tactical → "T" → trường "campaignType"
- Ngày bắt đầu (yyyy-MM-dd) → trường "effectiveFrom"
- Ngày kết thúc (yyyy-MM-dd) → trường "effectiveTo"
- Owner/người sở hữu → trường "ownerName"
- Mã chiến dịch (từ generateCampaignId) → trường "campaignId"
Dữ liệu đã thu thập:
""" + formatData(data) + """
Sau khi tạo xong, thông báo kết quả cho người dùng.""";
QUAN TRỌNG:
Sau khi gọi tool, nếu kết quả trả về báo lỗi (ví dụ: trùng ID, thiếu dữ liệu), hãy bắt đầu câu trả lời bằng [ERROR] và giải thích lỗi cho người dùng để họ có thể nhập lại.
Nếu kết quả báo thành công (ví dụ: errorCode = 00 hoặc không có lỗi), hãy bắt đầu câu trả lời bằng [SUCCESS] và thông báo kết quả cho người dùng.""";
}
// ========== RULE CREATION PROMPTS ==========
@@ -81,7 +93,7 @@ public class WorkflowPromptBuilder {
5. Thời gian hiệu lực: từ ngày → đến ngày
Hỏi TẤT CẢ các thông tin trên trong MỘT lần. Liệt kê rõ ràng từng mục.
KHÔNG gọi tool nào. Chỉ hỏi thông tin.""";
Nếu người dùng đã cung cấp mã chiến dịch, hãy dùng tool `checkCampaignId` hoặc `getCampaignById` để xác thực ngay. KHÔNG gọi tool createCampaign hay createRule.""";
}
static String ruleRewardConfigPrompt(Map<String, Object> data) {
@@ -120,12 +132,26 @@ public class WorkflowPromptBuilder {
return """
Người dùng đã xác nhận tạo rule. Hãy thực hiện:
1. Gọi tool `generateRuleId` để tạo mã rule
2. Gọi tool `createRule` với thông tin sau:
1. Gọi tool `generateRuleId` để tạo mã rule (nếu chưa có)
2. Gọi tool `createRule` với dữ liệu đã thu thập.
HƯỚNG DẪN MAP DỮ LIỆU sang JSON:
- Tên rule → trường "ruleName"
- Mô tả → trường "description"
- Mã chiến dịch → trường "campaignId"
- Loại rule (AWD/RED/IRED/ADJ/CEP/REP/TEP/MAWD) → trường "ruleType"
- Thời gian bắt đầu (yyyy-MM-dd'T'HH:mm:ss) → trường "effectiveFrom"
- Thời gian kết thúc (yyyy-MM-dd'T'HH:mm:ss) → trường "effectiveTo"
- Pool ID → trường "poolId"
- Giá trị thưởng (số) → trường "rewardAmount"
- Mã rule (từ generateRuleId) → trường "ruleId"
Dữ liệu đã thu thập:
""" + formatData(data) + """
Sau khi tạo xong, thông báo kết quả cho người dùng.""";
QUAN TRỌNG:
Sau khi gọi tool, nếu kết quả trả về báo lỗi (ví dụ: trùng ID, thiếu dữ liệu), hãy bắt đầu câu trả lời bằng [ERROR] và giải thích lỗi cho người dùng để họ có thể nhập lại.
Nếu kết quả báo thành công (ví dụ: errorCode = 00 hoặc không có lỗi), hãy bắt đầu câu trả lời bằng [SUCCESS] và thông báo kết quả cho người dùng.""";
}
// ========== COMMON PROMPTS ==========
@@ -136,12 +162,13 @@ public class WorkflowPromptBuilder {
Người dùng đang cung cấp thông tin để tạo %s.
Dựa trên câu trả lời của người dùng, hãy trích xuất thông tin và xác nhận lại.
Nếu thiếu thông tin quan trọng, hãy hỏi lại phần còn thiếu.
Nếu người dùng cung cấp một mã (như Campaign ID), hãy dùng tool `checkCampaignId` hoặc `getCampaignById` để xác thực xem mã đó có hợp lệ/tồn tại không. Nếu không tồn tại, hãy báo lỗi và yêu cầu nhập lại.
Thông tin đã có trước đó:
%s
KHÔNG gọi tool nào. Chỉ xác nhận thông tin và hỏi phần còn thiếu (nếu có).
Nếu đã đủ thông tin cơ bản, hãy nói "Tôi đã ghi nhận đủ thông tin" và hỏi người dùng muốn tiếp tục cấu hình chi tiết hay tạo luôn.
Chỉ gọi tool để kiểm tra (READ_ONLY). KHÔNG gọi tool createCampaign hay createRule lúc này.
Nếu đã đủ thông tin cơ bản và hợp lệ, hãy nói "Tôi đã ghi nhận đủ thông tin" và hỏi người dùng muốn tiếp tục cấu hình chi tiết hay tạo luôn.
""".formatted(target, formatData(state.getCollectedData()));
}
@@ -163,7 +190,8 @@ public class WorkflowPromptBuilder {
static boolean isConfirmation(String userInput) {
if (userInput == null) return false;
String lower = userInput.trim().toLowerCase();
return lower.matches("(?i).*(xác nhận|đồng ý|ok|oke|được|tạo đi|tạo luôn|yes|confirm|đúng rồi|chính xác).*");
return lower.matches("(?i)^\\s*(xác nhận|đồng ý|tạo đi|tạo luôn|yes|confirm|đúng rồi|chính xác|ok|oke|được)(\\s*[.!?]*)$")
|| lower.matches("(?i).*(xác nhận|đồng ý|tạo đi|tạo luôn|confirm|đúng rồi|chính xác).*");
}
/**
@@ -172,7 +200,8 @@ public class WorkflowPromptBuilder {
static boolean isCancellation(String userInput) {
if (userInput == null) return false;
String lower = userInput.trim().toLowerCase();
return lower.matches("(?i)^\\s*(hủy|cancel|thôi|bỏ qua workflow|dừng|stop|không tạo|không tạo nữa|hủy tạo)(\\s|[.!?])*.*");
return lower.matches("(?i)^\\s*(hủy|cancel|thôi|bỏ qua workflow|dừng|stop|không tạo|không tạo nữa|hủy tạo)(\\s|[.!?])*.*")
|| lower.matches("(?i).*(hủy tạo|hủy workflow|cancel workflow|không muốn tạo|dừng tạo|bỏ tạo).*");
}
/**

View File

@@ -0,0 +1,36 @@
package dev.sonpx.loyalty.agent.health;
import io.modelcontextprotocol.client.McpSyncClient;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.health.contributor.Health;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class McpServerHealthIndicator implements HealthIndicator {
private final ObjectProvider<List<McpSyncClient>> mcpClientsProvider;
public McpServerHealthIndicator(ObjectProvider<List<McpSyncClient>> mcpClientsProvider) {
this.mcpClientsProvider = mcpClientsProvider;
}
@Override
public Health health() {
List<McpSyncClient> clients = mcpClientsProvider.getIfAvailable();
if (clients == null || clients.isEmpty()) {
return Health.unknown().withDetail("message", "No MCP clients configured").build();
}
try {
for (McpSyncClient client : clients) {
client.listTools();
}
return Health.up().withDetail("clients", clients.size()).build();
} catch (Exception e) {
return Health.down().withException(e).build();
}
}
}

View File

@@ -0,0 +1,31 @@
package dev.sonpx.loyalty.agent.health;
import dev.sonpx.loyalty.agent.core.config.AgentProperties;
import org.springframework.boot.health.contributor.Health;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
@Component
public class OllamaHealthIndicator implements HealthIndicator {
private final RestClient restClient;
public OllamaHealthIndicator(AgentProperties properties) {
String baseUrl = properties.getModels().getQuery().getBaseUrl();
this.restClient = RestClient.builder().baseUrl(baseUrl).build();
}
@Override
public Health health() {
try {
restClient.get()
.uri("/api/tags")
.retrieve()
.toBodilessEntity();
return Health.up().build();
} catch (Exception e) {
return Health.down().withException(e).build();
}
}
}

View File

@@ -0,0 +1,24 @@
spring:
devtools:
restart:
enabled: false
datasource:
hikari:
maximum-pool-size: 10
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
logging:
level:
root: WARN
dev.sonpx.loyalty: INFO
org.springframework.ai: WARN
pattern:
console:
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus

View File

@@ -3,6 +3,8 @@ server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
devtools:
restart:
quiet-period: 1000ms
@@ -14,9 +16,9 @@ spring:
application:
name: loyalty-agent-service
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://192.168.99.242:5433/loyalty_agent}
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5433/loyalty_agent}
username: ${SPRING_DATASOURCE_USERNAME:postgres}
password: ${SPRING_DATASOURCE_PASSWORD:Sonpx@1234}
password: ${SPRING_DATASOURCE_PASSWORD}
driver-class-name: org.postgresql.Driver
jpa:
open-in-view: false
@@ -32,15 +34,15 @@ spring:
locations: classpath:db/migration
ai:
ollama:
base-url: http://192.168.99.10:11434
base-url: ${OLLAMA_BASE_URL:http://localhost:11434}
chat:
options:
model: qwen3.5:4b
model: ${OLLAMA_MODEL:qwen3.5:4b}
temperature: 0.3
num_ctx: 32768
mcp:
client:
request-timeout: 3000
request-timeout: 30000
enabled: true
type: sync
sse:
@@ -48,11 +50,27 @@ spring:
loyalty:
url: ${MCP_SERVER_URL:http://localhost:9331/sse}
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: when-authorized
probes:
enabled: true
health:
livenessState:
enabled: true
readinessState:
enabled: true
logging:
pattern:
console: "%d{HH:mm:ss.SSS} %5p --- %-40.40logger{39} : %m%n"
level:
org.flywaydb: DEBUG
org.flywaydb: INFO
org.springframework.ai: INFO
dev.sonpx.loyalty: DEBUG
root: warn
@@ -66,15 +84,15 @@ agent:
num-ctx: ${CHAT_MODEL_NUM_CTX:8192}
disable-thinking: ${CHAT_MODEL_DISABLE_THINKING:true}
query:
base-url: ${QUERY_OLLAMA_BASE_URL:http://192.168.99.10:11434}
base-url: ${QUERY_OLLAMA_BASE_URL:http://localhost:11434}
model: ${QUERY_MODEL:qwen3.5:4b}
temperature: ${QUERY_MODEL_TEMPERATURE:0.3}
num-ctx: ${QUERY_MODEL_NUM_CTX:32768}
disable-thinking: ${QUERY_MODEL_DISABLE_THINKING:false}
creator:
base-url: ${CREATOR_OLLAMA_BASE_URL:http://192.168.99.10:11434}
base-url: ${CREATOR_OLLAMA_BASE_URL:http://localhost:11434}
model: ${CREATOR_MODEL:qwen3.5:4b}
temperature: ${CREATOR_MODEL_TEMPERATURE:0.3}
num-ctx: ${CREATOR_MODEL_NUM_CTX:32768}
disable-thinking: ${CREATOR_MODEL_DISABLE_THINKING:false}
request-timeout-seconds: ${AGENT_REQUEST_TIMEOUT:120}

View File

@@ -102,6 +102,65 @@ class IntentClassifierTest {
assertEquals(Intent.RULE_CREATE, classifier.classify(input));
}
// ========== POOL QUERY ==========
@ParameterizedTest
@ValueSource(strings = {
"danh sách pool",
"xem pool điểm",
"tìm quỹ điểm",
"pool nào đang có"
})
@DisplayName("Should classify pool queries as POOL_QUERY")
void shouldClassifyPoolQuery(String input) {
assertEquals(Intent.POOL_QUERY, classifier.classify(input));
}
// ========== CUSTOMER QUERY ==========
@ParameterizedTest
@ValueSource(strings = {
"tìm khách hàng",
"tra cứu khách hàng VIP",
"thông tin customer",
"xem hạng thẻ",
"điểm thưởng khách"
})
@DisplayName("Should classify customer queries as CUSTOMER_QUERY")
void shouldClassifyCustomerQuery(String input) {
assertEquals(Intent.CUSTOMER_QUERY, classifier.classify(input));
}
// ========== TRANSACTION QUERY ==========
@ParameterizedTest
@ValueSource(strings = {
"xem giao dịch gần đây",
"lịch sử giao dịch",
"tìm transaction",
"danh sách giao dịch"
})
@DisplayName("Should classify transaction queries as TRANSACTION_QUERY")
void shouldClassifyTransactionQuery(String input) {
assertEquals(Intent.TRANSACTION_QUERY, classifier.classify(input));
}
// ========== REFERENCE DATA QUERY ==========
@ParameterizedTest
@ValueSource(strings = {
"tìm bộ đếm",
"xem catalogue",
"danh sách danh mục",
"tìm tham số hệ thống",
"xem counter definition",
"danh sách deduction"
})
@DisplayName("Should classify reference data queries as REFERENCE_DATA_QUERY")
void shouldClassifyReferenceDataQuery(String input) {
assertEquals(Intent.REFERENCE_DATA_QUERY, classifier.classify(input));
}
// ========== UNKNOWN ==========
@ParameterizedTest
@@ -143,4 +202,88 @@ class IntentClassifierTest {
assertEquals(Intent.CAMPAIGN_CREATE, classifier.classify("tạo chiến dịch mới"));
assertEquals(Intent.RULE_CREATE, classifier.classify("tạo rule mới"));
}
// ========== ENGLISH LANGUAGE SUPPORT ==========
@ParameterizedTest
@ValueSource(strings = {
"find all campaigns",
"show campaigns",
"list active campaigns",
"search campaign by name",
"get campaign details"
})
@DisplayName("Should classify English campaign queries as CAMPAIGN_QUERY")
void shouldClassifyEnglishCampaignQuery(String input) {
assertEquals(Intent.CAMPAIGN_QUERY, classifier.classify(input));
}
@ParameterizedTest
@ValueSource(strings = {
"create a new campaign",
"create campaign for summer",
"new campaign",
"setup campaign"
})
@DisplayName("Should classify English campaign creation as CAMPAIGN_CREATE")
void shouldClassifyEnglishCampaignCreate(String input) {
assertEquals(Intent.CAMPAIGN_CREATE, classifier.classify(input));
}
@ParameterizedTest
@ValueSource(strings = {
"find rules for campaign",
"show all rules",
"list rules",
"search rules",
"get rule details"
})
@DisplayName("Should classify English rule queries as RULE_QUERY")
void shouldClassifyEnglishRuleQuery(String input) {
assertEquals(Intent.RULE_QUERY, classifier.classify(input));
}
@ParameterizedTest
@ValueSource(strings = {
"create rule for campaign CMP01",
"create new rule",
"new rule",
"setup rule"
})
@DisplayName("Should classify English rule creation as RULE_CREATE")
void shouldClassifyEnglishRuleCreate(String input) {
assertEquals(Intent.RULE_CREATE, classifier.classify(input));
}
@ParameterizedTest
@ValueSource(strings = {
"find customers",
"search customer",
"show customer details"
})
@DisplayName("Should classify English customer queries as CUSTOMER_QUERY")
void shouldClassifyEnglishCustomerQuery(String input) {
assertEquals(Intent.CUSTOMER_QUERY, classifier.classify(input));
}
@ParameterizedTest
@ValueSource(strings = {
"find transactions",
"show transaction history",
"list transactions"
})
@DisplayName("Should classify English transaction queries as TRANSACTION_QUERY")
void shouldClassifyEnglishTransactionQuery(String input) {
assertEquals(Intent.TRANSACTION_QUERY, classifier.classify(input));
}
@ParameterizedTest
@ValueSource(strings = {
"find pools",
"show pool details",
"search pool"
})
@DisplayName("Should classify English pool queries as POOL_QUERY")
void shouldClassifyEnglishPoolQuery(String input) {
assertEquals(Intent.POOL_QUERY, classifier.classify(input));
}
}

View File

@@ -13,6 +13,7 @@ class AgentToolScopeTest {
assertTrue(AgentToolScope.READ_ONLY.allows("reward_getRuleById"));
assertTrue(AgentToolScope.READ_ONLY.allows("countRules"));
assertTrue(AgentToolScope.READ_ONLY.allows("checkCampaignId"));
assertTrue(AgentToolScope.READ_ONLY.allows("findMatchingRules"));
}
@Test
@@ -33,4 +34,27 @@ class AgentToolScopeTest {
assertTrue(AgentToolScope.CREATE_RULE.allows("createRule"));
assertFalse(AgentToolScope.CREATE_RULE.allows("createCampaign"));
}
@Test
void readOnlyAllowsAllQueryTools() {
// Pool tools
assertTrue(AgentToolScope.READ_ONLY.allows("searchPools"));
assertTrue(AgentToolScope.READ_ONLY.allows("getPoolDefinitionById"));
assertTrue(AgentToolScope.READ_ONLY.allows("getPoolDefinitionsByIds"));
// Customer tools
assertTrue(AgentToolScope.READ_ONLY.allows("searchCustomers"));
// Transaction tools
assertTrue(AgentToolScope.READ_ONLY.allows("searchTransactions"));
// Reference data tools
assertTrue(AgentToolScope.READ_ONLY.allows("searchTransactionCodes"));
assertTrue(AgentToolScope.READ_ONLY.allows("searchCounterDefinitions"));
assertTrue(AgentToolScope.READ_ONLY.allows("searchDeductionSequences"));
assertTrue(AgentToolScope.READ_ONLY.allows("searchCatalogues"));
assertTrue(AgentToolScope.READ_ONLY.allows("searchAppParams"));
assertTrue(AgentToolScope.READ_ONLY.allows("searchDynamicAttributes"));
assertTrue(AgentToolScope.READ_ONLY.allows("searchJourneys"));
}
}

View File

@@ -4,6 +4,7 @@ import dev.sonpx.loyalty.agent.core.config.AgentProperties;
import dev.sonpx.loyalty.agent.exception.UnrecoverableToolExecutionException;
import dev.sonpx.loyalty.agent.service.AgentEventPublisher;
import dev.sonpx.loyalty.agent.service.ToolResultPresentationProcessor;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import io.modelcontextprotocol.client.McpSyncClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -40,6 +41,9 @@ public class ToolInterceptorTest {
@Mock
private ObjectProvider<List<McpSyncClient>> mcpSyncClientsProvider;
@Mock
private ObjectProvider<ToolCallbackProvider> toolProviders;
@Mock
private McpSyncClient mcpSyncClient;
@@ -56,13 +60,15 @@ public class ToolInterceptorTest {
lenient().when(mockToolDefinition.name()).thenReturn("test_tool");
lenient().when(mockTool.getToolDefinition()).thenReturn(mockToolDefinition);
lenient().when(toolCallbackProvider.getToolCallbacks()).thenReturn(new ToolCallback[]{mockTool});
lenient().when(toolProviders.stream()).thenAnswer(inv -> java.util.stream.Stream.of(toolCallbackProvider));
toolInterceptor = new ToolInterceptor(
List.of(toolCallbackProvider),
toolProviders,
eventPublisher,
toolResultProcessor,
agentProperties,
mcpSyncClientsProvider
mcpSyncClientsProvider,
new SimpleMeterRegistry()
);
}
@@ -117,7 +123,7 @@ public class ToolInterceptorTest {
@Test
void testIsConnectionOrSessionErrorDetection() {
assertThat(toolInterceptor.isConnectionOrSessionError(new EOFException("EOF"))).isTrue();
assertThat(toolInterceptor.isConnectionOrSessionError(new RuntimeException("HTTP 404 Not Found"))).isTrue();
assertThat(toolInterceptor.isConnectionOrSessionError(new RuntimeException("HTTP 404 Not Found"))).isFalse();
assertThat(toolInterceptor.isConnectionOrSessionError(new RuntimeException("Session not found"))).isTrue();
assertThat(toolInterceptor.isConnectionOrSessionError(new IllegalArgumentException("Invalid input"))).isFalse();
}

View File

@@ -0,0 +1,51 @@
package dev.sonpx.loyalty.agent.core.workflow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.sonpx.loyalty.agent.core.classifier.Intent;
import org.junit.jupiter.api.Test;
class AgentWorkflowE2ETest {
@Test
void testCampaignCreationWorkflow_Success() {
WorkflowState state = new WorkflowState(Intent.CAMPAIGN_CREATE);
assertEquals(WorkflowState.Phase.BASIC_INFO, state.getCurrentPhase());
state.advancePhase();
assertEquals(WorkflowState.Phase.CONFIRMING, state.getCurrentPhase());
state.complete();
assertEquals(WorkflowState.Phase.COMPLETED, state.getCurrentPhase());
}
@Test
void testCampaignCreationWorkflow_FailureAndRetry() {
WorkflowState state = new WorkflowState(Intent.CAMPAIGN_CREATE);
// Skip to confirmation
state.advancePhase();
assertEquals(WorkflowState.Phase.CONFIRMING, state.getCurrentPhase());
// Mark as executing
state.startExecuting();
// Simulating tool failure
state.failExecution();
// State should remain CONFIRMING so user can provide new data
assertEquals(WorkflowState.Phase.CONFIRMING, state.getCurrentPhase());
assertFalse(state.getCurrentPhase() == WorkflowState.Phase.COMPLETED);
}
@Test
void testCancellation() {
WorkflowState state = new WorkflowState(Intent.CAMPAIGN_CREATE);
state.cancel();
assertEquals(WorkflowState.Phase.CANCELLED, state.getCurrentPhase());
}
}

View File

@@ -0,0 +1,44 @@
package dev.sonpx.loyalty.agent.core.workflow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.sonpx.loyalty.agent.core.classifier.Intent;
import java.util.Map;
import org.junit.jupiter.api.Test;
class WorkflowDataExtractorKeyNormalizationTest {
private final WorkflowDataExtractor extractor = new WorkflowDataExtractor();
@Test
void shortNameKeyNormalizedForCampaign() {
WorkflowState state = new WorkflowState(Intent.CAMPAIGN_CREATE);
Map<String, Object> data = extractor.extract(state, "Tên: Mùa hè vui");
assertTrue(data.containsKey("Tên chiến dịch"), "Expected 'Tên chiến dịch' key but got: " + data.keySet());
assertEquals("Mùa hè vui", data.get("Tên chiến dịch"));
}
@Test
void shortNameKeyNormalizedForRule() {
WorkflowState state = new WorkflowState(Intent.RULE_CREATE);
Map<String, Object> data = extractor.extract(state, "Tên: Birthday Bonus");
assertTrue(data.containsKey("Tên rule"), "Expected 'Tên rule' key but got: " + data.keySet());
assertEquals("Birthday Bonus", data.get("Tên rule"));
}
@Test
void typeKeyNormalizedForCampaign() {
WorkflowState state = new WorkflowState(Intent.CAMPAIGN_CREATE);
Map<String, Object> data = extractor.extract(state, "Loại: Base");
assertTrue(data.containsKey("Loại chiến dịch"), "Expected 'Loại chiến dịch' key but got: " + data.keySet());
}
@Test
void fullKeyPreservedWhenAlreadyCorrect() {
WorkflowState state = new WorkflowState(Intent.CAMPAIGN_CREATE);
Map<String, Object> data = extractor.extract(state, "Tên chiến dịch: Summer Sale");
assertTrue(data.containsKey("Tên chiến dịch"), "Expected 'Tên chiến dịch' key but got: " + data.keySet());
assertEquals("Summer Sale", data.get("Tên chiến dịch"));
}
}

View File

@@ -15,4 +15,53 @@ class WorkflowPromptBuilderTest {
assertFalse(WorkflowPromptBuilder.isCancellation("không giới hạn cap"));
assertFalse(WorkflowPromptBuilder.isCancellation("không cần schedule"));
}
// --- isConfirmation ---
@Test
void confirmation_standaloneOk_isConfirmed() {
assertTrue(WorkflowPromptBuilder.isConfirmation("ok"));
assertTrue(WorkflowPromptBuilder.isConfirmation("OK!"));
assertTrue(WorkflowPromptBuilder.isConfirmation("oke"));
assertTrue(WorkflowPromptBuilder.isConfirmation("được"));
}
@Test
void confirmation_strongKeywords_matchAnywhere() {
assertTrue(WorkflowPromptBuilder.isConfirmation("vâng, xác nhận tạo"));
assertTrue(WorkflowPromptBuilder.isConfirmation("tôi đồng ý"));
assertTrue(WorkflowPromptBuilder.isConfirmation("đúng rồi, tạo đi"));
assertTrue(WorkflowPromptBuilder.isConfirmation("yes"));
assertTrue(WorkflowPromptBuilder.isConfirmation("confirm"));
}
@Test
void confirmation_weakKeywordsInLongerSentence_notConfirmed() {
assertFalse(WorkflowPromptBuilder.isConfirmation("ok tôi hiểu rồi, nhưng chưa muốn tạo"));
assertFalse(WorkflowPromptBuilder.isConfirmation("được rồi, cho tôi xem lại"));
}
// --- isCancellation ---
@Test
void cancellation_startWithKeyword_isCancelled() {
assertTrue(WorkflowPromptBuilder.isCancellation("hủy"));
assertTrue(WorkflowPromptBuilder.isCancellation("cancel"));
assertTrue(WorkflowPromptBuilder.isCancellation("thôi không tạo"));
assertTrue(WorkflowPromptBuilder.isCancellation("stop"));
assertTrue(WorkflowPromptBuilder.isCancellation("dừng lại"));
}
@Test
void cancellation_midSentenceStrongPhrase_isCancelled() {
assertTrue(WorkflowPromptBuilder.isCancellation("tôi muốn hủy tạo"));
assertTrue(WorkflowPromptBuilder.isCancellation("cho tôi hủy workflow"));
assertTrue(WorkflowPromptBuilder.isCancellation("tôi không muốn tạo nữa"));
}
@Test
void cancellation_notCancel_isNotCancelled() {
assertFalse(WorkflowPromptBuilder.isCancellation("không giới hạn cap"));
assertFalse(WorkflowPromptBuilder.isCancellation("không cần schedule"));
assertFalse(WorkflowPromptBuilder.isCancellation("tên rule là Hủy giá"));
}
}

View File

@@ -1,6 +1,23 @@
FROM eclipse-temurin:25-jre-alpine
# Non-root user for security
RUN addgroup -S app && adduser -S app -G app
VOLUME /tmp
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
EXPOSE 8081
ENTRYPOINT ["java","-jar","/app.jar"]
RUN chown app:app /app.jar
USER app
EXPOSE 9331
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=60s \
CMD wget --quiet --tries=1 --spider http://localhost:9331/actuator/health/liveness || exit 1
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"-Djava.security.egd=file:/dev/./urandom", \
"-jar", "/app.jar"]

File diff suppressed because one or more lines are too long

View File

@@ -14,6 +14,8 @@
<name>loyalty-mcp-server</name>
<description>MCP Server for Loyalty API Tools</description>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
@@ -38,6 +40,12 @@
<version>3.4.0</version>
</dependency>
<!-- Actuator for Health Checks -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- WebClient (needed for generated API client if library is webclient) -->
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -106,6 +114,11 @@
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
@@ -113,8 +126,8 @@
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</path>
</annotationProcessorPaths>
</configuration>

View File

@@ -6,10 +6,21 @@ import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestClientResponseException;
/**
* Global exception handler for @McpTool methods using AOP.
* Prevents internal stack traces from leaking to the MCP Client.
*
* Categorizes errors by HTTP status code so the LLM receives
* actionable messages instead of generic "system error":
* - 400 → Validation error (tell user what's wrong)
* - 404 → Entity not found
* - 409 → Duplicate/conflict
* - 401/403 → Auth error
* - 5xx → System error (generic message, details logged)
*/
@Slf4j
@Aspect
@@ -20,10 +31,73 @@ public class GlobalToolExceptionHandlerAspect {
public Object handleException(ProceedingJoinPoint pjp) throws Throwable {
try {
return pjp.proceed();
} catch (RestClientResponseException e) {
// HTTP error from Core API — categorize by status code
return categorizeHttpError(pjp.getSignature().getName(), e);
} catch (Exception e) {
log.error("Global AOP exception caught during execution of MCP tool {}: {}", pjp.getSignature().getName(), e.getMessage());
log.error("Global AOP exception caught during execution of MCP tool {}: {}", pjp.getSignature().getName(), e.getMessage(), e);
// Return a safe Result to prevent stack traces from leaking to the agent
return Result.of(null, "Lỗi hệ thống khi gọi tool tại MCP server. Không thể xử lý yêu cầu. Vui lòng báo cáo lỗi này.");
}
}
private Result<?> categorizeHttpError(String toolName, RestClientResponseException e) {
int status = e.getStatusCode().value();
String responseBody = e.getResponseBodyAsString();
switch (status) {
case 400 -> {
log.warn("Tool {} received HTTP 400 (Bad Request): {}", toolName, truncate(responseBody));
String detail = extractErrorMessage(responseBody);
return Result.failure("Dữ liệu không hợp lệ: " + detail);
}
case 404 -> {
log.warn("Tool {} received HTTP 404 (Not Found): {}", toolName, truncate(responseBody));
return Result.failure("Không tìm thấy đối tượng yêu cầu. Vui lòng kiểm tra lại ID.");
}
case 409 -> {
log.warn("Tool {} received HTTP 409 (Conflict): {}", toolName, truncate(responseBody));
return Result.failure("Đối tượng đã tồn tại hoặc xung đột dữ liệu. Vui lòng kiểm tra lại.");
}
case 401, 403 -> {
log.error("Tool {} received HTTP {} (Auth Error): {}", toolName, status, truncate(responseBody));
return Result.failure("Không có quyền truy cập. Vui lòng liên hệ quản trị viên.");
}
default -> {
if (status >= 500) {
log.error("Tool {} received HTTP {} (Server Error): {}", toolName, status, truncate(responseBody));
return Result.of(null, "Lỗi hệ thống nội bộ từ Core API. Vui lòng thử lại sau.");
}
log.error("Tool {} received unexpected HTTP {}: {}", toolName, status, truncate(responseBody));
return Result.of(null, "Lỗi không xác định từ Core API (HTTP " + status + "). Vui lòng thử lại.");
}
}
}
/**
* Extract a human-readable error message from Core API response body.
* Tries JSON "message" field first, falls back to raw body (truncated).
*/
private String extractErrorMessage(String responseBody) {
if (responseBody == null || responseBody.isBlank()) {
return "Vui lòng kiểm tra lại dữ liệu đầu vào.";
}
// Simple extraction: look for "message" field in JSON response
// Core API typically returns {"errorCode": "...", "message": "..."}
int msgIdx = responseBody.indexOf("\"message\"");
if (msgIdx >= 0) {
int colonIdx = responseBody.indexOf(':', msgIdx);
int quoteStart = responseBody.indexOf('"', colonIdx + 1);
int quoteEnd = responseBody.indexOf('"', quoteStart + 1);
if (quoteStart >= 0 && quoteEnd > quoteStart) {
return responseBody.substring(quoteStart + 1, quoteEnd);
}
}
return truncate(responseBody);
}
private String truncate(String s) {
if (s == null) return "";
return s.length() > 300 ? s.substring(0, 300) + "..." : s;
}
}

View File

@@ -0,0 +1,17 @@
package dev.sonpx.loyalty.mcp.attribute.config;
import dev.sonpx.loyalty.mcp.attribute.service.DynamicAttributeClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
@Configuration
public class DynamicAttributeConfig {
@Bean
public DynamicAttributeClient attributeClient(
@Qualifier("attributeHttpServiceProxyFactory") HttpServiceProxyFactory factory) {
return factory.createClient(DynamicAttributeClient.class);
}
}

View File

@@ -0,0 +1,12 @@
package dev.sonpx.loyalty.mcp.attribute.model;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class DynamicAttributeCriteria {
private String search;
private int page = 1;
private int size = 10;
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright© OneEmpower Pte Ltd. All rights reserved.
*
* This work contains trade secrets and confidential material of
* OneEmpower Pte Ltd, and its unauthorised dissemination, use or
* disclosure in whole or in part without explicit written
* permission of OneEmpower Pte Ltd is strictly prohibited.
*/
package dev.sonpx.loyalty.mcp.attribute.model;
import java.time.LocalDate;
import java.util.List;
import dev.sonpx.loyalty.mcp.model.AttributeDataType;
import dev.sonpx.loyalty.mcp.model.AttributeEntityType;
import dev.sonpx.loyalty.mcp.model.ReferenceData;
import lombok.Getter;
import lombok.Setter;
@Setter
@Getter
public class DynamicAttributeDto {
private AttributeEntityType entity;
private String attributeId;
private String attributeName;
private String description;
private LocalDate effectiveFrom;
private LocalDate effectiveTo;
private AttributeDataType dataType;
private String semanticType;
private String defaultValue;
private Boolean preDefineAttributeValue = false;
List<String> listOfValue;
private List<ReferenceData> attributeGroups;
}

View File

@@ -0,0 +1,21 @@
package dev.sonpx.loyalty.mcp.attribute.service;
import dev.sonpx.loyalty.mcp.model.Result;
import dev.sonpx.loyalty.mcp.attribute.model.DynamicAttributeDto;
import dev.sonpx.loyalty.mcp.attribute.model.DynamicAttributeCriteria;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.bind.annotation.ModelAttribute;
import java.util.List;
@HttpExchange
public interface DynamicAttributeClient {
@GetExchange("/api/dynamic-attribute/csr/list")
Result<List<DynamicAttributeDto>> search(@ModelAttribute DynamicAttributeCriteria criteria);
@GetExchange("/api/dynamic-attribute/csr/id/{id}")
Result<DynamicAttributeDto> getById(@PathVariable("id") String id);
}

View File

@@ -0,0 +1,36 @@
package dev.sonpx.loyalty.mcp.attribute.service;
import dev.sonpx.loyalty.mcp.model.Result;
import dev.sonpx.loyalty.mcp.attribute.model.DynamicAttributeDto;
import dev.sonpx.loyalty.mcp.attribute.model.DynamicAttributeCriteria;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class DynamicAttributeService {
private final DynamicAttributeClient client;
public Result<List<DynamicAttributeDto>> search(DynamicAttributeCriteria criteria) {
try {
return client.search(criteria);
} catch (Exception e) {
log.error("Failed to search DynamicAttribute", e);
return Result.failure(e.getMessage());
}
}
public Result<DynamicAttributeDto> getById(String id) {
try {
return client.getById(id);
} catch (Exception e) {
log.error("Failed to get DynamicAttribute by id", e);
return Result.failure(e.getMessage());
}
}
}

View File

@@ -0,0 +1,19 @@
package dev.sonpx.loyalty.mcp.catalogue.client;
import dev.sonpx.loyalty.mcp.catalogue.criteria.CatalogueCriteria;
import dev.sonpx.loyalty.mcp.catalogue.model.Catalogue;
import java.util.List;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
@HttpExchange
public interface CatalogueClient {
@GetExchange("/api/item/csr/list")
List<Catalogue> getAll(CatalogueCriteria criteria, Pageable pageable);
@GetExchange("/api/item/csr/id/{id}")
Catalogue findActiveById(@PathVariable String id);
}

View File

@@ -0,0 +1,11 @@
package dev.sonpx.loyalty.mcp.catalogue.criteria;
import dev.sonpx.loyalty.mcp.filter.StringFilter;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = false)
public class CatalogueCriteria {
private String search;
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright© OneEmpower Pte Ltd. All rights reserved.
*
* This work contains trade secrets and confidential material of
* OneEmpower Pte Ltd, and its unauthorised dissemination, use or
* disclosure in whole or in part without explicit written
* permission of OneEmpower Pte Ltd is strictly prohibited.
*/
package dev.sonpx.loyalty.mcp.catalogue.model;
import dev.sonpx.loyalty.mcp.model.ReferenceData;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class Catalogue {
private String itemCode;
private String itemName;
private String defaultLanguage;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
private LocalDate effectiveFrom;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
private LocalDate effectiveTo;
private List<ReferenceData> category;
private ReferenceData supplierId;
private ReferenceData redTransactionCode;
private ReferenceData awdTransactionCode;
private String itemType;
private Double itemValue;
private String voucherSetupOption;
private ReferenceData voucherCode;
private ItemVoucherLinkedDto itemVoucherLinked;
private boolean trackQuantityOnHand;
private Integer quantityOnHand;
private String lowStockAlertId;
private String lowStockMessage;
private String lowStockAlertGroup;
/**
* Submodules
*/
private List<ItemDetailLocaleDto> itemDetails;
private List<ItemImageDto> itemImages;
private List<ItemPriceDto> itemPrices;
private List<LimitOnRedemptionDto> limitOnRedemptions;
private List<ItemContentSmsDto> itemContentSms;
private List<ItemContentEmailDto> itemContentEmail;
// -------------------------------------------------------------------
private ReferenceData catalogueCode;
private Double faceValue;
private Integer initQuantityOfStock;
private Integer quantityRedeem;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
private Long numberExchange;
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
private Boolean hasRedeemed;
private ReferenceData storeId;
private String hotItem;
private String featuredItem;
private ReferenceData brandId;
private ReferenceData corporationId;
private List<ItemStoreDto> itemStores;
private List<ItemContentNotificationDto> itemContentNotifications;
private List<String> redemptionModes;
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
private String recordNoActive;
private Map<String, Long> numberItemsByCategories;
private List<ReferenceData> itemNameLanguages;
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright© OneEmpower Pte Ltd. All rights reserved.
*
* This work contains trade secrets and confidential material of
* OneEmpower Pte Ltd, and its unauthorised dissemination, use or
* disclosure in whole or in part without explicit written
* permission of OneEmpower Pte Ltd is strictly prohibited.
*/
package dev.sonpx.loyalty.mcp.catalogue.model;
import dev.sonpx.loyalty.mcp.model.ReferenceData;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ItemContentEmailDto {
@JsonFormat(shape = JsonFormat.Shape.STRING)
private Long itemRecordNo;
private String itemCode;
private String sender;
private ReferenceData messageId;
private String fulfillmentStatus;
private String subject;
private String body;
private String design;
private String metadata;
private String attachments;
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright© OneEmpower Pte Ltd. All rights reserved.
*
* This work contains trade secrets and confidential material of
* OneEmpower Pte Ltd, and its unauthorised dissemination, use or
* disclosure in whole or in part without explicit written
* permission of OneEmpower Pte Ltd is strictly prohibited.
*/
package dev.sonpx.loyalty.mcp.catalogue.model;
import dev.sonpx.loyalty.mcp.model.ReferenceData;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* @author AnhDT
* Created on 2024/10/21
*/
@Getter
@Setter
public class ItemContentNotificationDto {
@JsonFormat(shape = JsonFormat.Shape.STRING)
private Long itemRecordNo;
private String itemCode;
private String fulfillmentStatus;
private ReferenceData messageId;
private String notificationContentId;
private String languageCode;
private String title;
private String shortContent;
private String content;
private ReferenceData navigationType;
private String link;
private ReferenceData targetScreen;
private ReferenceData poolId;
private ReferenceData offerGroup;
private ReferenceData categoryId;
private ReferenceData referenceNo;
private ReferenceData campaignId;
private ReferenceData itemCodeContent;
private ReferenceData notificationType;
private Boolean richMedia;
private Object avatar;
private List<Object> relatedButtons;
}

View File

@@ -0,0 +1,39 @@
/*
* Copyright© OneEmpower Pte Ltd. All rights reserved.
*
* This work contains trade secrets and confidential material of
* OneEmpower Pte Ltd, and its unauthorised dissemination, use or
* disclosure in whole or in part without explicit written
* permission of OneEmpower Pte Ltd is strictly prohibited.
*/
package dev.sonpx.loyalty.mcp.catalogue.model;
import dev.sonpx.loyalty.mcp.model.ReferenceData;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ItemContentSmsDto {
@JsonFormat(shape = JsonFormat.Shape.STRING)
private Long itemRecordNo;
private String itemCode;
private ReferenceData smsBrand;
private ReferenceData messageId;
private String fulfillmentStatus;
private String message;
private Boolean refusedPromotion;
private String messageCode;
private String phone;
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright© OneEmpower Pte Ltd. All rights reserved.
*
* This work contains trade secrets and confidential material of
* OneEmpower Pte Ltd, and its unauthorised dissemination, use or
* disclosure in whole or in part without explicit written
* permission of OneEmpower Pte Ltd is strictly prohibited.
*/
package dev.sonpx.loyalty.mcp.catalogue.model;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ItemDetailLocaleDto {
@JsonFormat(shape = JsonFormat.Shape.STRING)
protected Long itemRecordNo;
private String itemCode;
private String locale;
private String itemName;
private String itemDescription;
private String itemContent;
private String howToUse;
private String termAndCondition;
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright© OneEmpower Pte Ltd. All rights reserved.
*
* This work contains trade secrets and confidential material of
* OneEmpower Pte Ltd, and its unauthorised dissemination, use or
* disclosure in whole or in part without explicit written
* permission of OneEmpower Pte Ltd is strictly prohibited.
*/
package dev.sonpx.loyalty.mcp.catalogue.model;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ItemImageDto {
@JsonFormat(shape = JsonFormat.Shape.STRING)
protected Long itemRecordNo;
private String itemCode;
private Object image;
private Integer displayOrder;
private Boolean isCover;
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright© OneEmpower Pte Ltd. All rights reserved.
*
* This work contains trade secrets and confidential material of
* OneEmpower Pte Ltd, and its unauthorised dissemination, use or
* disclosure in whole or in part without explicit written
* permission of OneEmpower Pte Ltd is strictly prohibited.
*/
package dev.sonpx.loyalty.mcp.catalogue.model;
import dev.sonpx.loyalty.mcp.model.ReferenceData;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ItemPriceDto {
@JsonFormat(shape = JsonFormat.Shape.STRING)
protected Long itemRecordNo;
private String itemCode;
private String priceCode;
private String priceName;
private LocalDate effectiveFrom;
private LocalDate effectiveTo;
private List<String> redemptionChannel;
private ReferenceData poolId;
private Object entityLevel;
private BigDecimal priceInPoint;
private BigDecimal faceValue;
private Boolean partialRedemption;
private String fullItemPriceInCash;
private String minPartialRedeemPoint;
private BigDecimal internalCost;
// ------------------------------------------
private Boolean useOnlyPoint;
private BigDecimal originalPrice;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
private Double discountValue;
private BigDecimal cashValuePerPoint;
private String taxRate;
private BigDecimal cashPerTransaction;
private String pointPerTransaction;
private String criteria;
private ReferenceData campaignId;
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright© OneEmpower Pte Ltd. All rights reserved.
*
* This work contains trade secrets and confidential material of
* OneEmpower Pte Ltd, and its unauthorised dissemination, use or
* disclosure in whole or in part without explicit written
* permission of OneEmpower Pte Ltd is strictly prohibited.
*/
package dev.sonpx.loyalty.mcp.catalogue.model;
import dev.sonpx.loyalty.mcp.model.ReferenceData;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ItemStoreDto {
@JsonFormat(shape = JsonFormat.Shape.STRING)
protected Long itemRecordNo;
private String itemCode;
private ReferenceData storeId;
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright© OneEmpower Pte Ltd. All rights reserved.
*
* This work contains trade secrets and confidential material of
* OneEmpower Pte Ltd, and its unauthorised dissemination, use or
* disclosure in whole or in part without explicit written
* permission of OneEmpower Pte Ltd is strictly prohibited.
*/
package dev.sonpx.loyalty.mcp.catalogue.model;
import dev.sonpx.loyalty.mcp.model.ReferenceData;
import java.time.LocalDate;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ItemVoucherLinkedDto {
private String voucherCode;
private String itemCode;
private ReferenceData supplierId;
private String expiryPolicy;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
private LocalDate expiryDate;
private Integer expiryParams;
private Integer quantity;
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright© OneEmpower Pte Ltd. All rights reserved.
*
* This work contains trade secrets and confidential material of
* OneEmpower Pte Ltd, and its unauthorised dissemination, use or
* disclosure in whole or in part without explicit written
* permission of OneEmpower Pte Ltd is strictly prohibited.
*/
package dev.sonpx.loyalty.mcp.catalogue.model;
import java.math.BigDecimal;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class LimitOnRedemptionDto {
@JsonFormat(shape = JsonFormat.Shape.STRING)
private Long itemRecordNo;
private String itemCode;
private BigDecimal allowNotMoreThan;
private String unit;
private String perEntity;
private String perPeriod;
}

View File

@@ -35,14 +35,97 @@ public class RestClientConfig {
@Bean
public RestClient rewardRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
return createBaseRestClient("/svc/reward", authorizedClientManager);
}
@Bean
public HttpServiceProxyFactory rewardHttpServiceProxyFactory(RestClient rewardRestClient) {
return createHttpServiceProxyFactory(rewardRestClient);
}
@Bean
public RestClient catalogueRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
return createBaseRestClient("/svc/catalogue", authorizedClientManager);
}
@Bean
public HttpServiceProxyFactory catalogueHttpServiceProxyFactory(RestClient catalogueRestClient) {
return createHttpServiceProxyFactory(catalogueRestClient);
}
@Bean
public RestClient customerRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
return createBaseRestClient("/svc/customer", authorizedClientManager);
}
@Bean
public HttpServiceProxyFactory customerHttpServiceProxyFactory(RestClient customerRestClient) {
return createHttpServiceProxyFactory(customerRestClient);
}
@Bean
public RestClient transactionRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
return createBaseRestClient("/svc/transaction", authorizedClientManager);
}
@Bean
public HttpServiceProxyFactory transactionHttpServiceProxyFactory(RestClient transactionRestClient) {
return createHttpServiceProxyFactory(transactionRestClient);
}
@Bean
public RestClient identityRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
return createBaseRestClient("/svc/identity", authorizedClientManager);
}
@Bean
public HttpServiceProxyFactory identityHttpServiceProxyFactory(RestClient identityRestClient) {
return createHttpServiceProxyFactory(identityRestClient);
}
@Bean
public RestClient masterRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
return createBaseRestClient("/svc/master", authorizedClientManager);
}
@Bean
public HttpServiceProxyFactory masterHttpServiceProxyFactory(RestClient masterRestClient) {
return createHttpServiceProxyFactory(masterRestClient);
}
@Bean
public RestClient marketingRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
return createBaseRestClient("/svc/marketing", authorizedClientManager);
}
@Bean
public HttpServiceProxyFactory marketingHttpServiceProxyFactory(RestClient marketingRestClient) {
return createHttpServiceProxyFactory(marketingRestClient);
}
@Bean
public RestClient attributeRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
return createBaseRestClient("/svc/attribute", authorizedClientManager);
}
@Bean
public HttpServiceProxyFactory attributeHttpServiceProxyFactory(RestClient attributeRestClient) {
return createHttpServiceProxyFactory(attributeRestClient);
}
private RestClient createBaseRestClient(String servicePath, OAuth2AuthorizedClientManager authorizedClientManager) {
OAuth2ClientHttpRequestInterceptor oauth2Interceptor =
new OAuth2ClientHttpRequestInterceptor(authorizedClientManager);
oauth2Interceptor.setClientRegistrationIdResolver(new RequestAttributeClientRegistrationIdResolver());
oauth2Interceptor.setPrincipalResolver(new RequestAttributePrincipalResolver());
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(java.time.Duration.ofSeconds(10));
requestFactory.setReadTimeout(java.time.Duration.ofSeconds(30));
return RestClient.builder()
.baseUrl(coreBaseUrl + "/svc/reward")
.requestFactory(new BufferingClientHttpRequestFactory(new SimpleClientHttpRequestFactory()))
.baseUrl(coreBaseUrl + servicePath)
.requestFactory(new BufferingClientHttpRequestFactory(requestFactory))
.requestInterceptor((request, body, execution) -> {
clientRegistrationId("keycloak").accept(request.getAttributes());
principal("loyalty-service").accept(request.getAttributes());
@@ -55,8 +138,9 @@ public class RestClientConfig {
ClientHttpResponse response = oauth2Interceptor.intercept(request, body, execution);
log.info("MCP-Agent Call API [Response Status] <- {}", response.getStatusCode());
if (response.getBody() != null) {
log.info("MCP-Agent Call API [Response Body] <- {}", StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8));
if (log.isDebugEnabled() && response.getBody() != null) {
String body2 = StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8);
log.debug("MCP-Agent Call API [Response Body] <- {}", body2.length() > 2000 ? body2.substring(0, 2000) + "...(truncated)" : body2);
}
return response;
@@ -64,16 +148,19 @@ public class RestClientConfig {
.defaultStatusHandler(
HttpStatusCode::isError,
(request, response) -> {
throw new RuntimeException("API Error: HTTP Status " + response.getStatusCode() + " Body: " + new String(response.getBody().readAllBytes()));
String errorBody = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8);
log.error("Loyalty Core API error: HTTP {} for {} {}: {}", response.getStatusCode(), request.getMethod(), request.getURI(), errorBody.length() > 500 ? errorBody.substring(0, 500) : errorBody);
// Preserve HTTP status code for error categorization in GlobalToolExceptionHandlerAspect
throw org.springframework.web.client.HttpClientErrorException.create(
errorBody, response.getStatusCode(), response.getStatusText(),
response.getHeaders(), errorBody.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8);
}
)
.build();
}
@Bean
public HttpServiceProxyFactory rewardHttpServiceProxyFactory(RestClient rewardRestClient) {
return HttpServiceProxyFactory.builderFor(RestClientAdapter.create(rewardRestClient))
private HttpServiceProxyFactory createHttpServiceProxyFactory(RestClient restClient) {
return HttpServiceProxyFactory.builderFor(RestClientAdapter.create(restClient))
.customArgumentResolver(new PageableArgumentResolver())
.customArgumentResolver(new PojoArgumentResolver())
.build();

View File

@@ -0,0 +1,19 @@
package dev.sonpx.loyalty.mcp.customer.client;
import dev.sonpx.loyalty.mcp.customer.criteria.CustomerCriteria;
import dev.sonpx.loyalty.mcp.customer.model.Customer;
import java.util.List;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
@HttpExchange
public interface CustomerClient {
@GetExchange("/api/client/csr/list")
List<Customer> getAll(CustomerCriteria criteria, Pageable pageable);
@GetExchange("/api/client/csr/id/{id}")
Customer findActiveById(@PathVariable String id);
}

View File

@@ -0,0 +1,11 @@
package dev.sonpx.loyalty.mcp.customer.criteria;
import dev.sonpx.loyalty.mcp.filter.StringFilter;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = false)
public class CustomerCriteria {
private String search;
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright© OneEmpower Pte Ltd. All rights reserved.
*
* This work contains trade secrets and confidential material of
* OneEmpower Pte Ltd, and its unauthorised dissemination, use or
* disclosure in whole or in part without explicit written
* permission of OneEmpower Pte Ltd is strictly prohibited.
*/
package dev.sonpx.loyalty.mcp.customer.model;
import dev.sonpx.loyalty.mcp.model.ReferenceData;
import lombok.Getter;
import lombok.Setter;
/**
* PhuDao created on 16/10/2023
*/
@Getter
@Setter
public class ClientCorrDto {
private Long clientRecordNo;
private String csn;
private String email;
private String mobile;
private String addressLine1;
private String addressLine2;
private String postalCode;
private ReferenceData zipCode;
private ReferenceData city;
private ReferenceData state;
private String deviceId;
private String deviceName;
private Object addressType;
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright© OneEmpower Pte Ltd. All rights reserved.
*
* This work contains trade secrets and confidential material of
* OneEmpower Pte Ltd, and its unauthorised dissemination, use or
* disclosure in whole or in part without explicit written
* permission of OneEmpower Pte Ltd is strictly prohibited.
*/
package dev.sonpx.loyalty.mcp.customer.model;
import dev.sonpx.loyalty.mcp.model.ReferenceData;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
/**
* PhuDao created on 16/10/2023
*/
@Getter
@Setter
public class Customer {
private String csn;
private String cifNbr;
private HolderType holderType;
private String holderId;
private String clientName;
private String firstName;
private String lastName;
private LocalDate birthDate;
private LocalDate registrationDate;
private Gender gender;
private ReferenceData customerStatus;
private ReferenceData countryOrigin;
private String blockCode;
private MaritalStatus maritalStatus;
private BigDecimal income;
private String profilePicture;
private String profilePictureUrl;
private String profileLanguage;
private String batchNo;
private Instant maintenanceDate;
// For primary contact
private String mobile;
private String email;
private String address;
private String address2;
private ReferenceData country;
private ReferenceData zipCode;
private ReferenceData city;
private ReferenceData state;
private LocalDateTime firstLoginDate;
private LocalDateTime lastLoginDate;
private List<ClientCorrDto> clientCorrs;
public String getClientName() {
return this.firstName + " " + this.lastName;
}
public void setClientName(String clientName) {
this.clientName = this.firstName + " " + this.lastName;
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright© OneEmpower Pte Ltd. All rights reserved.
*
* This work contains trade secrets and confidential material of
* OneEmpower Pte Ltd, and its unauthorised dissemination, use or
* disclosure in whole or in part without explicit written
* permission of OneEmpower Pte Ltd is strictly prohibited.
*/
package dev.sonpx.loyalty.mcp.customer.model;
import java.util.Arrays;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* enum for rule criteria
* Created by Phudao on Tuesday, 12-Dec-2023
*/
public enum Gender {
MALE("M"),
FEMALE("F"),
OTHER("O");
@JsonValue
private final String code;
@JsonCreator
public static Gender fromCode(String code) {
return Arrays.stream(values())
.filter(e -> e.code.equalsIgnoreCase(code))
.findFirst()
.orElse(null);
}
Gender(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright© OneEmpower Pte Ltd. All rights reserved.
*
* This work contains trade secrets and confidential material of
* OneEmpower Pte Ltd, and its unauthorised dissemination, use or
* disclosure in whole or in part without explicit written
* permission of OneEmpower Pte Ltd is strictly prohibited.
*/
package dev.sonpx.loyalty.mcp.customer.model;
import java.util.Arrays;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* enum for rule criteria
* Created by Phudao on Tuesday, 12-Dec-2023
*/
public enum HolderType {
PASSPORT("P"),
ID_CARD("I");
@JsonValue
private final String code;
@JsonCreator
public static HolderType fromCode(String code) {
return Arrays.stream(values())
.filter(e -> e.code.equalsIgnoreCase(code))
.findFirst()
.orElse(null);
}
HolderType(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright© OneEmpower Pte Ltd. All rights reserved.
*
* This work contains trade secrets and confidential material of
* OneEmpower Pte Ltd, and its unauthorised dissemination, use or
* disclosure in whole or in part without explicit written
* permission of OneEmpower Pte Ltd is strictly prohibited.
*/
package dev.sonpx.loyalty.mcp.customer.model;
import java.util.Arrays;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* enum for rule criteria
* Created by Phudao on Tuesday, 12-Dec-2023
*/
public enum MaritalStatus {
SINGLE("S"),
MARRIED("M"),
OTHER("O");
@JsonValue
private final String code;
@JsonCreator
public static MaritalStatus fromCode(String code) {
return Arrays.stream(values())
.filter(e -> e.code.equalsIgnoreCase(code))
.findFirst()
.orElse(null);
}
MaritalStatus(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -2,11 +2,7 @@
package dev.sonpx.loyalty.mcp.enums;
import java.util.Arrays;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum AttributeDataType {
STRING("STRING"),
NUMBER("NUMBER"),
@@ -22,4 +18,12 @@ public enum AttributeDataType {
.findFirst()
.orElse(null);
}
AttributeDataType(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.enums;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import java.util.Arrays;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* Created by SonPhung on Monday, 20-Jul-2026
*/
@Getter
@RequiredArgsConstructor
public enum AttributeEntityType {
CLIENT("CLIENT"),
PRODUCT_ACCOUNT("PRODUCT_ACCOUNT"),
@@ -33,4 +29,12 @@ public enum AttributeEntityType {
.findFirst()
.orElse(null);
}
AttributeEntityType(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -4,11 +4,7 @@ package dev.sonpx.loyalty.mcp.enums;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import java.util.Arrays;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum AttributeType {
CUSTOM("CUSTOM"),
DYNAMIC("DYNAMIC"),
@@ -24,4 +20,12 @@ public enum AttributeType {
.findFirst()
.orElse(null);
}
AttributeType(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -4,11 +4,7 @@ package dev.sonpx.loyalty.mcp.enums;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import java.util.Arrays;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum CounterBucket {
CURRENT_BUCKET("CB"),
PREVIOUS_BUCKET("PB"),
@@ -24,4 +20,12 @@ public enum CounterBucket {
.findFirst()
.orElse(null);
}
CounterBucket(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -4,11 +4,7 @@ package dev.sonpx.loyalty.mcp.enums;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import java.util.Arrays;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum CounterEntity {
CUSTOMER("CU"),
ACCOUNT("AC"),
@@ -26,4 +22,12 @@ public enum CounterEntity {
.findFirst()
.orElse(null);
}
CounterEntity(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.enums;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import java.util.Arrays;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* Created by SonPhung on Wednesday, 20-Dec-2023
*/
@Getter
@RequiredArgsConstructor
public enum CounterPeriod {
AOD_ANNIVERSARY("AA"),
DAY_COUNTER("DC"),
@@ -39,4 +35,12 @@ public enum CounterPeriod {
.orElse(null);
}
CounterPeriod(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -2,6 +2,7 @@
package dev.sonpx.loyalty.mcp.enums;
import java.util.Arrays;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@@ -41,4 +42,5 @@ public enum DerivedType {
public static boolean isAudienceType(String code) {
return DerivedType.AUDIENCE.code.equalsIgnoreCase(code) || DerivedType.SEGMENT.code.equalsIgnoreCase(code);
}
}

View File

@@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.enums;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import java.util.Arrays;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* Created by SonPhung on Wednesday, 20-Dec-2023
*/
@Getter
@RequiredArgsConstructor
public enum EntityLevel {
CUSTOMER("CU"),
ACCOUNT("AC"),
@@ -28,4 +24,12 @@ public enum EntityLevel {
.findFirst()
.orElse(null);
}
EntityLevel(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -4,11 +4,7 @@ package dev.sonpx.loyalty.mcp.enums;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import java.util.Arrays;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum ExpiryPolicy {
FIXED_DATE("FD"),
NO_EXPIRY("NE"),
@@ -29,4 +25,12 @@ public enum ExpiryPolicy {
.findFirst()
.orElse(null);
}
ExpiryPolicy(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -4,11 +4,7 @@ package dev.sonpx.loyalty.mcp.enums;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import java.util.Arrays;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum FileStoreType {
S3("s3"), // For S3 or S3-compatible storage
FILE("file"); // For local file system storage
@@ -23,4 +19,12 @@ public enum FileStoreType {
.findFirst()
.orElse(null);
}
FileStoreType(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -4,11 +4,7 @@ package dev.sonpx.loyalty.mcp.enums;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import java.util.Arrays;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Getter
public enum ImageSourceType {
UPLOAD("UPLOAD"),
@@ -24,4 +20,12 @@ public enum ImageSourceType {
.findFirst()
.orElse(null);
}
ImageSourceType(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -3,11 +3,7 @@ package dev.sonpx.loyalty.mcp.enums;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum RecordStatus implements EnumBase {
/** Record is active and eligible for processing. DB code: {@code "A"}. */
@@ -30,4 +26,12 @@ public enum RecordStatus implements EnumBase {
}
return null;
}
RecordStatus(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -5,8 +5,6 @@ import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import java.util.Arrays;
import java.util.List;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* Created by SonPhung on Monday, 20-Jul-2026
@@ -15,8 +13,6 @@ import lombok.RequiredArgsConstructor;
* {@link #LAST_3_MONTHS},
* {@link #LAST_6_MONTHS}
*/
@Getter
@RequiredArgsConstructor
public enum StatisticPeriod {
TODAY("TODAY"),
LAST_7_DAYS("L7D"),
@@ -38,4 +34,12 @@ public enum StatisticPeriod {
public boolean isAny(StatisticPeriod... periods) {
return List.of(periods).contains(this);
}
StatisticPeriod(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -5,11 +5,7 @@ import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import dev.sonpx.loyalty.mcp.enums.EnumBase;
import java.util.Arrays;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum DayOfMonth implements EnumBase {
DAY01("1"),
DAY02("2"),
@@ -53,4 +49,12 @@ public enum DayOfMonth implements EnumBase {
.findFirst()
.orElse(null);
}
DayOfMonth(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -5,11 +5,7 @@ import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import dev.sonpx.loyalty.mcp.enums.EnumBase;
import java.util.Arrays;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum DayOfWeek implements EnumBase {
MONDAY("1"),
TUESDAY("2"),
@@ -29,4 +25,12 @@ public enum DayOfWeek implements EnumBase {
.findFirst()
.orElse(null);
}
DayOfWeek(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -5,11 +5,7 @@ import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import dev.sonpx.loyalty.mcp.enums.EnumBase;
import java.util.Arrays;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum MonthOfYear implements EnumBase {
JANUARY("1"),
FEBRUARY("2"),
@@ -34,4 +30,12 @@ public enum MonthOfYear implements EnumBase {
.findFirst()
.orElse(null);
}
MonthOfYear(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -5,11 +5,7 @@ import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import dev.sonpx.loyalty.mcp.enums.EnumBase;
import java.util.Arrays;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum PeriodicType implements EnumBase {
DAY("D"),
WEEK("W"),
@@ -26,4 +22,12 @@ public enum PeriodicType implements EnumBase {
.findFirst()
.orElse(null);
}
PeriodicType(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -5,11 +5,7 @@ import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import dev.sonpx.loyalty.mcp.enums.EnumBase;
import java.util.Arrays;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum ScheduleMonthType implements EnumBase {
DAY_OF_MONTH("DOM"),
DAY_OF_WEEK("DOW");
@@ -24,4 +20,12 @@ public enum ScheduleMonthType implements EnumBase {
.findFirst()
.orElse(null);
}
ScheduleMonthType(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -5,11 +5,7 @@ import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import dev.sonpx.loyalty.mcp.enums.EnumBase;
import java.util.Arrays;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum ScheduleRecurrenceType implements EnumBase {
ONE_TIME("O"),
RECURRING("R");
@@ -24,4 +20,12 @@ public enum ScheduleRecurrenceType implements EnumBase {
.findFirst()
.orElse(null);
}
ScheduleRecurrenceType(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -5,11 +5,7 @@ import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import dev.sonpx.loyalty.mcp.enums.EnumBase;
import java.util.Arrays;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum TriggerMethod implements EnumBase {
SCHEDULE("S"),
EVENT("E");
@@ -24,4 +20,12 @@ public enum TriggerMethod implements EnumBase {
.findFirst()
.orElse(null);
}
TriggerMethod(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -5,11 +5,7 @@ import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import dev.sonpx.loyalty.mcp.enums.EnumBase;
import java.util.Arrays;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum WeekOfMonth implements EnumBase {
FIRST("1"),
SECOND("2"),
@@ -28,4 +24,12 @@ public enum WeekOfMonth implements EnumBase {
.findFirst()
.orElse(null);
}
WeekOfMonth(String code) {
this.code = code;
}
public String getCode() {
return this.code;
}
}

View File

@@ -16,7 +16,7 @@ public class GlobalMcpExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleException(Exception ex) {
log.error("Global exception caught in MCP server: {}", ex.getMessage());
log.error("Global exception caught in MCP server: {}", ex.getMessage(), ex);
// We return a safe error message instead of the HTML stack trace.
// Returning a generic 500 error prevents the LLM from trying to explain server internals.
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)

View File

@@ -0,0 +1,30 @@
package dev.sonpx.loyalty.mcp.health;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.health.contributor.Health;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
@Component
public class CoreApiHealthIndicator implements HealthIndicator {
private final RestClient restClient;
public CoreApiHealthIndicator(@Value("${loyalty.core.base-url:http://localhost:8081}") String baseUrl) {
this.restClient = RestClient.builder().baseUrl(baseUrl).build();
}
@Override
public Health health() {
try {
restClient.get()
.uri("/actuator/health")
.retrieve()
.toBodilessEntity();
return Health.up().build();
} catch (Exception e) {
return Health.down().withException(e).build();
}
}
}

View File

@@ -0,0 +1,17 @@
package dev.sonpx.loyalty.mcp.identity.config;
import dev.sonpx.loyalty.mcp.identity.service.UserClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
@Configuration
public class UserConfig {
@Bean
public UserClient identityClient(
@Qualifier("identityHttpServiceProxyFactory") HttpServiceProxyFactory factory) {
return factory.createClient(UserClient.class);
}
}

View File

@@ -0,0 +1,12 @@
package dev.sonpx.loyalty.mcp.identity.model;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class UserCriteria {
private String search;
private int page = 1;
private int size = 10;
}

View File

@@ -0,0 +1,64 @@
/*
* Copyright© OneEmpower Pte Ltd. All rights reserved.
*
* This work contains trade secrets and confidential material of
* OneEmpower Pte Ltd, and its unauthorised dissemination, use or
* disclosure in whole or in part without explicit written
* permission of OneEmpower Pte Ltd is strictly prohibited.
*/
package dev.sonpx.loyalty.mcp.identity.model;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat;
import dev.sonpx.loyalty.mcp.model.ReferenceData;
import dev.sonpx.loyalty.mcp.model.LanguageCode;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class UserResponseDto {
private String userId;
private String profileName;
private String email;
private LocalDate profileStartDate;
private LocalDate profileEndDate;
private String userGroup;
private List<ReferenceData> userRoles;
private String reportAccessRight;
private String accessDays;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm")
private LocalTime accessTimeStart;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm")
private LocalTime accessTimeEnd;
private LanguageCode languageCode;
private String userStatus;
private LocalDateTime createdDate;
/** Merchant Access Level */
private ReferenceData corporation;
private ReferenceData chain;
private ReferenceData store;
}

View File

@@ -0,0 +1,21 @@
package dev.sonpx.loyalty.mcp.identity.service;
import dev.sonpx.loyalty.mcp.model.Result;
import dev.sonpx.loyalty.mcp.identity.model.UserResponseDto;
import dev.sonpx.loyalty.mcp.identity.model.UserCriteria;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.bind.annotation.ModelAttribute;
import java.util.List;
@HttpExchange
public interface UserClient {
@GetExchange("/api/user/csr/list")
Result<List<UserResponseDto>> search(@ModelAttribute UserCriteria criteria);
@GetExchange("/api/user/csr/id/{id}")
Result<UserResponseDto> getById(@PathVariable("id") String id);
}

View File

@@ -0,0 +1,36 @@
package dev.sonpx.loyalty.mcp.identity.service;
import dev.sonpx.loyalty.mcp.model.Result;
import dev.sonpx.loyalty.mcp.identity.model.UserResponseDto;
import dev.sonpx.loyalty.mcp.identity.model.UserCriteria;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {
private final UserClient client;
public Result<List<UserResponseDto>> search(UserCriteria criteria) {
try {
return client.search(criteria);
} catch (Exception e) {
log.error("Failed to search User", e);
return Result.failure(e.getMessage());
}
}
public Result<UserResponseDto> getById(String id) {
try {
return client.getById(id);
} catch (Exception e) {
log.error("Failed to get User by id", e);
return Result.failure(e.getMessage());
}
}
}

View File

@@ -0,0 +1,47 @@
package dev.sonpx.loyalty.mcp.mapper;
import dev.sonpx.loyalty.mcp.reward.model.*;
import dev.sonpx.loyalty.mcp.tool.request.*;
import dev.sonpx.loyalty.mcp.tool.response.*;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import java.util.List;
@Mapper(componentModel = "spring")
public interface McpDtoMapper {
default ReferenceData stringToReferenceData(String code) {
if (code == null) {
return null;
}
return new ReferenceData(code);
}
default String referenceDataToString(ReferenceData refData) {
if (refData == null) {
return null;
}
return refData.getCode();
}
Campaign toEntity(CreateCampaignRequest request);
CampaignResponse toCampaignResponse(Campaign entity);
@Mapping(target = "campaignFormulaTwo.fixedAmt", source = "rewardAmount")
CampaignRule toEntity(CreateCampaignRuleRequest request);
@Mapping(target = "rewardAmount", source = "campaignFormulaTwo.fixedAmt")
CampaignRuleResponse toCampaignRuleResponse(CampaignRule entity);
PoolDefinitionResponse toPoolDefinitionResponse(PoolDefinition entity);
TransactionCodeResponse toDto(TransactionCode entity);
List<TransactionCodeResponse> toTransactionCodeDtoList(List<TransactionCode> entities);
CounterDefinitionResponse toDto(CounterDefinition entity);
List<CounterDefinitionResponse> toCounterDefinitionDtoList(List<CounterDefinition> entities);
DeductionSequenceResponse toDto(DeductionSequences entity);
List<DeductionSequenceResponse> toDeductionSequenceDtoList(List<DeductionSequences> entities);
}

View File

@@ -0,0 +1,17 @@
package dev.sonpx.loyalty.mcp.marketing.config;
import dev.sonpx.loyalty.mcp.marketing.service.JourneyClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
@Configuration
public class JourneyConfig {
@Bean
public JourneyClient marketingClient(
@Qualifier("marketingHttpServiceProxyFactory") HttpServiceProxyFactory factory) {
return factory.createClient(JourneyClient.class);
}
}

View File

@@ -0,0 +1,12 @@
package dev.sonpx.loyalty.mcp.marketing.model;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class JourneyCriteria {
private String search;
private int page = 1;
private int size = 10;
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright© OneEmpower Pte Ltd. All rights reserved.
*
* This work contains trade secrets and confidential material of
* OneEmpower Pte Ltd, and its unauthorised dissemination, use or
* disclosure in whole or in part without explicit written
* permission of OneEmpower Pte Ltd is strictly prohibited.
*/
package dev.sonpx.loyalty.mcp.marketing.model;
import java.time.LocalDateTime;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Getter;
import lombok.Setter;
/**
* @author AnhDT
* Created on 2025/12/23
*/
@Getter
@Setter
public class JourneyDto {
@JsonFormat(shape = JsonFormat.Shape.STRING)
private String journeyId;
private String name;
private LocalDateTime effectiveFrom;
private LocalDateTime effectiveTo;
private String businessUnit;
private String campaignId;
private String description;
private LocalDateTime createdAt;
// Submodules
private Object journeySetting;
private Object journeyCanvas; // contains nodes and edges
private Object journeySrcAudience;
private List<Object> journeySrcEvents;
private List<Object> journeyActSms;
private List<Object> journeyActEmails;
private List<Object> journeyActPushNotifications;
private List<Object> journeyActInappMessages;
private List<Object> journeyActVouchers;
private List<Object> journeyFlowDurations;
private List<Object> journeyFlowDates;
private List<Object> journeyFlowInteractions;
private List<Object> journeyFlowDecisions;
private List<Object> journeyFlowRandoms;
private List<Object> journeyFlowJoins;
}

View File

@@ -0,0 +1,21 @@
package dev.sonpx.loyalty.mcp.marketing.service;
import dev.sonpx.loyalty.mcp.model.Result;
import dev.sonpx.loyalty.mcp.marketing.model.JourneyDto;
import dev.sonpx.loyalty.mcp.marketing.model.JourneyCriteria;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.bind.annotation.ModelAttribute;
import java.util.List;
@HttpExchange
public interface JourneyClient {
@GetExchange("/api/journey/csr/list")
Result<List<JourneyDto>> search(@ModelAttribute JourneyCriteria criteria);
@GetExchange("/api/journey/csr/id/{id}")
Result<JourneyDto> getById(@PathVariable("id") String id);
}

View File

@@ -0,0 +1,36 @@
package dev.sonpx.loyalty.mcp.marketing.service;
import dev.sonpx.loyalty.mcp.model.Result;
import dev.sonpx.loyalty.mcp.marketing.model.JourneyDto;
import dev.sonpx.loyalty.mcp.marketing.model.JourneyCriteria;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class JourneyService {
private final JourneyClient client;
public Result<List<JourneyDto>> search(JourneyCriteria criteria) {
try {
return client.search(criteria);
} catch (Exception e) {
log.error("Failed to search Journey", e);
return Result.failure(e.getMessage());
}
}
public Result<JourneyDto> getById(String id) {
try {
return client.getById(id);
} catch (Exception e) {
log.error("Failed to get Journey by id", e);
return Result.failure(e.getMessage());
}
}
}

View File

@@ -0,0 +1,17 @@
package dev.sonpx.loyalty.mcp.master.config;
import dev.sonpx.loyalty.mcp.master.service.AppParamClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
@Configuration
public class AppParamConfig {
@Bean
public AppParamClient masterClient(
@Qualifier("masterHttpServiceProxyFactory") HttpServiceProxyFactory factory) {
return factory.createClient(AppParamClient.class);
}
}

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