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:
44
.env.example
44
.env.example
@@ -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
170
ARCHITECTURE.md
Normal 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
165
DEPLOYMENT.md
Normal 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
143
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
22
context.md
22
context.md
@@ -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`
|
||||
|
||||
@@ -90,10 +94,10 @@ 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` |
|
||||
| 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.88:8081` | `LOYALTY_CORE_BASE_URL` |
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -12,6 +12,8 @@ public class AppConfig {
|
||||
|
||||
@Bean
|
||||
public ObjectMapper objectMapper() {
|
||||
return new ObjectMapper();
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
mapper.findAndRegisterModules();
|
||||
return mapper;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -32,13 +32,54 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
return userInput.trim().length() >= 20 || data.size() >= 3;
|
||||
}
|
||||
|
||||
private void extractKeyValues(String userInput, Map<String, Object> extracted) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +291,24 @@ public class WorkflowExecutor {
|
||||
builder.stream()
|
||||
.content()
|
||||
.doOnComplete(() -> {
|
||||
persistenceService.saveAssistantMessage(conversationKey, responseAccumulator.toString());
|
||||
String fullResponse = responseAccumulator.toString();
|
||||
persistenceService.saveAssistantMessage(conversationKey, fullResponse);
|
||||
|
||||
if (onComplete != null) {
|
||||
onComplete.run();
|
||||
// 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);
|
||||
})
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).*");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
24
loyalty-agent/src/main/resources/application-prod.yml
Normal file
24
loyalty-agent/src/main/resources/application-prod.yml
Normal 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
|
||||
@@ -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}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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á"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
15
loyalty-mcp-server/lombok_debug.txt
Normal file
15
loyalty-mcp-server/lombok_debug.txt
Normal file
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user