diff --git a/.env.example b/.env.example index 1cad230..41deb1e 100644 --- a/.env.example +++ b/.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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..b858b1b --- /dev/null +++ b/ARCHITECTURE.md @@ -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` 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` — 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()` | diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..77bdac6 --- /dev/null +++ b/DEPLOYMENT.md @@ -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 diff --git a/README.md b/README.md index 479b9ec..13d6ae1 100644 --- a/README.md +++ b/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 + diff --git a/context-agent.md b/context-agent.md index e172947..f932576 100644 --- a/context-agent.md +++ b/context-agent.md @@ -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`) --- diff --git a/context-mcp-server.md b/context-mcp-server.md index 8d0f7de..03b43d6 100644 --- a/context-mcp-server.md +++ b/context-mcp-server.md @@ -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`: -- `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. diff --git a/context.md b/context.md index b80def8..edcaa87 100644 --- a/context.md +++ b/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` | diff --git a/loyalty-agent/Dockerfile b/loyalty-agent/Dockerfile index 7ef433a..db1a51c 100644 --- a/loyalty-agent/Dockerfile +++ b/loyalty-agent/Dockerfile @@ -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"] diff --git a/loyalty-agent/pom.xml b/loyalty-agent/pom.xml index cfaee9e..401c2fd 100644 --- a/loyalty-agent/pom.xml +++ b/loyalty-agent/pom.xml @@ -65,6 +65,12 @@ spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-actuator + + org.postgresql diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/AppConfig.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/AppConfig.java index d7b5162..c66a240 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/AppConfig.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/AppConfig.java @@ -12,6 +12,8 @@ public class AppConfig { @Bean public ObjectMapper objectMapper() { - return new ObjectMapper(); + ObjectMapper mapper = new ObjectMapper(); + mapper.findAndRegisterModules(); + return mapper; } } diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/MdcChannelInterceptor.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/MdcChannelInterceptor.java new file mode 100644 index 0000000..2aadb04 --- /dev/null +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/MdcChannelInterceptor.java @@ -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(); + } +} diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/SecurityConfig.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/SecurityConfig.java index 2be2be9..e008f54 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/SecurityConfig.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/SecurityConfig.java @@ -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; + } } diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/WebSocketConfig.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/WebSocketConfig.java index 3d9cb99..e710294 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/WebSocketConfig.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/WebSocketConfig.java @@ -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"); diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/controller/AgentController.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/controller/AgentController.java index 2a4a632..83afa61 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/controller/AgentController.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/controller/AgentController.java @@ -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()); } diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/controller/ConversationController.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/controller/ConversationController.java index 6cefdcb..47164f9 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/controller/ConversationController.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/controller/ConversationController.java @@ -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> getConversations() { List conversations = conversationRepository.findAllByOrderByUpdatedAtDesc(); List dtos = new ArrayList<>(); @@ -39,9 +34,8 @@ public class ConversationController { for (Conversation c : conversations) { List 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(); } } - - diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/classifier/Intent.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/classifier/Intent.java index 47087f8..76b588c 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/classifier/Intent.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/classifier/Intent.java @@ -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, diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/classifier/IntentClassifier.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/classifier/IntentClassifier.java index 05b2a7f..14d0efc 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/classifier/IntentClassifier.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/classifier/IntentClassifier.java @@ -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) diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/config/AgentProperties.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/config/AgentProperties.java index 44e1deb..33c3cf4 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/config/AgentProperties.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/config/AgentProperties.java @@ -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(); diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/AgentToolScope.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/AgentToolScope.java index 066ae54..bf762bf 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/AgentToolScope.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/AgentToolScope.java @@ -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; diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/SimpleAgentExecutor.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/SimpleAgentExecutor.java index 25bcd5f..6181f30 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/SimpleAgentExecutor.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/SimpleAgentExecutor.java @@ -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; + } } diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptor.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptor.java index b71a7c7..bc545a6 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptor.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptor.java @@ -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> mcpSyncClientsProvider; + private final MeterRegistry meterRegistry; public List 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"); } diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/AgentOrchestrator.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/AgentOrchestrator.java index b91d04e..916fd8b 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/AgentOrchestrator.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/AgentOrchestrator.java @@ -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); } diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/PromptBuilder.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/PromptBuilder.java index 551e98b..5c12a27 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/PromptBuilder.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/PromptBuilder.java @@ -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); - } } diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowDataExtractor.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowDataExtractor.java index 0de7d29..92509ce 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowDataExtractor.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowDataExtractor.java @@ -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 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 extracted) { + private void extractKeyValues(String userInput, Map 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; + } } diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowExecutor.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowExecutor.java index b1acb39..3552e90 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowExecutor.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowExecutor.java @@ -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"); + } } diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowPromptBuilder.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowPromptBuilder.java index 7b734b3..f66143d 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowPromptBuilder.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowPromptBuilder.java @@ -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 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).*"); } /** diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/health/McpServerHealthIndicator.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/health/McpServerHealthIndicator.java new file mode 100644 index 0000000..8371dd3 --- /dev/null +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/health/McpServerHealthIndicator.java @@ -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> mcpClientsProvider; + + public McpServerHealthIndicator(ObjectProvider> mcpClientsProvider) { + this.mcpClientsProvider = mcpClientsProvider; + } + + @Override + public Health health() { + List 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(); + } + } +} diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/health/OllamaHealthIndicator.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/health/OllamaHealthIndicator.java new file mode 100644 index 0000000..36f9a75 --- /dev/null +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/health/OllamaHealthIndicator.java @@ -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(); + } + } +} diff --git a/loyalty-agent/src/main/resources/application-prod.yml b/loyalty-agent/src/main/resources/application-prod.yml new file mode 100644 index 0000000..c9b7a11 --- /dev/null +++ b/loyalty-agent/src/main/resources/application-prod.yml @@ -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 diff --git a/loyalty-agent/src/main/resources/application.yml b/loyalty-agent/src/main/resources/application.yml index 4a2fd37..fca4636 100644 --- a/loyalty-agent/src/main/resources/application.yml +++ b/loyalty-agent/src/main/resources/application.yml @@ -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} diff --git a/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/classifier/IntentClassifierTest.java b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/classifier/IntentClassifierTest.java index 4bebaa9..4ed79cf 100644 --- a/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/classifier/IntentClassifierTest.java +++ b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/classifier/IntentClassifierTest.java @@ -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)); + } } diff --git a/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/executor/AgentToolScopeTest.java b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/executor/AgentToolScopeTest.java index 47c1bed..6aa8dc9 100644 --- a/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/executor/AgentToolScopeTest.java +++ b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/executor/AgentToolScopeTest.java @@ -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")); + } } diff --git a/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptorTest.java b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptorTest.java index 2ebbc87..cc8ceef 100644 --- a/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptorTest.java +++ b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptorTest.java @@ -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> mcpSyncClientsProvider; + @Mock + private ObjectProvider 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(); } diff --git a/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/workflow/AgentWorkflowE2ETest.java b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/workflow/AgentWorkflowE2ETest.java new file mode 100644 index 0000000..7f5e1ed --- /dev/null +++ b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/workflow/AgentWorkflowE2ETest.java @@ -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()); + } +} diff --git a/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowDataExtractorKeyNormalizationTest.java b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowDataExtractorKeyNormalizationTest.java new file mode 100644 index 0000000..778e6d3 --- /dev/null +++ b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowDataExtractorKeyNormalizationTest.java @@ -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 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 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 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 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")); + } +} diff --git a/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowPromptBuilderTest.java b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowPromptBuilderTest.java index fa3784a..4ab7f56 100644 --- a/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowPromptBuilderTest.java +++ b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowPromptBuilderTest.java @@ -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á")); + } } diff --git a/loyalty-mcp-server/Dockerfile b/loyalty-mcp-server/Dockerfile index 1c5293a..8449031 100644 --- a/loyalty-mcp-server/Dockerfile +++ b/loyalty-mcp-server/Dockerfile @@ -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"] diff --git a/loyalty-mcp-server/lombok_debug.txt b/loyalty-mcp-server/lombok_debug.txt new file mode 100644 index 0000000..e260aaa --- /dev/null +++ b/loyalty-mcp-server/lombok_debug.txt @@ -0,0 +1,15 @@ + org.projectlombok + lombok + org.projectlombok + lombok-mapstruct-binding +[DEBUG] org.projectlombok:lombok:jar:1.18.38:compile (optional) +[DEBUG] env.ANTIGRAVITY_SOURCE_METADATA: {"tool":{"conversationId":"425d7966-f556-4930-b6ed-14ba471b3a57","stepIndex":201,"toolCall":{"id":"svjmpv88","name":"run_command","argumentsJson":"{\"CommandLine\":\"mvn clean compile -X | grep -i lombok \\u003e lombok_debug.txt \\u0026\\u0026 cat lombok_debug.txt\",\"Cwd\":\"/Users/sonphung/git/loyalty-agent-service/loyalty-mcp-server\",\"WaitMsBeforeAsync\":5000,\"toolAction\":\"Debugging lombok execution\",\"toolSummary\":\"Debug lombok\"}","thinkingSignature":"Et8uCtwuARFNMg/pztxfYfHtPbmV5NHHP4z0Fcq98gPn3ZH/hrNr2CIQLnih+uAIUu7LIQa3nfp+mWpei6pqbl3nK5X4wJ2KOOXnSfhphiWDbTg8izJTqMYdA2BmNtj2VbDGgCpNZZjDkjZ/lg9P7Y5G3w0Jx/rfE3Q6z1iPf9FV41NyE5cEZKWy7mYiPMZM2LKMJ/FiMoyvmQ/tCkGVLHR97EUXf0ayVJVHFg8ywpdekLTKZboJW3Whdpu8UNUBeC1F6PVrYgeVpEZfhZ1K91KrG2aYi9qqEcPCbg98GmT7WKXQF6mkJcXJkuL8KkS3w0yeHFEcLyuELHbN8j8RnybHwBVZgLEvd+LBFQUPYpvxfLGuBK+nrWg3RyR0qMcaKnNilpjiLdX3xRBi55RoKes5eGRmQD2iA2OMCKqUL+CFC3fVKWlV5k5Fr6nSBcCbZT8i5V7kVg/0axpUdbT2vR65qy+B8hDPcWrAelrhxmWRSPftK8NepLY6+o/GQhBdGKlraUZt+Cw0NQYtQHYmJv18OpIF3O5LYXO0O6irBcOBdEfJpcZkg2bDO9tl9PS775dgSOUDqsQqBCXtKBlIh85Af74hhHKubO0BPbr+ku04fRPkOFDcKAHmMDzD0/0P/feAsFT+AqGRK03Xpi8zzXvTMYOczRJtSTtDF4TG6l2du3NeJBIcro1fwG+7OJzDppmOYFZ9RcFTuQzj9+IqObpiz579VAMV0KP7KlgG8gmVIm7Jf6JuYfor8wLmQbazt+r2yQECMU5ZceWuYvOs8lOg21YIRuEzmWQnsEWBKrGnpFjtMaypOPQpVZh0SpxD1knFwm5d513CEKiPZbuKr2N6eX/xBXceh2qMvPX0ijc+6naAvXZU1rG0RAQR20vVVemXemgY/3DHVXD6ReKvpaHD2kBcjVPub7KizR+3INpSb9Cw+YnnCyJ5PVq5aXIC5DZrlmqkexnwKr79nTla6lByhkZTcvTx7stUYrUpbOJlnh0QLYF45p20JvS8IsCf+9J8zqkXN/sdWlWlsaVeKI/DeVbATDGgoGGYoB8cMF1yJvZA6/JsGMs2FI/nxCUtlwgfrj6JnuOHX8leFrsh1YDs3XDjNYW90LoII0DdlqEzd9NUI/qlGm81OcUAA4aNp5DVK9aeMG//zXnefCAwasCybb5pGXev88xHFP9FaPFFqpeEuVOffly7VL2ujNzdBfSdJS3tdLfHVeuOS4gtlROkRiv0t5ovuSIWOucJwEBN83af3Q77+u6PVQi4vvtKTH1W7FAbgrBV/1r5y2pGamJE++8zMinnGgYoBou415ouO3ZocwAhd3oxZnU7hlqpk9Jn0BEDQBWBBsaStdOQySK6Vu9KEFq/xiMamGlFy5DVNrXC/RqVo14eZnFIpcCPlYGhsWdJaJrhjcoplk4wtdOm/lB/UvZhvuaco1wr38yo9xGTs4IQ08wCiYITUQdxBPWH/cxS4MqB1jrH/YhsuGS8ZN0V1jaTHGYDjle5BzuskqVf9DgBsKz3vL6s8ejESktKu6nu+Tbi1sNlEizkFTwYmFqX4e0Ss9IokGylZqXUzXWMWjE6Lye75/xGP44zADlhLeZf983JkB/umOgX99tWq0YRBzCKWPReORePwSlxleCp9yTUHXShWnX8zY/i9vmBGYG6RvWJ3ylxFeZgXNV2gDvaGqg40ig79AjmSZ/UiQee+cJXM10CXZ1lyWD1Nnd4dZsdUrWjQsGc6RbxoYDIQ4WF3rUso3/GRfX+JNPzBx9KXGnWi4YOqJmy0z0IMRSveYe4tyF0uxoXh7bVTuBMrEoFYIyufW9pSLJyz6OV4pKu0UpaBIeqn9xxzCAyC5yVzuCJyq3CqMNmZI4nlJ6qBEUNAugLna6oFeAuOP7QCzluhTqu1XnBzi8rMMcLglKvMPrHOCJuqq2UcD0+kjJC2S3FwLy3xlLYYFpoeiYqlESiOaxirIOssk0LhxkScmPq+rv1N7azN0ZaNVrmxRZQasRWVTTeUZEEwrS273jq7W0+NxhnqRZ/5oH4WP29dO53LFrKFz3kLwgEfcY4npO3cgg41Je0qBAczgryUTe5lZiup8NHt9p1Lh9NnatHCGBMMXQylvrb7YHpNlywdhiDxWjoVx6UX99bzVdLArEwonr2aBt/CQOMoKaHCYYQa3h50gCjpszPrHfjBe+3u1Q6h1BnhGj2rLta0cCq3CykGw+GZbSMmtXGpUJjncmx/aOe6bMHJUvL+UdNO3ajhfgUqVHI22kUIeJ9SWFULUHRNvr+9/ZKjipB/QK6bFeaPEzxGlPAmoYQg/0zcJnh8tDast7+5XQP3hZHZd/9mgq2hKuX724ywMxTl/SZZNBghihrEgf+JOpLs09mPYg4Cjuzrwu+3QqG5Ney//5s2WvH01pehre3WJObL3qrroRiGN1lfcesANLPAwGnWMrjZsOmeZYulM3H+37SVg+Y/Dfy+Y3O3Y+WICXpezdjaczysrVvJtJ82zLy7GqQq3lcPte60YiAUybyIQfPMTub2W7aHI2TSeEDFs9R/LHCcoTEkbsvEE8P84BLB5JdffoEweTACaED/3HRIW1moBmJvJz8cTdhhaKlfkfl03rBHHMEbJ+hDxoT2yv5Fyb7nA8nJIWekhcq9q1JyAdNTR4ufvUqSFq2j2ppwVeWXrlNXiwu0w6eoncpwLs2h3bD1HfcNOirN1HB7gDIkCwmrLeXjGiTTE5Ve0VoKSAZsru6zsJQYJFeyiZe32UkCgmMLdNPkqdiiYEeA3uAEkm+eXekTuxl6ggMbAseLEKqFO3I1YqtCM1MZcwrGgeA7GvMbafSgRfE8KrY/F5IcG9qH6uQHvy9Z67ka6bg1rRu/OZSo/UOotOIZWqOj3+A2HijiT2bU3ne1SmfPT4vA1Q7J4XRzLzNlpoO9M+uI1W/Is+99EYdQI07J3Uyx0W4vAna9NTYIXXC7AkpPrgygF2yxLhOqcgbIVVvNGGwy7/K6mianqojINdEQTmWcyXxxJFO09gZGOMZ9vwMkWhluqP6WRCBURuHfdgN+U9jW/4z+coxiw/f5ii5osqreAXt2KbHC9Sb8pXKNYCcm/578Fb7og0DQITpuQhwnI3GlLlhYB8IHTw7cpnZRc3Y5ZiWfCV3rltlpMZlNyWmhR16lWC5jvzPbWyxyz2qTras7s5l8qjTw3COIFRM8PxcEuska0f7WcLp4I3Wh+JbRwy1/+HL6uBHquzsncGJs5ESQVwaGQtn5z96kH5rRlOFmWR+J8bZjwYfSPCldyH9i1Xvx7DMuUpvv9E/pSrHo8BnraHXRRXoBNBPU2w0S7IbsjI/umQTlZf6X418FiTOTlqI5FoNhmqaplBXrZfo6+nFl5R39uvgfLjLl+21mFzpnBsgtZwODTliPMLjJRU+gkyTf147xvLy8TG68O9ypQdx3vhrG2/s7Zm+qbv7wKGHybpnN88EExxQS4R3hqt1PTbQykJkbdBZVfXsdeDwY3O1i9duhDzQpCht1bDfGNqXbhThq3botOvUWjZK9BGtYKEFTYOsPFIpNAdaPBw6ww7IrHdtZ1aUNGNtkv6ozmaKR9RFfZ3JVhfbo1wHYsuEApN8l1vuMnHo8KA7AVMW+a9XPgEYOi1eC5UEuYJ3vd2f9apguAjdsT+HF68jnhmVdUTUaP0MkuiJnm6OyReuQSvEjb5A43DcdVDaccqFruO10QwCaiihPqcRejtwFlr1jtWbmumx51bei6DCdFncCBxtTlPsDoJRvCxTA28xfl9vLaACCr859ANasiLcMrVf4z7DPJae9XwaWSEloPxB2ORMr/iNJ9sDtw/wAnnMluvpzx2TqcHQxyJ0fXfLPLNtqVIzyDYyepZ+3h6GgDkrg9qRV8s640fPN3DBURTjjJmkoaC6QjvMemwYqUHIjjha4dVpfsQ10DjEZG1/dKhbzui2GaEBldmlT/Q/nypBATFI/squNVmhFAyxK5wtcT+WLBTzn+TibXBqc/mRW5C+ARwNXTLSd2Ay96eSjdxwMG5P9C00nbkInmXe4lz8oHBzJCIRy2yaoOUCOoyoBV1C9rUJHCxjtfrn96gu2W7kRHK9Xqc+u1xEjtBL/iE0fejaRA66WYFYNJvkcxkILQhdVYQDOuGMjFa3k8pYzGuvt97CREr96x789nnsqOhhQTjgB9u90JrbL1t2IlaCHv1/I4UdlfHNYu3RxaOjNqq0+7py/me7uNs+m8Y+FzFm/td3kw9GZc5nUTayMMfJq9RmlvP2hQyJ8dgz4/EXikDntZA14o/Hu0CIosfMdhOvQkRBgTe4XCpFCYOS2hw6tKMOuriE/likGUop4UUPCw3Mn9uGsCUyoCEO5yDahhnjP3tAYvYzU7lURbJFRXs7ca5sW9DqsG47iQFrdMsE6NKSxKO8jDX+i6iB7sqKVkBfS0a4WjoOCkfnc/X9758i56jKmx07Pq6i7qFZfUPYVRQkzMDa33UAYJosZGCqgkjZV0DKEEwYQC83tZu9JFtx/7mXzKgURrkhJ69ieXoTTv0Nrj/llD6H7OU9u8HcsvkzAqxJxSUobmyLnvddCevrqQZidG13Fx9ll5jQAst1MiMlfcJr66mOOPJnDGR1IXc5675/xNThTV/eLU7TAtY0U//VbHtubTQuvR5QSX+Pybw0zADpWCe2c8imiZG8nZSekDzC6bV5MYv6sJM1BRpPT7/pfBpD8TT05IpzYTOUHMxPKr/jkRlusq+UXJZRZkpfqAlvnED6GfMH0sDudsXKBr35KIY32BgNvfj869RzbxxdloMpv8wc1bK2Iuki+wBkfpkoSrT7RTWuQYrT46fpUTWixWDpv+yZyG/c4J5A2E3erlo5THe/sMU8++m28TfoZGwHMRPEKsSiYSnkG6pz1dgfWHlnpsXhgosk0Xwb+7/jnvh/cNuwSVPIjDXZD+YwLg/FuFlj2KJl0/iz7a0aQ+rDEAcaBUNkkJj5LIAVMRlWK7skObZ4MUgYpki+j1o8FCSCGU+8rQ/zASwhbyo0E7ElTslwFuHl/Wc3k/GK6xdlO5XkyDrdInuvGAwVrqaNfYwft7AIAcc2Ib6eAdcbeOo18Bff9g4wzv10dk3xdg43xhdncJSgswx4PIKsnTmLYLxpiAWdO/bfgFq9eYx0ll+qMf1ASxUxJ9t7b3J2hqsTeCo1CEihRurQ9I5nDxK5ijSP6YdOdOKbHhPIBJKmEnUrpUxapfLFSvntnyMWR6gBIuq+eKBUyees3yJKoE+AQs9xkmnmNfUz3pvFSazgcHyNgfmlQX+AzI8TlYYnxWWSeNN08R/QfbtX49nbCyMnfwhrvwUqRACeqaWGjtJ++ECCJ1rnuhg73FzJ0yKYPPdSnc2jDryV5a33ra/N3GOmQMJPTC1KZeSnrUlzJnXoSaFjxPbFqr6QT05yUK2dyjZtkS8Qi1GT/Zc8KndkYdJbKKupXCX5PdFL4sI0g2nf4VNuSoBm2qdZw0UAxCoz2D7BKFuXANmLuevNvLTWd2EQrEEseo4R1GjXApHDtT5cCj7C06pdHK37dZZPlPuiJhAiUWrV/2g+IzzCZpyaP45quoDe4pAPc3GEk196H7U3T+TyeBE7t4RgWr4ov2zZZrYds8VmlLe8U6Kp9NmVNwol2CsjEALKQemKqa62tsdBoI0wSEFg2EMp5tE8Bsv/TuKW+skDuhVoXovUapQYGB52uGlDxMXNPw164asOcxlv6XmA7nAjR6aH6gE52Lfg0RXNIFZkxPbY0rDWTEmp9/t6BWOVb8gpuKke0S6xWDzr6jZ7SMIap/hTj30eGBN9Ccc4MMuBkE/brHq2Tv2QEVbn9peSa11qSXGHA0Ayhs6cinPuWvBv8niA9iPXTpjfmzNPsLNGKARksYmmlZweRYLv8PD/f+T1POOoqksw1x9NOm+xto4A3wUmHCv4WDBPylIiTCC9VyUm42YZyOeHOTjq3NbUQelT/IDMUhtg9AUkn7PdEgWBAaTUJHxAp0hRXEtdMf7BPffvL+2rUe68nSZilxzQc4IhmRlAwWb+Eet/HVVJmiw+73qN2gwhEiuBK74DrbmqiIPhtn/xpeEjO2iZIS/DYeKBwLDBq1DHJDOUZmoYpDJhqZtO68AER6F9UAMtLUlc5lfmlMD4mBBBGNjwfy7mB2L9ENfn6e+r6CtXuvKXzHqiV+QX+VniaHz/GDPmJgFfIjmW1A7OW8PNXh5ZhhLBGoFWTQDcI2Z7/KWR8T2InSgwkwd6dYnS1pvwFlmCQbzTOb2IWjheEtDj1GeEHCuzJsewzj8CrPVVyyqEQQumRHlqISbJRjnUS7DJZ4RJWFVecX5V3n+VLaXVSemysX4+DBu+/QUMlwHZfQI4JPf1MwQr+G6rTIzA8xVyA/6RjWct4UatG1MQCkjVzJxZAhJmcVOaSYk6UKbFi0zAEhCpy2rENjBpLHpmsr5CeurnceNg1PR3XZW/jTiRNZY6cbS3iiJmIK6vqgtlmEmMN4eQmnp/4yp/dXQYeOU5LYnYpNo06J4lGT4l+D6+AFTCpecFxys9zwz284SXn+r6pDOcnkYGZkcRMAXLzqeJg14uRGNlnwVrl7kaMvVJAJWF2v7lPSkVCu7IwsqcMpgf0u4BE4EYnfg8wvxUOq28+L48T1XXsGMXbVY6O8dZYD+4DtLTtyDY5jMduW+biN8+qOAKoe5zWlrmh/SLuqntN9Y3wZx39Uf/TBZFVyuxN9SsNIRkwzBChdsHyOUsfL6BsQZ1tJsySjKTHFTK0Up5mgc0s5UdyxOaBsfyKW7eY+KPQOWok3uTvU0++YFgKFIpTLx4P7nhX6sP+USmcZyOgsxRbVB0NNpShDEOt7lbDqMNjRyDQk3l1vk4MtwSRyCB3sy7HTm51THoXmsz6Y5Sumsu7I1/AlFdDLpKKt3AGfbIqwAntVqT4Lwtrp9+iMxzZMytqh1+6wNzM6RqRcdw+ZYk7MsctxtBfqJ1G1ulXf3oQkk7MW9gH9HLSiueu1x2NzhnCFenSHi4+L3dhOHaKaJ2Gu4AGDbTGoGPoHhmqfz5kYSWqBWauAJWj6N3SLFp/9qC+opZFYK1OdzlHlYhU6+Stals6fb8ZF9/eRIPlTAmYTswE9C66K08lqEmXdc1BtSSgkE0LaLXY0VBBA20YHFBvOSVySEUAwsr6Rz4zHPxvi8qqncQBor5aKV046527r+mfLxCwB0ud3EmymZtCoG8/Vda+wLUt6TCWSyugYrqbJ8S0fm+Fa3wijgtX+uncQFhHzcSMKy5fOsKNLhmOMruZg20YxSiBI9RR5vfCHbvK5F6zlvP6BLjXP5GcEqvqBBOKNDvxDbXrpCJYrEkJ7j3zMYAnpfhAD0iA9mhB10+60xjLwxQYxYEMJXE/m2B0anb5PlESAe1U8n+RBkyn7uDDTebZj0vX4tmEyz29nM+/e5/oifX7NZ9AQr4J1roUDEnEGq5bNK2hrgiS/s2WwaL5iyAVlgM5WumaFWyFGJllauhNmt3l9J+g4TSSVMfpxg/7iijtpvHPjHxqedyxZxUS6desZ7Lkc6FzUSZdj/6G0XFAxXqQd24tTc7lfc53I112+V/RAhI41dtdcv6yQgqQrIOhwDHX2k2m0/MlOU3UF87Fj8FdZiTnTkpNL6gfffciGTekIdakxZxv+Aju6/rNESy+F3P7uMeFGKhhPK9oCgtUb7Fo23XCodNvkbwchTHlAq0LEmRPe5d+Y4erkmAZOXcPicr3cclcTWcEGHytJESon5csEylhjt+iP7iUi/i84Jy4lPt2XbIuMlng0rNhUAFFXmbGYtvAsdDqeLyDqCCVrawg9tgBfk4+/dHMnHbHxuehgkiRaYAC9d+kaJnf7LIXlcBQMa6JzGs+yJHPJj8Am/KVjob1F1QfQc1WqTNzEBXoreT8avTfoYrOZKqi4nSY0nc7Ib/qj7EZ6c9p4mnUfcxa9KF0XLqQx395A==","originalName":"run_command"}}} +[DEBUG] lombok.version: 1.18.38 +[DEBUG] (s) groupId = org.projectlombok +[DEBUG] (s) artifactId = lombok +[DEBUG] (s) groupId = org.projectlombok +[DEBUG] (s) artifactId = lombok-mapstruct-binding +[DEBUG] (f) annotationProcessorPaths = [org.projectlombok:lombok:1.18.38.jar, org.mapstruct:mapstruct-processor:1.6.2.jar, org.projectlombok:lombok-mapstruct-binding:0.2.0.jar] +[DEBUG] (f) compilePath = [/Users/sonphung/git/loyalty-agent-service/loyalty-mcp-server/target/classes, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-web/4.0.0/spring-boot-starter-web-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-jackson/4.0.0/spring-boot-starter-jackson-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-jackson/4.0.0/spring-boot-jackson-4.0.0.jar, /Users/sonphung/.m2/repository/tools/jackson/core/jackson-databind/3.0.2/jackson-databind-3.0.2.jar, /Users/sonphung/.m2/repository/tools/jackson/core/jackson-core/3.0.2/jackson-core-3.0.2.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-tomcat/4.0.0/spring-boot-starter-tomcat-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-tomcat-runtime/4.0.0/spring-boot-starter-tomcat-runtime-4.0.0.jar, /Users/sonphung/.m2/repository/org/apache/tomcat/embed/tomcat-embed-core/11.0.14/tomcat-embed-core-11.0.14.jar, /Users/sonphung/.m2/repository/org/apache/tomcat/embed/tomcat-embed-el/11.0.14/tomcat-embed-el-11.0.14.jar, /Users/sonphung/.m2/repository/org/apache/tomcat/embed/tomcat-embed-websocket/11.0.14/tomcat-embed-websocket-11.0.14.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-tomcat/4.0.0/spring-boot-tomcat-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-http-converter/4.0.0/spring-boot-http-converter-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/spring-web/7.0.1/spring-web-7.0.1.jar, /Users/sonphung/.m2/repository/io/micrometer/micrometer-observation/1.16.0/micrometer-observation-1.16.0.jar, /Users/sonphung/.m2/repository/io/micrometer/micrometer-commons/1.16.0/micrometer-commons-1.16.0.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-webmvc/4.0.0/spring-boot-webmvc-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-servlet/4.0.0/spring-boot-servlet-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/spring-webmvc/7.0.1/spring-webmvc-7.0.1.jar, /Users/sonphung/.m2/repository/org/springframework/spring-expression/7.0.1/spring-expression-7.0.1.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-data-jpa/4.0.0/spring-boot-starter-data-jpa-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter/4.0.0/spring-boot-starter-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-logging/4.0.0/spring-boot-starter-logging-4.0.0.jar, /Users/sonphung/.m2/repository/ch/qos/logback/logback-classic/1.5.21/logback-classic-1.5.21.jar, /Users/sonphung/.m2/repository/ch/qos/logback/logback-core/1.5.21/logback-core-1.5.21.jar, /Users/sonphung/.m2/repository/org/apache/logging/log4j/log4j-to-slf4j/2.25.2/log4j-to-slf4j-2.25.2.jar, /Users/sonphung/.m2/repository/org/apache/logging/log4j/log4j-api/2.25.2/log4j-api-2.25.2.jar, /Users/sonphung/.m2/repository/org/slf4j/jul-to-slf4j/2.0.17/jul-to-slf4j-2.0.17.jar, /Users/sonphung/.m2/repository/jakarta/annotation/jakarta.annotation-api/3.0.0/jakarta.annotation-api-3.0.0.jar, /Users/sonphung/.m2/repository/org/yaml/snakeyaml/2.5/snakeyaml-2.5.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-jdbc/4.0.0/spring-boot-starter-jdbc-4.0.0.jar, /Users/sonphung/.m2/repository/com/zaxxer/HikariCP/7.0.2/HikariCP-7.0.2.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-data-jpa/4.0.0/spring-boot-data-jpa-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-data-commons/4.0.0/spring-boot-data-commons-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-persistence/4.0.0/spring-boot-persistence-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/data/spring-data-commons/4.0.0/spring-data-commons-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-hibernate/4.0.0/spring-boot-hibernate-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-jpa/4.0.0/spring-boot-jpa-4.0.0.jar, /Users/sonphung/.m2/repository/jakarta/persistence/jakarta.persistence-api/3.2.0/jakarta.persistence-api-3.2.0.jar, /Users/sonphung/.m2/repository/org/hibernate/orm/hibernate-core/7.1.8.Final/hibernate-core-7.1.8.Final.jar, /Users/sonphung/.m2/repository/jakarta/transaction/jakarta.transaction-api/2.0.1/jakarta.transaction-api-2.0.1.jar, /Users/sonphung/.m2/repository/com/fasterxml/classmate/1.7.1/classmate-1.7.1.jar, /Users/sonphung/.m2/repository/org/springframework/spring-orm/7.0.1/spring-orm-7.0.1.jar, /Users/sonphung/.m2/repository/org/springframework/data/spring-data-jpa/4.0.0/spring-data-jpa-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/spring-tx/7.0.1/spring-tx-7.0.1.jar, /Users/sonphung/.m2/repository/org/antlr/antlr4-runtime/4.13.2/antlr4-runtime-4.13.2.jar, /Users/sonphung/.m2/repository/org/springframework/spring-aspects/7.0.1/spring-aspects-7.0.1.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-jdbc/4.0.0/spring-boot-jdbc-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-sql/4.0.0/spring-boot-sql-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-transaction/4.0.0/spring-boot-transaction-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/spring-jdbc/7.0.1/spring-jdbc-7.0.1.jar, /Users/sonphung/.m2/repository/org/springframework/ai/spring-ai-starter-mcp-server-webmvc/2.0.0/spring-ai-starter-mcp-server-webmvc-2.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/ai/spring-ai-autoconfigure-mcp-server-webmvc/2.0.0/spring-ai-autoconfigure-mcp-server-webmvc-2.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/ai/spring-ai-autoconfigure-mcp-server-common/2.0.0/spring-ai-autoconfigure-mcp-server-common-2.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/ai/spring-ai-mcp/2.0.0/spring-ai-mcp-2.0.0.jar, /Users/sonphung/.m2/repository/io/modelcontextprotocol/sdk/mcp/2.0.0/mcp-2.0.0.jar, /Users/sonphung/.m2/repository/io/modelcontextprotocol/sdk/mcp-json-jackson3/2.0.0/mcp-json-jackson3-2.0.0.jar, /Users/sonphung/.m2/repository/com/networknt/json-schema-validator/3.0.0/json-schema-validator-3.0.0.jar, /Users/sonphung/.m2/repository/com/ethlo/time/itu/1.14.0/itu-1.14.0.jar, /Users/sonphung/.m2/repository/tools/jackson/dataformat/jackson-dataformat-yaml/3.0.2/jackson-dataformat-yaml-3.0.2.jar, /Users/sonphung/.m2/repository/org/snakeyaml/snakeyaml-engine/2.10/snakeyaml-engine-2.10.jar, /Users/sonphung/.m2/repository/org/springframework/ai/spring-ai-model/2.0.0/spring-ai-model-2.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/ai/spring-ai-commons/2.0.0/spring-ai-commons-2.0.0.jar, /Users/sonphung/.m2/repository/io/micrometer/micrometer-core/1.16.0/micrometer-core-1.16.0.jar, /Users/sonphung/.m2/repository/io/micrometer/context-propagation/1.2.0/context-propagation-1.2.0.jar, /Users/sonphung/.m2/repository/com/knuddels/jtokkit/1.1.0/jtokkit-1.1.0.jar, /Users/sonphung/.m2/repository/org/springframework/ai/spring-ai-template-st/2.0.0/spring-ai-template-st-2.0.0.jar, /Users/sonphung/.m2/repository/org/antlr/ST4/4.3.4/ST4-4.3.4.jar, /Users/sonphung/.m2/repository/org/antlr/antlr-runtime/3.5.3/antlr-runtime-3.5.3.jar, /Users/sonphung/.m2/repository/org/springframework/spring-messaging/7.0.1/spring-messaging-7.0.1.jar, /Users/sonphung/.m2/repository/io/projectreactor/reactor-core/3.8.0/reactor-core-3.8.0.jar, /Users/sonphung/.m2/repository/org/reactivestreams/reactive-streams/1.0.4/reactive-streams-1.0.4.jar, /Users/sonphung/.m2/repository/com/github/victools/jsonschema-generator/5.0.0/jsonschema-generator-5.0.0.jar, /Users/sonphung/.m2/repository/com/github/victools/jsonschema-module-jackson/5.0.0/jsonschema-module-jackson-5.0.0.jar, /Users/sonphung/.m2/repository/com/github/victools/jsonschema-module-swagger-2/5.0.0/jsonschema-module-swagger-2-5.0.0.jar, /Users/sonphung/.m2/repository/io/swagger/core/v3/swagger-annotations-jakarta/2.2.38/swagger-annotations-jakarta-2.2.38.jar, /Users/sonphung/.m2/repository/org/springframework/ai/spring-ai-mcp-annotations/2.0.0/spring-ai-mcp-annotations-2.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/ai/mcp-spring-webmvc/2.0.0/mcp-spring-webmvc-2.0.0.jar, /Users/sonphung/.m2/repository/io/modelcontextprotocol/sdk/mcp-core/2.0.0/mcp-core-2.0.0.jar, /Users/sonphung/.m2/repository/com/fasterxml/jackson/core/jackson-annotations/2.20/jackson-annotations-2.20.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-aop/3.4.0/spring-boot-starter-aop-3.4.0.jar, /Users/sonphung/.m2/repository/org/springframework/spring-aop/7.0.1/spring-aop-7.0.1.jar, /Users/sonphung/.m2/repository/org/springframework/spring-beans/7.0.1/spring-beans-7.0.1.jar, /Users/sonphung/.m2/repository/org/aspectj/aspectjweaver/1.9.25/aspectjweaver-1.9.25.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-webflux/4.0.0/spring-boot-starter-webflux-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-reactor-netty/4.0.0/spring-boot-starter-reactor-netty-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-reactor/4.0.0/spring-boot-reactor-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-reactor-netty/4.0.0/spring-boot-reactor-netty-4.0.0.jar, /Users/sonphung/.m2/repository/io/projectreactor/netty/reactor-netty-http/1.3.0/reactor-netty-http-1.3.0.jar, /Users/sonphung/.m2/repository/io/netty/netty-codec-http/4.2.7.Final/netty-codec-http-4.2.7.Final.jar, /Users/sonphung/.m2/repository/io/netty/netty-codec-compression/4.2.7.Final/netty-codec-compression-4.2.7.Final.jar, /Users/sonphung/.m2/repository/io/netty/netty-codec-http2/4.2.7.Final/netty-codec-http2-4.2.7.Final.jar, /Users/sonphung/.m2/repository/io/netty/netty-codec-http3/4.2.7.Final/netty-codec-http3-4.2.7.Final.jar, /Users/sonphung/.m2/repository/io/netty/netty-codec-classes-quic/4.2.7.Final/netty-codec-classes-quic-4.2.7.Final.jar, /Users/sonphung/.m2/repository/org/bouncycastle/bcprov-jdk18on/1.80/bcprov-jdk18on-1.80.jar, /Users/sonphung/.m2/repository/io/projectreactor/netty/reactor-netty-core/1.3.0/reactor-netty-core-1.3.0.jar, /Users/sonphung/.m2/repository/io/netty/netty-handler-proxy/4.2.7.Final/netty-handler-proxy-4.2.7.Final.jar, /Users/sonphung/.m2/repository/io/netty/netty-codec-socks/4.2.7.Final/netty-codec-socks-4.2.7.Final.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-webflux/4.0.0/spring-boot-webflux-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-http-codec/4.0.0/spring-boot-http-codec-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/spring-webflux/7.0.1/spring-webflux-7.0.1.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-web-server/4.0.0/spring-boot-web-server-4.0.0.jar, /Users/sonphung/.m2/repository/io/netty/netty-common/4.2.7.Final/netty-common-4.2.7.Final.jar, /Users/sonphung/.m2/repository/io/netty/netty-resolver-dns/4.2.7.Final/netty-resolver-dns-4.2.7.Final.jar, /Users/sonphung/.m2/repository/io/netty/netty-buffer/4.2.7.Final/netty-buffer-4.2.7.Final.jar, /Users/sonphung/.m2/repository/io/netty/netty-resolver/4.2.7.Final/netty-resolver-4.2.7.Final.jar, /Users/sonphung/.m2/repository/io/netty/netty-transport/4.2.7.Final/netty-transport-4.2.7.Final.jar, /Users/sonphung/.m2/repository/io/netty/netty-codec-base/4.2.7.Final/netty-codec-base-4.2.7.Final.jar, /Users/sonphung/.m2/repository/io/netty/netty-codec-dns/4.2.7.Final/netty-codec-dns-4.2.7.Final.jar, /Users/sonphung/.m2/repository/io/netty/netty-handler/4.2.7.Final/netty-handler-4.2.7.Final.jar, /Users/sonphung/.m2/repository/io/netty/netty-transport-native-unix-common/4.2.7.Final/netty-transport-native-unix-common-4.2.7.Final.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-oauth2-client/4.0.0/spring-boot-starter-oauth2-client-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-security/4.0.0/spring-boot-starter-security-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-security/4.0.0/spring-boot-security-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/security/spring-security-config/7.0.0/spring-security-config-7.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/security/spring-security-web/7.0.0/spring-security-web-7.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-security-oauth2-client/4.0.0/spring-boot-security-oauth2-client-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/security/spring-security-oauth2-client/7.0.0/spring-security-oauth2-client-7.0.0.jar, /Users/sonphung/.m2/repository/com/nimbusds/oauth2-oidc-sdk/11.26.1/oauth2-oidc-sdk-11.26.1.jar, /Users/sonphung/.m2/repository/com/github/stephenc/jcip/jcip-annotations/1.0-1/jcip-annotations-1.0-1.jar, /Users/sonphung/.m2/repository/com/nimbusds/content-type/2.3/content-type-2.3.jar, /Users/sonphung/.m2/repository/com/nimbusds/lang-tag/1.7/lang-tag-1.7.jar, /Users/sonphung/.m2/repository/org/springframework/security/spring-security-oauth2-jose/7.0.0/spring-security-oauth2-jose-7.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/security/spring-security-core/7.0.0/spring-security-core-7.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/security/spring-security-crypto/7.0.0/spring-security-crypto-7.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/security/spring-security-oauth2-core/7.0.0/spring-security-oauth2-core-7.0.0.jar, /Users/sonphung/.m2/repository/com/nimbusds/nimbus-jose-jwt/10.4/nimbus-jose-jwt-10.4.jar, /Users/sonphung/.m2/repository/org/projectlombok/lombok/1.18.38/lombok-1.18.38.jar, /Users/sonphung/.m2/repository/org/mapstruct/mapstruct/1.6.2/mapstruct-1.6.2.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot/4.0.0/spring-boot-4.0.0.jar, /Users/sonphung/.m2/repository/org/springframework/spring-context/7.0.1/spring-context-7.0.1.jar, /Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-autoconfigure/4.0.0/spring-boot-autoconfigure-4.0.0.jar, /Users/sonphung/.m2/repository/org/springdoc/springdoc-openapi-starter-webmvc-ui/2.7.0/springdoc-openapi-starter-webmvc-ui-2.7.0.jar, /Users/sonphung/.m2/repository/org/springdoc/springdoc-openapi-starter-webmvc-api/2.7.0/springdoc-openapi-starter-webmvc-api-2.7.0.jar, /Users/sonphung/.m2/repository/org/springdoc/springdoc-openapi-starter-common/2.7.0/springdoc-openapi-starter-common-2.7.0.jar, /Users/sonphung/.m2/repository/io/swagger/core/v3/swagger-core-jakarta/2.2.25/swagger-core-jakarta-2.2.25.jar, /Users/sonphung/.m2/repository/org/apache/commons/commons-lang3/3.19.0/commons-lang3-3.19.0.jar, /Users/sonphung/.m2/repository/io/swagger/core/v3/swagger-models-jakarta/2.2.25/swagger-models-jakarta-2.2.25.jar, /Users/sonphung/.m2/repository/jakarta/validation/jakarta.validation-api/3.1.1/jakarta.validation-api-3.1.1.jar, /Users/sonphung/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.20.1/jackson-databind-2.20.1.jar, /Users/sonphung/.m2/repository/com/fasterxml/jackson/core/jackson-core/2.20.1/jackson-core-2.20.1.jar, /Users/sonphung/.m2/repository/com/fasterxml/jackson/dataformat/jackson-dataformat-yaml/2.20.1/jackson-dataformat-yaml-2.20.1.jar, /Users/sonphung/.m2/repository/com/fasterxml/jackson/datatype/jackson-datatype-jsr310/2.20.1/jackson-datatype-jsr310-2.20.1.jar, /Users/sonphung/.m2/repository/org/webjars/swagger-ui/5.18.2/swagger-ui-5.18.2.jar, /Users/sonphung/.m2/repository/org/webjars/webjars-locator-lite/1.1.2/webjars-locator-lite-1.1.2.jar, /Users/sonphung/.m2/repository/org/jspecify/jspecify/1.0.0/jspecify-1.0.0.jar, /Users/sonphung/.m2/repository/org/slf4j/slf4j-api/2.0.17/slf4j-api-2.0.17.jar, /Users/sonphung/.m2/repository/jakarta/xml/bind/jakarta.xml.bind-api/4.0.4/jakarta.xml.bind-api-4.0.4.jar, /Users/sonphung/.m2/repository/jakarta/activation/jakarta.activation-api/2.1.4/jakarta.activation-api-2.1.4.jar, /Users/sonphung/.m2/repository/net/minidev/json-smart/2.6.0/json-smart-2.6.0.jar, /Users/sonphung/.m2/repository/net/minidev/accessors-smart/2.6.0/accessors-smart-2.6.0.jar, /Users/sonphung/.m2/repository/org/ow2/asm/asm/9.7.1/asm-9.7.1.jar, /Users/sonphung/.m2/repository/org/springframework/spring-core/7.0.1/spring-core-7.0.1.jar, /Users/sonphung/.m2/repository/commons-logging/commons-logging/1.3.5/commons-logging-1.3.5.jar] +[DEBUG] /Users/sonphung/.m2/repository/org/projectlombok/lombok/1.18.38/lombok-1.18.38.jar +[DEBUG] -d /Users/sonphung/git/loyalty-agent-service/loyalty-mcp-server/target/classes -classpath /Users/sonphung/git/loyalty-agent-service/loyalty-mcp-server/target/classes:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-web/4.0.0/spring-boot-starter-web-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-jackson/4.0.0/spring-boot-starter-jackson-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-jackson/4.0.0/spring-boot-jackson-4.0.0.jar:/Users/sonphung/.m2/repository/tools/jackson/core/jackson-databind/3.0.2/jackson-databind-3.0.2.jar:/Users/sonphung/.m2/repository/tools/jackson/core/jackson-core/3.0.2/jackson-core-3.0.2.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-tomcat/4.0.0/spring-boot-starter-tomcat-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-tomcat-runtime/4.0.0/spring-boot-starter-tomcat-runtime-4.0.0.jar:/Users/sonphung/.m2/repository/org/apache/tomcat/embed/tomcat-embed-core/11.0.14/tomcat-embed-core-11.0.14.jar:/Users/sonphung/.m2/repository/org/apache/tomcat/embed/tomcat-embed-el/11.0.14/tomcat-embed-el-11.0.14.jar:/Users/sonphung/.m2/repository/org/apache/tomcat/embed/tomcat-embed-websocket/11.0.14/tomcat-embed-websocket-11.0.14.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-tomcat/4.0.0/spring-boot-tomcat-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-http-converter/4.0.0/spring-boot-http-converter-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/spring-web/7.0.1/spring-web-7.0.1.jar:/Users/sonphung/.m2/repository/io/micrometer/micrometer-observation/1.16.0/micrometer-observation-1.16.0.jar:/Users/sonphung/.m2/repository/io/micrometer/micrometer-commons/1.16.0/micrometer-commons-1.16.0.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-webmvc/4.0.0/spring-boot-webmvc-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-servlet/4.0.0/spring-boot-servlet-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/spring-webmvc/7.0.1/spring-webmvc-7.0.1.jar:/Users/sonphung/.m2/repository/org/springframework/spring-expression/7.0.1/spring-expression-7.0.1.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-data-jpa/4.0.0/spring-boot-starter-data-jpa-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter/4.0.0/spring-boot-starter-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-logging/4.0.0/spring-boot-starter-logging-4.0.0.jar:/Users/sonphung/.m2/repository/ch/qos/logback/logback-classic/1.5.21/logback-classic-1.5.21.jar:/Users/sonphung/.m2/repository/ch/qos/logback/logback-core/1.5.21/logback-core-1.5.21.jar:/Users/sonphung/.m2/repository/org/apache/logging/log4j/log4j-to-slf4j/2.25.2/log4j-to-slf4j-2.25.2.jar:/Users/sonphung/.m2/repository/org/apache/logging/log4j/log4j-api/2.25.2/log4j-api-2.25.2.jar:/Users/sonphung/.m2/repository/org/slf4j/jul-to-slf4j/2.0.17/jul-to-slf4j-2.0.17.jar:/Users/sonphung/.m2/repository/jakarta/annotation/jakarta.annotation-api/3.0.0/jakarta.annotation-api-3.0.0.jar:/Users/sonphung/.m2/repository/org/yaml/snakeyaml/2.5/snakeyaml-2.5.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-jdbc/4.0.0/spring-boot-starter-jdbc-4.0.0.jar:/Users/sonphung/.m2/repository/com/zaxxer/HikariCP/7.0.2/HikariCP-7.0.2.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-data-jpa/4.0.0/spring-boot-data-jpa-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-data-commons/4.0.0/spring-boot-data-commons-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-persistence/4.0.0/spring-boot-persistence-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/data/spring-data-commons/4.0.0/spring-data-commons-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-hibernate/4.0.0/spring-boot-hibernate-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-jpa/4.0.0/spring-boot-jpa-4.0.0.jar:/Users/sonphung/.m2/repository/jakarta/persistence/jakarta.persistence-api/3.2.0/jakarta.persistence-api-3.2.0.jar:/Users/sonphung/.m2/repository/org/hibernate/orm/hibernate-core/7.1.8.Final/hibernate-core-7.1.8.Final.jar:/Users/sonphung/.m2/repository/jakarta/transaction/jakarta.transaction-api/2.0.1/jakarta.transaction-api-2.0.1.jar:/Users/sonphung/.m2/repository/com/fasterxml/classmate/1.7.1/classmate-1.7.1.jar:/Users/sonphung/.m2/repository/org/springframework/spring-orm/7.0.1/spring-orm-7.0.1.jar:/Users/sonphung/.m2/repository/org/springframework/data/spring-data-jpa/4.0.0/spring-data-jpa-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/spring-tx/7.0.1/spring-tx-7.0.1.jar:/Users/sonphung/.m2/repository/org/antlr/antlr4-runtime/4.13.2/antlr4-runtime-4.13.2.jar:/Users/sonphung/.m2/repository/org/springframework/spring-aspects/7.0.1/spring-aspects-7.0.1.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-jdbc/4.0.0/spring-boot-jdbc-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-sql/4.0.0/spring-boot-sql-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-transaction/4.0.0/spring-boot-transaction-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/spring-jdbc/7.0.1/spring-jdbc-7.0.1.jar:/Users/sonphung/.m2/repository/org/springframework/ai/spring-ai-starter-mcp-server-webmvc/2.0.0/spring-ai-starter-mcp-server-webmvc-2.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/ai/spring-ai-autoconfigure-mcp-server-webmvc/2.0.0/spring-ai-autoconfigure-mcp-server-webmvc-2.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/ai/spring-ai-autoconfigure-mcp-server-common/2.0.0/spring-ai-autoconfigure-mcp-server-common-2.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/ai/spring-ai-mcp/2.0.0/spring-ai-mcp-2.0.0.jar:/Users/sonphung/.m2/repository/io/modelcontextprotocol/sdk/mcp/2.0.0/mcp-2.0.0.jar:/Users/sonphung/.m2/repository/io/modelcontextprotocol/sdk/mcp-json-jackson3/2.0.0/mcp-json-jackson3-2.0.0.jar:/Users/sonphung/.m2/repository/com/networknt/json-schema-validator/3.0.0/json-schema-validator-3.0.0.jar:/Users/sonphung/.m2/repository/com/ethlo/time/itu/1.14.0/itu-1.14.0.jar:/Users/sonphung/.m2/repository/tools/jackson/dataformat/jackson-dataformat-yaml/3.0.2/jackson-dataformat-yaml-3.0.2.jar:/Users/sonphung/.m2/repository/org/snakeyaml/snakeyaml-engine/2.10/snakeyaml-engine-2.10.jar:/Users/sonphung/.m2/repository/org/springframework/ai/spring-ai-model/2.0.0/spring-ai-model-2.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/ai/spring-ai-commons/2.0.0/spring-ai-commons-2.0.0.jar:/Users/sonphung/.m2/repository/io/micrometer/micrometer-core/1.16.0/micrometer-core-1.16.0.jar:/Users/sonphung/.m2/repository/io/micrometer/context-propagation/1.2.0/context-propagation-1.2.0.jar:/Users/sonphung/.m2/repository/com/knuddels/jtokkit/1.1.0/jtokkit-1.1.0.jar:/Users/sonphung/.m2/repository/org/springframework/ai/spring-ai-template-st/2.0.0/spring-ai-template-st-2.0.0.jar:/Users/sonphung/.m2/repository/org/antlr/ST4/4.3.4/ST4-4.3.4.jar:/Users/sonphung/.m2/repository/org/antlr/antlr-runtime/3.5.3/antlr-runtime-3.5.3.jar:/Users/sonphung/.m2/repository/org/springframework/spring-messaging/7.0.1/spring-messaging-7.0.1.jar:/Users/sonphung/.m2/repository/io/projectreactor/reactor-core/3.8.0/reactor-core-3.8.0.jar:/Users/sonphung/.m2/repository/org/reactivestreams/reactive-streams/1.0.4/reactive-streams-1.0.4.jar:/Users/sonphung/.m2/repository/com/github/victools/jsonschema-generator/5.0.0/jsonschema-generator-5.0.0.jar:/Users/sonphung/.m2/repository/com/github/victools/jsonschema-module-jackson/5.0.0/jsonschema-module-jackson-5.0.0.jar:/Users/sonphung/.m2/repository/com/github/victools/jsonschema-module-swagger-2/5.0.0/jsonschema-module-swagger-2-5.0.0.jar:/Users/sonphung/.m2/repository/io/swagger/core/v3/swagger-annotations-jakarta/2.2.38/swagger-annotations-jakarta-2.2.38.jar:/Users/sonphung/.m2/repository/org/springframework/ai/spring-ai-mcp-annotations/2.0.0/spring-ai-mcp-annotations-2.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/ai/mcp-spring-webmvc/2.0.0/mcp-spring-webmvc-2.0.0.jar:/Users/sonphung/.m2/repository/io/modelcontextprotocol/sdk/mcp-core/2.0.0/mcp-core-2.0.0.jar:/Users/sonphung/.m2/repository/com/fasterxml/jackson/core/jackson-annotations/2.20/jackson-annotations-2.20.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-aop/3.4.0/spring-boot-starter-aop-3.4.0.jar:/Users/sonphung/.m2/repository/org/springframework/spring-aop/7.0.1/spring-aop-7.0.1.jar:/Users/sonphung/.m2/repository/org/springframework/spring-beans/7.0.1/spring-beans-7.0.1.jar:/Users/sonphung/.m2/repository/org/aspectj/aspectjweaver/1.9.25/aspectjweaver-1.9.25.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-webflux/4.0.0/spring-boot-starter-webflux-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-reactor-netty/4.0.0/spring-boot-starter-reactor-netty-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-reactor/4.0.0/spring-boot-reactor-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-reactor-netty/4.0.0/spring-boot-reactor-netty-4.0.0.jar:/Users/sonphung/.m2/repository/io/projectreactor/netty/reactor-netty-http/1.3.0/reactor-netty-http-1.3.0.jar:/Users/sonphung/.m2/repository/io/netty/netty-codec-http/4.2.7.Final/netty-codec-http-4.2.7.Final.jar:/Users/sonphung/.m2/repository/io/netty/netty-codec-compression/4.2.7.Final/netty-codec-compression-4.2.7.Final.jar:/Users/sonphung/.m2/repository/io/netty/netty-codec-http2/4.2.7.Final/netty-codec-http2-4.2.7.Final.jar:/Users/sonphung/.m2/repository/io/netty/netty-codec-http3/4.2.7.Final/netty-codec-http3-4.2.7.Final.jar:/Users/sonphung/.m2/repository/io/netty/netty-codec-classes-quic/4.2.7.Final/netty-codec-classes-quic-4.2.7.Final.jar:/Users/sonphung/.m2/repository/org/bouncycastle/bcprov-jdk18on/1.80/bcprov-jdk18on-1.80.jar:/Users/sonphung/.m2/repository/io/projectreactor/netty/reactor-netty-core/1.3.0/reactor-netty-core-1.3.0.jar:/Users/sonphung/.m2/repository/io/netty/netty-handler-proxy/4.2.7.Final/netty-handler-proxy-4.2.7.Final.jar:/Users/sonphung/.m2/repository/io/netty/netty-codec-socks/4.2.7.Final/netty-codec-socks-4.2.7.Final.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-webflux/4.0.0/spring-boot-webflux-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-http-codec/4.0.0/spring-boot-http-codec-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/spring-webflux/7.0.1/spring-webflux-7.0.1.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-web-server/4.0.0/spring-boot-web-server-4.0.0.jar:/Users/sonphung/.m2/repository/io/netty/netty-common/4.2.7.Final/netty-common-4.2.7.Final.jar:/Users/sonphung/.m2/repository/io/netty/netty-resolver-dns/4.2.7.Final/netty-resolver-dns-4.2.7.Final.jar:/Users/sonphung/.m2/repository/io/netty/netty-buffer/4.2.7.Final/netty-buffer-4.2.7.Final.jar:/Users/sonphung/.m2/repository/io/netty/netty-resolver/4.2.7.Final/netty-resolver-4.2.7.Final.jar:/Users/sonphung/.m2/repository/io/netty/netty-transport/4.2.7.Final/netty-transport-4.2.7.Final.jar:/Users/sonphung/.m2/repository/io/netty/netty-codec-base/4.2.7.Final/netty-codec-base-4.2.7.Final.jar:/Users/sonphung/.m2/repository/io/netty/netty-codec-dns/4.2.7.Final/netty-codec-dns-4.2.7.Final.jar:/Users/sonphung/.m2/repository/io/netty/netty-handler/4.2.7.Final/netty-handler-4.2.7.Final.jar:/Users/sonphung/.m2/repository/io/netty/netty-transport-native-unix-common/4.2.7.Final/netty-transport-native-unix-common-4.2.7.Final.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-oauth2-client/4.0.0/spring-boot-starter-oauth2-client-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-starter-security/4.0.0/spring-boot-starter-security-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-security/4.0.0/spring-boot-security-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/security/spring-security-config/7.0.0/spring-security-config-7.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/security/spring-security-web/7.0.0/spring-security-web-7.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-security-oauth2-client/4.0.0/spring-boot-security-oauth2-client-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/security/spring-security-oauth2-client/7.0.0/spring-security-oauth2-client-7.0.0.jar:/Users/sonphung/.m2/repository/com/nimbusds/oauth2-oidc-sdk/11.26.1/oauth2-oidc-sdk-11.26.1.jar:/Users/sonphung/.m2/repository/com/github/stephenc/jcip/jcip-annotations/1.0-1/jcip-annotations-1.0-1.jar:/Users/sonphung/.m2/repository/com/nimbusds/content-type/2.3/content-type-2.3.jar:/Users/sonphung/.m2/repository/com/nimbusds/lang-tag/1.7/lang-tag-1.7.jar:/Users/sonphung/.m2/repository/org/springframework/security/spring-security-oauth2-jose/7.0.0/spring-security-oauth2-jose-7.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/security/spring-security-core/7.0.0/spring-security-core-7.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/security/spring-security-crypto/7.0.0/spring-security-crypto-7.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/security/spring-security-oauth2-core/7.0.0/spring-security-oauth2-core-7.0.0.jar:/Users/sonphung/.m2/repository/com/nimbusds/nimbus-jose-jwt/10.4/nimbus-jose-jwt-10.4.jar:/Users/sonphung/.m2/repository/org/projectlombok/lombok/1.18.38/lombok-1.18.38.jar:/Users/sonphung/.m2/repository/org/mapstruct/mapstruct/1.6.2/mapstruct-1.6.2.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot/4.0.0/spring-boot-4.0.0.jar:/Users/sonphung/.m2/repository/org/springframework/spring-context/7.0.1/spring-context-7.0.1.jar:/Users/sonphung/.m2/repository/org/springframework/boot/spring-boot-autoconfigure/4.0.0/spring-boot-autoconfigure-4.0.0.jar:/Users/sonphung/.m2/repository/org/springdoc/springdoc-openapi-starter-webmvc-ui/2.7.0/springdoc-openapi-starter-webmvc-ui-2.7.0.jar:/Users/sonphung/.m2/repository/org/springdoc/springdoc-openapi-starter-webmvc-api/2.7.0/springdoc-openapi-starter-webmvc-api-2.7.0.jar:/Users/sonphung/.m2/repository/org/springdoc/springdoc-openapi-starter-common/2.7.0/springdoc-openapi-starter-common-2.7.0.jar:/Users/sonphung/.m2/repository/io/swagger/core/v3/swagger-core-jakarta/2.2.25/swagger-core-jakarta-2.2.25.jar:/Users/sonphung/.m2/repository/org/apache/commons/commons-lang3/3.19.0/commons-lang3-3.19.0.jar:/Users/sonphung/.m2/repository/io/swagger/core/v3/swagger-models-jakarta/2.2.25/swagger-models-jakarta-2.2.25.jar:/Users/sonphung/.m2/repository/jakarta/validation/jakarta.validation-api/3.1.1/jakarta.validation-api-3.1.1.jar:/Users/sonphung/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.20.1/jackson-databind-2.20.1.jar:/Users/sonphung/.m2/repository/com/fasterxml/jackson/core/jackson-core/2.20.1/jackson-core-2.20.1.jar:/Users/sonphung/.m2/repository/com/fasterxml/jackson/dataformat/jackson-dataformat-yaml/2.20.1/jackson-dataformat-yaml-2.20.1.jar:/Users/sonphung/.m2/repository/com/fasterxml/jackson/datatype/jackson-datatype-jsr310/2.20.1/jackson-datatype-jsr310-2.20.1.jar:/Users/sonphung/.m2/repository/org/webjars/swagger-ui/5.18.2/swagger-ui-5.18.2.jar:/Users/sonphung/.m2/repository/org/webjars/webjars-locator-lite/1.1.2/webjars-locator-lite-1.1.2.jar:/Users/sonphung/.m2/repository/org/jspecify/jspecify/1.0.0/jspecify-1.0.0.jar:/Users/sonphung/.m2/repository/org/slf4j/slf4j-api/2.0.17/slf4j-api-2.0.17.jar:/Users/sonphung/.m2/repository/jakarta/xml/bind/jakarta.xml.bind-api/4.0.4/jakarta.xml.bind-api-4.0.4.jar:/Users/sonphung/.m2/repository/jakarta/activation/jakarta.activation-api/2.1.4/jakarta.activation-api-2.1.4.jar:/Users/sonphung/.m2/repository/net/minidev/json-smart/2.6.0/json-smart-2.6.0.jar:/Users/sonphung/.m2/repository/net/minidev/accessors-smart/2.6.0/accessors-smart-2.6.0.jar:/Users/sonphung/.m2/repository/org/ow2/asm/asm/9.7.1/asm-9.7.1.jar:/Users/sonphung/.m2/repository/org/springframework/spring-core/7.0.1/spring-core-7.0.1.jar:/Users/sonphung/.m2/repository/commons-logging/commons-logging/1.3.5/commons-logging-1.3.5.jar: -sourcepath /Users/sonphung/git/loyalty-agent-service/loyalty-mcp-server/src/main/java: -s /Users/sonphung/git/loyalty-agent-service/loyalty-mcp-server/target/generated-sources/annotations -processorpath /Users/sonphung/.m2/repository/org/projectlombok/lombok/1.18.38/lombok-1.18.38.jar:/Users/sonphung/.m2/repository/org/mapstruct/mapstruct-processor/1.6.2/mapstruct-processor-1.6.2.jar:/Users/sonphung/.m2/repository/org/projectlombok/lombok-mapstruct-binding/0.2.0/lombok-mapstruct-binding-0.2.0.jar: -g -parameters --release 25 -encoding UTF-8 -XDuseUnsharedTable=true diff --git a/loyalty-mcp-server/pom.xml b/loyalty-mcp-server/pom.xml index db09fd1..4e0ff0d 100644 --- a/loyalty-mcp-server/pom.xml +++ b/loyalty-mcp-server/pom.xml @@ -14,6 +14,8 @@ loyalty-mcp-server MCP Server for Loyalty API Tools + + @@ -38,6 +40,12 @@ 3.4.0 + + + org.springframework.boot + spring-boot-starter-actuator + + org.springframework.boot @@ -106,6 +114,11 @@ maven-compiler-plugin + + org.projectlombok + lombok + ${lombok.version} + org.mapstruct mapstruct-processor @@ -113,8 +126,8 @@ org.projectlombok - lombok - ${lombok.version} + lombok-mapstruct-binding + 0.2.0 diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/aspect/GlobalToolExceptionHandlerAspect.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/aspect/GlobalToolExceptionHandlerAspect.java index 1f09b65..b277ae4 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/aspect/GlobalToolExceptionHandlerAspect.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/aspect/GlobalToolExceptionHandlerAspect.java @@ -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; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/attribute/config/DynamicAttributeConfig.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/attribute/config/DynamicAttributeConfig.java new file mode 100644 index 0000000..1a8e10e --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/attribute/config/DynamicAttributeConfig.java @@ -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); + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/attribute/model/DynamicAttributeCriteria.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/attribute/model/DynamicAttributeCriteria.java new file mode 100644 index 0000000..314c322 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/attribute/model/DynamicAttributeCriteria.java @@ -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; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/attribute/model/DynamicAttributeDto.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/attribute/model/DynamicAttributeDto.java new file mode 100644 index 0000000..132a761 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/attribute/model/DynamicAttributeDto.java @@ -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 listOfValue; + + private List attributeGroups; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/attribute/service/DynamicAttributeClient.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/attribute/service/DynamicAttributeClient.java new file mode 100644 index 0000000..23ec5b9 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/attribute/service/DynamicAttributeClient.java @@ -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> search(@ModelAttribute DynamicAttributeCriteria criteria); + + @GetExchange("/api/dynamic-attribute/csr/id/{id}") + Result getById(@PathVariable("id") String id); +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/attribute/service/DynamicAttributeService.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/attribute/service/DynamicAttributeService.java new file mode 100644 index 0000000..f2dbd55 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/attribute/service/DynamicAttributeService.java @@ -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> 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 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()); + } + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/client/CatalogueClient.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/client/CatalogueClient.java new file mode 100644 index 0000000..5799930 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/client/CatalogueClient.java @@ -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 getAll(CatalogueCriteria criteria, Pageable pageable); + + @GetExchange("/api/item/csr/id/{id}") + Catalogue findActiveById(@PathVariable String id); +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/criteria/CatalogueCriteria.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/criteria/CatalogueCriteria.java new file mode 100644 index 0000000..d23a7c0 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/criteria/CatalogueCriteria.java @@ -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; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/Catalogue.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/Catalogue.java new file mode 100644 index 0000000..83d56e5 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/Catalogue.java @@ -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 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 itemDetails; + + private List itemImages; + + private List itemPrices; + + private List limitOnRedemptions; + + private List itemContentSms; + + private List 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 itemStores; + + private List itemContentNotifications; + + private List redemptionModes; + + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private String recordNoActive; + + private Map numberItemsByCategories; + private List itemNameLanguages; + +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemContentEmailDto.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemContentEmailDto.java new file mode 100644 index 0000000..3175d79 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemContentEmailDto.java @@ -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; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemContentNotificationDto.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemContentNotificationDto.java new file mode 100644 index 0000000..deccb30 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemContentNotificationDto.java @@ -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 relatedButtons; + +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemContentSmsDto.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemContentSmsDto.java new file mode 100644 index 0000000..38e8b6b --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemContentSmsDto.java @@ -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; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemDetailLocaleDto.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemDetailLocaleDto.java new file mode 100644 index 0000000..3c00c71 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemDetailLocaleDto.java @@ -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; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemImageDto.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemImageDto.java new file mode 100644 index 0000000..6eefb1e --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemImageDto.java @@ -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; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemPriceDto.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemPriceDto.java new file mode 100644 index 0000000..58041ae --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemPriceDto.java @@ -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 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; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemStoreDto.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemStoreDto.java new file mode 100644 index 0000000..c6b8c93 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemStoreDto.java @@ -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; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemVoucherLinkedDto.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemVoucherLinkedDto.java new file mode 100644 index 0000000..93bcc8b --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/ItemVoucherLinkedDto.java @@ -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; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/LimitOnRedemptionDto.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/LimitOnRedemptionDto.java new file mode 100644 index 0000000..74e6def --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/catalogue/model/LimitOnRedemptionDto.java @@ -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; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/config/RestClientConfig.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/config/RestClientConfig.java index 141ca38..d9e0247 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/config/RestClientConfig.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/config/RestClientConfig.java @@ -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(); diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/customer/client/CustomerClient.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/customer/client/CustomerClient.java new file mode 100644 index 0000000..803f9aa --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/customer/client/CustomerClient.java @@ -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 getAll(CustomerCriteria criteria, Pageable pageable); + + @GetExchange("/api/client/csr/id/{id}") + Customer findActiveById(@PathVariable String id); +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/customer/criteria/CustomerCriteria.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/customer/criteria/CustomerCriteria.java new file mode 100644 index 0000000..fb7d34e --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/customer/criteria/CustomerCriteria.java @@ -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; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/customer/model/ClientCorrDto.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/customer/model/ClientCorrDto.java new file mode 100644 index 0000000..4ca6caf --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/customer/model/ClientCorrDto.java @@ -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; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/customer/model/Customer.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/customer/model/Customer.java new file mode 100644 index 0000000..d08d844 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/customer/model/Customer.java @@ -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 clientCorrs; + + public String getClientName() { + return this.firstName + " " + this.lastName; + } + + public void setClientName(String clientName) { + this.clientName = this.firstName + " " + this.lastName; + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/customer/model/Gender.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/customer/model/Gender.java new file mode 100644 index 0000000..0ac92e1 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/customer/model/Gender.java @@ -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; + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/customer/model/HolderType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/customer/model/HolderType.java new file mode 100644 index 0000000..ddebbe8 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/customer/model/HolderType.java @@ -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; + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/customer/model/MaritalStatus.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/customer/model/MaritalStatus.java new file mode 100644 index 0000000..ba42a9e --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/customer/model/MaritalStatus.java @@ -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; + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/AttributeDataType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/AttributeDataType.java index 0a49879..8dd2db2 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/AttributeDataType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/AttributeDataType.java @@ -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; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/AttributeEntityType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/AttributeEntityType.java index ba74a2b..2ee2b6f 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/AttributeEntityType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/AttributeEntityType.java @@ -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; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/AttributeType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/AttributeType.java index 1997b0c..fe9890a 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/AttributeType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/AttributeType.java @@ -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; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/CounterBucket.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/CounterBucket.java index a936950..a6d88bd 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/CounterBucket.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/CounterBucket.java @@ -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; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/CounterEntity.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/CounterEntity.java index bcb329e..a57741e 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/CounterEntity.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/CounterEntity.java @@ -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; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/CounterPeriod.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/CounterPeriod.java index af7bd03..2cd4989 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/CounterPeriod.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/CounterPeriod.java @@ -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; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/DerivedType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/DerivedType.java index 96cff46..9788c9f 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/DerivedType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/DerivedType.java @@ -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); } + } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/EntityLevel.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/EntityLevel.java index eb44b36..90a5415 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/EntityLevel.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/EntityLevel.java @@ -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; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/ExpiryPolicy.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/ExpiryPolicy.java index f7384c5..5e148dc 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/ExpiryPolicy.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/ExpiryPolicy.java @@ -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; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/FileStoreType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/FileStoreType.java index 1ed136f..a2d199c 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/FileStoreType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/FileStoreType.java @@ -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; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/ImageSourceType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/ImageSourceType.java index 9a5c562..e3d8666 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/ImageSourceType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/ImageSourceType.java @@ -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; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/RecordStatus.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/RecordStatus.java index 181e812..5135422 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/RecordStatus.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/RecordStatus.java @@ -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; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/StatisticPeriod.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/StatisticPeriod.java index 58fa510..b4ed699 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/StatisticPeriod.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/StatisticPeriod.java @@ -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; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/DayOfMonth.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/DayOfMonth.java index df73dcf..de53bf1 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/DayOfMonth.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/DayOfMonth.java @@ -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; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/DayOfWeek.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/DayOfWeek.java index b932584..531f1b4 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/DayOfWeek.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/DayOfWeek.java @@ -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; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/MonthOfYear.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/MonthOfYear.java index 7e71300..b8f8a2b 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/MonthOfYear.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/MonthOfYear.java @@ -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; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/PeriodicType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/PeriodicType.java index e51692f..9a7c60a 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/PeriodicType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/PeriodicType.java @@ -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; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/ScheduleMonthType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/ScheduleMonthType.java index 2e78a15..09090e3 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/ScheduleMonthType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/ScheduleMonthType.java @@ -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; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/ScheduleRecurrenceType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/ScheduleRecurrenceType.java index ecc1ad7..74a938e 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/ScheduleRecurrenceType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/ScheduleRecurrenceType.java @@ -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; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/TriggerMethod.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/TriggerMethod.java index b90cc30..a470788 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/TriggerMethod.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/TriggerMethod.java @@ -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; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/WeekOfMonth.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/WeekOfMonth.java index 0b1a1b5..861305b 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/WeekOfMonth.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/enums/schedule/WeekOfMonth.java @@ -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; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/exception/GlobalMcpExceptionHandler.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/exception/GlobalMcpExceptionHandler.java index cecb81b..8b790e5 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/exception/GlobalMcpExceptionHandler.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/exception/GlobalMcpExceptionHandler.java @@ -16,7 +16,7 @@ public class GlobalMcpExceptionHandler { @ExceptionHandler(Exception.class) public ResponseEntity 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) diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/health/CoreApiHealthIndicator.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/health/CoreApiHealthIndicator.java new file mode 100644 index 0000000..a0f845b --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/health/CoreApiHealthIndicator.java @@ -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(); + } + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/identity/config/UserConfig.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/identity/config/UserConfig.java new file mode 100644 index 0000000..3fe739c --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/identity/config/UserConfig.java @@ -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); + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/identity/model/UserCriteria.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/identity/model/UserCriteria.java new file mode 100644 index 0000000..b08e805 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/identity/model/UserCriteria.java @@ -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; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/identity/model/UserResponseDto.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/identity/model/UserResponseDto.java new file mode 100644 index 0000000..e62992b --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/identity/model/UserResponseDto.java @@ -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 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; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/identity/service/UserClient.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/identity/service/UserClient.java new file mode 100644 index 0000000..4079401 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/identity/service/UserClient.java @@ -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> search(@ModelAttribute UserCriteria criteria); + + @GetExchange("/api/user/csr/id/{id}") + Result getById(@PathVariable("id") String id); +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/identity/service/UserService.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/identity/service/UserService.java new file mode 100644 index 0000000..3645703 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/identity/service/UserService.java @@ -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> 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 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()); + } + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/mapper/McpDtoMapper.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/mapper/McpDtoMapper.java new file mode 100644 index 0000000..a3befcd --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/mapper/McpDtoMapper.java @@ -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 toTransactionCodeDtoList(List entities); + + CounterDefinitionResponse toDto(CounterDefinition entity); + List toCounterDefinitionDtoList(List entities); + + DeductionSequenceResponse toDto(DeductionSequences entity); + List toDeductionSequenceDtoList(List entities); +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/marketing/config/JourneyConfig.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/marketing/config/JourneyConfig.java new file mode 100644 index 0000000..ca597e3 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/marketing/config/JourneyConfig.java @@ -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); + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/marketing/model/JourneyCriteria.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/marketing/model/JourneyCriteria.java new file mode 100644 index 0000000..bce58c9 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/marketing/model/JourneyCriteria.java @@ -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; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/marketing/model/JourneyDto.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/marketing/model/JourneyDto.java new file mode 100644 index 0000000..fe57b86 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/marketing/model/JourneyDto.java @@ -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 journeySrcEvents; + private List journeyActSms; + private List journeyActEmails; + private List journeyActPushNotifications; + private List journeyActInappMessages; + private List journeyActVouchers; + private List journeyFlowDurations; + private List journeyFlowDates; + private List journeyFlowInteractions; + private List journeyFlowDecisions; + private List journeyFlowRandoms; + private List journeyFlowJoins; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/marketing/service/JourneyClient.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/marketing/service/JourneyClient.java new file mode 100644 index 0000000..ca8086c --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/marketing/service/JourneyClient.java @@ -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> search(@ModelAttribute JourneyCriteria criteria); + + @GetExchange("/api/journey/csr/id/{id}") + Result getById(@PathVariable("id") String id); +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/marketing/service/JourneyService.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/marketing/service/JourneyService.java new file mode 100644 index 0000000..fcb0bec --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/marketing/service/JourneyService.java @@ -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> 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 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()); + } + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/master/config/AppParamConfig.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/master/config/AppParamConfig.java new file mode 100644 index 0000000..54b5e91 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/master/config/AppParamConfig.java @@ -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); + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/master/model/AppParamCriteria.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/master/model/AppParamCriteria.java new file mode 100644 index 0000000..f848de0 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/master/model/AppParamCriteria.java @@ -0,0 +1,12 @@ +package dev.sonpx.loyalty.mcp.master.model; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class AppParamCriteria { + private String search; + private int page = 1; + private int size = 10; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/master/model/AppParamDto.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/master/model/AppParamDto.java new file mode 100644 index 0000000..a650111 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/master/model/AppParamDto.java @@ -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.master.model; + + +import dev.sonpx.loyalty.mcp.model.ReferenceData; + +import lombok.Getter; +import lombok.Setter; + +/** + * Created by AnhNt on Wednesday, 15-Nov-2023 + */ +@Getter +@Setter +public class AppParamDto { + + private ReferenceData appGroup; + + private String key; + + private String description; + + private String value; + + private String semanticType; + + private Boolean editable; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/master/service/AppParamClient.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/master/service/AppParamClient.java new file mode 100644 index 0000000..9fec759 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/master/service/AppParamClient.java @@ -0,0 +1,21 @@ +package dev.sonpx.loyalty.mcp.master.service; + +import dev.sonpx.loyalty.mcp.model.Result; +import dev.sonpx.loyalty.mcp.master.model.AppParamDto; +import dev.sonpx.loyalty.mcp.master.model.AppParamCriteria; +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 AppParamClient { + + @GetExchange("/api/app-param/csr/list") + Result> search(@ModelAttribute AppParamCriteria criteria); + + @GetExchange("/api/app-param/csr/id/{id}") + Result getById(@PathVariable("id") String id); +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/master/service/AppParamService.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/master/service/AppParamService.java new file mode 100644 index 0000000..da451d6 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/master/service/AppParamService.java @@ -0,0 +1,36 @@ +package dev.sonpx.loyalty.mcp.master.service; + +import dev.sonpx.loyalty.mcp.model.Result; +import dev.sonpx.loyalty.mcp.master.model.AppParamDto; +import dev.sonpx.loyalty.mcp.master.model.AppParamCriteria; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AppParamService { + + private final AppParamClient client; + + public Result> search(AppParamCriteria criteria) { + try { + return client.search(criteria); + } catch (Exception e) { + log.error("Failed to search AppParam", e); + return Result.failure(e.getMessage()); + } + } + + public Result getById(String id) { + try { + return client.getById(id); + } catch (Exception e) { + log.error("Failed to get AppParam by id", e); + return Result.failure(e.getMessage()); + } + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/model/AttributeDataType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/model/AttributeDataType.java new file mode 100644 index 0000000..e69bac6 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/model/AttributeDataType.java @@ -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.model; + +import java.util.Arrays; + + +public enum AttributeDataType { + STRING("STRING"), + NUMBER("NUMBER"), + DATE("DATE"), + BOOLEAN("BOOLEAN"), + TIME("TIME"); + + private final String code; + + public static AttributeDataType fromCode(String code) { + return Arrays.stream(AttributeDataType.values()) + .filter(o -> code.equals(o.getCode())) + .findFirst() + .orElse(null); + } + + AttributeDataType(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/model/AttributeEntityType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/model/AttributeEntityType.java new file mode 100644 index 0000000..5bc0ca7 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/model/AttributeEntityType.java @@ -0,0 +1,50 @@ +/* + * 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.model; + +import java.util.Arrays; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + + +/** + * @author Phudao + * Created on 2024/01/12 + */ +public enum AttributeEntityType { + CLIENT("CLIENT"), + PRODUCT_ACCOUNT("PRODUCT_ACCOUNT"), + CARD("CARD"), + CORPORATION("CORPORATION"), + CHAIN("CHAIN"), + STORE("STORE"), + // TRANSACTIONS("TRANSACTIONS"), + CAMPAIGN("CAMPAIGN"), + ITEM("ITEM"); + + @JsonValue + private final String code; + + @JsonCreator + public static AttributeEntityType fromCode(String code) { + return Arrays.stream(values()) + .filter(e -> e.code.equalsIgnoreCase(code)) + .findFirst() + .orElse(null); + } + + AttributeEntityType(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/model/LanguageCode.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/model/LanguageCode.java new file mode 100644 index 0000000..b896e7a --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/model/LanguageCode.java @@ -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.model; + +import java.util.Arrays; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + + +public enum LanguageCode { + ENGLISH("EN"), + VIETNAM("VI"); + + @JsonValue + private final String code; + + @JsonCreator + public static LanguageCode fromCode(String code) { + return Arrays.stream(values()) + .filter(e -> e.code.equalsIgnoreCase(code)) + .findFirst() + .orElse(LanguageCode.ENGLISH); + } + + public static boolean isDefault(String code) { + return LanguageCode.ENGLISH.equals(fromCode(code)); + } + + LanguageCode(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AlertChannel.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AlertChannel.java index db60216..5c119ee 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AlertChannel.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AlertChannel.java @@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.reward.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 AlertChannel { EMAIL("E"); @@ -25,4 +21,12 @@ public enum AlertChannel { .findFirst() .orElse(null); } + + AlertChannel(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AmountToUseType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AmountToUseType.java index 2ca4d29..43839e1 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AmountToUseType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AmountToUseType.java @@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.reward.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 AmountToUseType { GROSS_AMOUNT("GA"), NETT_AMOUNT("NA"); @@ -26,4 +22,12 @@ public enum AmountToUseType { .findFirst() .orElse(null); } + + AmountToUseType(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AmountToUseViewType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AmountToUseViewType.java index 6577619..06075fa 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AmountToUseViewType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AmountToUseViewType.java @@ -3,14 +3,11 @@ package dev.sonpx.loyalty.mcp.reward.enums; import com.fasterxml.jackson.annotation.JsonCreator; import java.util.Arrays; -import lombok.Getter; -import lombok.RequiredArgsConstructor; /** * Created by SonPhung on Monday, 20-Jul-2026 */ -@Getter -@RequiredArgsConstructor + public enum AmountToUseViewType { CODE(null, null), ATTRIBUTE("A", "Attribute"), @@ -19,10 +16,23 @@ public enum AmountToUseViewType { private final String code; private final String name; + AmountToUseViewType(String code, String name) { + this.code = code; + this.name = name; + } + + public String getCode() { + return code; + } + + public String getName() { + return name; + } + @JsonCreator public static AmountToUseViewType fromCode(String code) { return Arrays.stream(values()) - .filter(e -> e.code.equalsIgnoreCase(code)) + .filter(e -> e.code != null && e.code.equalsIgnoreCase(code)) .findFirst() .orElse(null); } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AwardBasisType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AwardBasisType.java index 8e5a7f5..58d12d6 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AwardBasisType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AwardBasisType.java @@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.reward.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 Friday, 10-Nov-2023 */ -@Getter -@RequiredArgsConstructor public enum AwardBasisType { APPLY_EACH_TIER("AAF"), APPLY_HIGHEST_TIER("AHT"); @@ -26,4 +22,12 @@ public enum AwardBasisType { .findFirst() .orElse(null); } + + AwardBasisType(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AwardFactorType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AwardFactorType.java index 9bceeed..3ea044d 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AwardFactorType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AwardFactorType.java @@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.reward.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 Friday, 10-Nov-2023 */ -@Getter -@RequiredArgsConstructor public enum AwardFactorType { PERCENT("GA"), POOL_UNIT("NA"); @@ -26,4 +22,12 @@ public enum AwardFactorType { .findFirst() .orElse(null); } + + AwardFactorType(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AwardLimitType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AwardLimitType.java index baf3a30..10a87c5 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AwardLimitType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/AwardLimitType.java @@ -4,16 +4,12 @@ package dev.sonpx.loyalty.mcp.reward.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 Friday, 10-Nov-2023 * This sets the cap on the sum of Result from the formulas set up in Rule. * If the Result from Rule exceeds this cap, then this cap is used as the Result. */ -@Getter -@RequiredArgsConstructor public enum AwardLimitType { NO_MORE_THAN("NM"), AT_LEAST("AL"); @@ -28,4 +24,12 @@ public enum AwardLimitType { .findFirst() .orElse(null); } + + AwardLimitType(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/BaseDateType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/BaseDateType.java index 72ff0ca..eb9ea16 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/BaseDateType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/BaseDateType.java @@ -5,14 +5,10 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonPropertyDescription; 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 BaseDateType { @JsonPropertyDescription("Ngày hệ thống ghi nhận giao dịch") @@ -31,4 +27,12 @@ public enum BaseDateType { .findFirst() .orElse(null); } + + BaseDateType(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/CampaignType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/CampaignType.java index 1c85ebd..8910c58 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/CampaignType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/CampaignType.java @@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.reward.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 CampaignType { BASE("B"), TACTICAL("T"); @@ -26,4 +22,12 @@ public enum CampaignType { .findFirst() .orElse(null); } + + CampaignType(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/CapAwardType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/CapAwardType.java index 6112186..d51df8c 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/CapAwardType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/CapAwardType.java @@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.reward.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 Friday, 10-Nov-2023 */ -@Getter -@RequiredArgsConstructor public enum CapAwardType { TRACKED_IN_COUNTER("C"), PER_TRANSACTION("T"); @@ -26,4 +22,12 @@ public enum CapAwardType { .findFirst() .orElse(null); } + + CapAwardType(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/CounterCountType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/CounterCountType.java index 2ba92fe..918d949 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/CounterCountType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/CounterCountType.java @@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.reward.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 CounterCountType { GROSS_AMOUNT("GA"), NET_AMOUNT("NA"), @@ -29,4 +25,12 @@ public enum CounterCountType { .findFirst() .orElse(null); } + + CounterCountType(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/CounterResetType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/CounterResetType.java index 17416a2..d0868cb 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/CounterResetType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/CounterResetType.java @@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.reward.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 CounterResetType { RESET_TO_REMAINDER("R"), RESET_TO_ZERO("Z"); @@ -26,4 +22,12 @@ public enum CounterResetType { .findFirst() .orElse(null); } + + CounterResetType(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/CounterUpdateType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/CounterUpdateType.java index 05cb7bc..ebad699 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/CounterUpdateType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/CounterUpdateType.java @@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.reward.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 CounterUpdateType { ON_AWARD("A"), ON_EXTRACT("E"), @@ -27,4 +23,12 @@ public enum CounterUpdateType { .findFirst() .orElse(null); } + + CounterUpdateType(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/DeductionPriorityType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/DeductionPriorityType.java index dfcc446..cd520e2 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/DeductionPriorityType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/DeductionPriorityType.java @@ -4,11 +4,7 @@ package dev.sonpx.loyalty.mcp.reward.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 DeductionPriorityType { START_DATE("STA"), EXPIRY_DATE("EXP"), @@ -24,4 +20,12 @@ public enum DeductionPriorityType { .findFirst() .orElse(null); } + + DeductionPriorityType(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/EventFrequencyType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/EventFrequencyType.java index ab4b3ee..0157fc3 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/EventFrequencyType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/EventFrequencyType.java @@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.reward.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 EventFrequencyType { ONCE("O"), MONTHLY("M"), @@ -27,4 +23,12 @@ public enum EventFrequencyType { .findFirst() .orElse(null); } + + EventFrequencyType(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/ExtractBlockedCard.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/ExtractBlockedCard.java index 4b02fe9..c5df493 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/ExtractBlockedCard.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/ExtractBlockedCard.java @@ -4,13 +4,9 @@ package dev.sonpx.loyalty.mcp.reward.enums; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; import java.util.Arrays; -import lombok.Getter; -import lombok.RequiredArgsConstructor; // exc_blocked_card -@Getter -@RequiredArgsConstructor public enum ExtractBlockedCard { INCLUDE("I"), EXCLUDE("E"); @@ -25,4 +21,12 @@ public enum ExtractBlockedCard { .findFirst() .orElse(null); } + + ExtractBlockedCard(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/ExtractNoCounter.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/ExtractNoCounter.java index ec94148..3658727 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/ExtractNoCounter.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/ExtractNoCounter.java @@ -4,11 +4,7 @@ package dev.sonpx.loyalty.mcp.reward.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 ExtractNoCounter { INCLUDE("I"), EXCLUDE("E"); @@ -23,4 +19,12 @@ public enum ExtractNoCounter { .findFirst() .orElse(null); } + + ExtractNoCounter(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/LateValuePosting.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/LateValuePosting.java index 31648f1..803759a 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/LateValuePosting.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/LateValuePosting.java @@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.reward.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 LateValuePosting { UPDATE_LATE_VALUE("L"), UPDATE_CURRENT_BUCKET("C"); @@ -26,4 +22,12 @@ public enum LateValuePosting { .findFirst() .orElse(null); } + + LateValuePosting(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/MerchantEntityLevel.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/MerchantEntityLevel.java index c8ab2ad..4851a3d 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/MerchantEntityLevel.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/MerchantEntityLevel.java @@ -5,14 +5,10 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; import java.util.Arrays; import java.util.Locale; -import lombok.Getter; -import lombok.RequiredArgsConstructor; /** * Created by SonPhung on Monday, 20-Jul-2026 */ -@Getter -@RequiredArgsConstructor public enum MerchantEntityLevel { CHAIN("C"), CORPORATION("CO"), @@ -39,4 +35,11 @@ public enum MerchantEntityLevel { default -> null; }; } + MerchantEntityLevel(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/NotificationChannel.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/NotificationChannel.java index d97a598..42a44b3 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/NotificationChannel.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/NotificationChannel.java @@ -4,16 +4,12 @@ package dev.sonpx.loyalty.mcp.reward.enums; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; import java.util.Arrays; -import lombok.Getter; -import lombok.RequiredArgsConstructor; /** * {@link #SMS} * {@link #EMAIL} * {@link #PUSH_NOTIFICATIONS} */ -@Getter -@RequiredArgsConstructor public enum NotificationChannel { SMS("SMS"), EMAIL("EMAIL"), @@ -29,4 +25,12 @@ public enum NotificationChannel { .findFirst() .orElse(null); } + + NotificationChannel(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/PoolType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/PoolType.java index 01fdb1e..79603ec 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/PoolType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/PoolType.java @@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.reward.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 PoolType { POINT("BPT"), CASH_REBATE("CR"), @@ -29,4 +25,12 @@ public enum PoolType { .findFirst() .orElse(null); } + + PoolType(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/PostTransactionUnderPa.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/PostTransactionUnderPa.java index 9017845..f794d41 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/PostTransactionUnderPa.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/PostTransactionUnderPa.java @@ -4,11 +4,7 @@ package dev.sonpx.loyalty.mcp.reward.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 PostTransactionUnderPa { BY_BASED_ON_ATG("ACA"), // PA based on ATG of Pool BY_HIGHEST_SPEND("PHS"), // PA with highest spend in the past month + month to date @@ -25,4 +21,12 @@ public enum PostTransactionUnderPa { .findFirst() .orElse(null); } + + PostTransactionUnderPa(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/PrecisionOfBalance.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/PrecisionOfBalance.java index 227f53f..53d718b 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/PrecisionOfBalance.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/PrecisionOfBalance.java @@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.reward.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 PrecisionOfBalance { ZERO("0"), ONE("1"), @@ -27,4 +23,12 @@ public enum PrecisionOfBalance { .findFirst() .orElse(null); } + + PrecisionOfBalance(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/RepRunSchedule.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/RepRunSchedule.java index 16eddd3..e188316 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/RepRunSchedule.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/RepRunSchedule.java @@ -5,11 +5,7 @@ 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; -@Getter -@RequiredArgsConstructor public enum RepRunSchedule { DAILY("D"), MONTHLY("M"), @@ -33,4 +29,12 @@ public enum RepRunSchedule { return List.of(DAILY, MONTHLY, ANNUALLY, N_DAYS_AFTER_AOD, N_MONTHS_AFTER_AOD, STATEMENT_CYCLE) .contains(schedule); } + + RepRunSchedule(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/RewardLimitResultType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/RewardLimitResultType.java index 76aef67..4801773 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/RewardLimitResultType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/RewardLimitResultType.java @@ -4,15 +4,11 @@ package dev.sonpx.loyalty.mcp.reward.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 Friday, 10-Nov-2023 * al-capping-limit-type */ -@Getter -@RequiredArgsConstructor public enum RewardLimitResultType { TRACKED_IN_COUNTER("C"), PER_CAMPAIGN_RULE("R"); @@ -27,4 +23,12 @@ public enum RewardLimitResultType { .findFirst() .orElse(null); } + + RewardLimitResultType(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/RoundingType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/RoundingType.java index e765899..5754cb2 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/RoundingType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/RoundingType.java @@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.reward.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, 13-Dec-2023 */ -@Getter -@RequiredArgsConstructor public enum RoundingType { ROUND_UP("U"), ROUND_DOWN("D"), @@ -27,4 +23,12 @@ public enum RoundingType { .findFirst() .orElse(null); } + + RoundingType(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/RuleType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/RuleType.java index 319f02d..fe65654 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/RuleType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/RuleType.java @@ -5,14 +5,10 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonPropertyDescription; import com.fasterxml.jackson.annotation.JsonValue; import java.util.Arrays; -import lombok.Getter; -import lombok.RequiredArgsConstructor; /** * Created by SonPhung on Friday, 10-Nov-2023 */ -@Getter -@RequiredArgsConstructor public enum RuleType { @JsonPropertyDescription("Thưởng điểm") AWARD("AWD"), @@ -48,4 +44,11 @@ public enum RuleType { .findFirst() .orElse(null); } + RuleType(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/RunSchedule.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/RunSchedule.java index 68349f1..5fb3245 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/RunSchedule.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/RunSchedule.java @@ -4,11 +4,7 @@ package dev.sonpx.loyalty.mcp.reward.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 RunSchedule { DAILY("D"), MONTHLY("M"), @@ -26,4 +22,12 @@ public enum RunSchedule { .findFirst() .orElse(null); } + + RunSchedule(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/TriggerCampaignRule.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/TriggerCampaignRule.java index 767b8da..c3d39b3 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/TriggerCampaignRule.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/TriggerCampaignRule.java @@ -4,11 +4,7 @@ package dev.sonpx.loyalty.mcp.reward.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 TriggerCampaignRule { AWARD("AWD"), REDEEM("RED"); @@ -23,4 +19,12 @@ public enum TriggerCampaignRule { .findFirst() .orElse(null); } + + TriggerCampaignRule(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/UpdateCounterStateType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/UpdateCounterStateType.java index 7c8c5e8..0d035f6 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/UpdateCounterStateType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/UpdateCounterStateType.java @@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.reward.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 UpdateCounterStateType { NEXT_STATE("NS"), PREVIOUS_STATE("PS"); @@ -26,4 +22,12 @@ public enum UpdateCounterStateType { .findFirst() .orElse(null); } + + UpdateCounterStateType(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/VelocityEntity.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/VelocityEntity.java index c7e1958..9182557 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/VelocityEntity.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/VelocityEntity.java @@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.reward.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 VelocityEntity { CARD("CA"), CUSTOMER("CU"), @@ -27,4 +23,12 @@ public enum VelocityEntity { .findFirst() .orElse(null); } + + VelocityEntity(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/VelocityPeriod.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/VelocityPeriod.java index bcd1e9e..605bfe3 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/VelocityPeriod.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/VelocityPeriod.java @@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.reward.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 VelocityPeriod { PER_QUARTER("Q"), PER_WEEK("W"), @@ -29,4 +25,11 @@ public enum VelocityPeriod { .findFirst() .orElse(null); } + VelocityPeriod(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/VelocityTransactionType.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/VelocityTransactionType.java index 919c20f..d3c45fe 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/VelocityTransactionType.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/VelocityTransactionType.java @@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.reward.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 VelocityTransactionType { AWARD("AWD"), REDEEM("RED"), @@ -27,4 +23,11 @@ public enum VelocityTransactionType { .findFirst() .orElse(null); } + VelocityTransactionType(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/VelocityUnit.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/VelocityUnit.java index c5cb9e9..96cd6e8 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/VelocityUnit.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/enums/VelocityUnit.java @@ -4,14 +4,10 @@ package dev.sonpx.loyalty.mcp.reward.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 VelocityUnit { POOL_UNITS("PU"), TRANSACTIONS("TXN"); @@ -26,4 +22,11 @@ public enum VelocityUnit { .findFirst() .orElse(null); } + VelocityUnit(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CatalogueService.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CatalogueService.java new file mode 100644 index 0000000..47a6d00 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CatalogueService.java @@ -0,0 +1,39 @@ +package dev.sonpx.loyalty.mcp.service; + +import dev.sonpx.loyalty.mcp.catalogue.client.CatalogueClient; +import dev.sonpx.loyalty.mcp.catalogue.criteria.CatalogueCriteria; +import dev.sonpx.loyalty.mcp.catalogue.model.Catalogue; +import dev.sonpx.loyalty.mcp.model.Result; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CatalogueService { + + private final CatalogueClient client; + + public Result> getAll(CatalogueCriteria criteria) { + try { + List list = client.getAll(criteria, PageRequest.of(0, 50)); + return Result.of(list, "List of catalogues"); + } catch (Exception e) { + log.error("Error fetching catalogues", e); + return Result.failure("Failed to fetch catalogues: " + e.getMessage()); + } + } + + public Result findActiveById(String id) { + try { + Catalogue item = client.findActiveById(id); + return Result.of(item, "Catalogue details"); + } catch (Exception e) { + log.error("Error fetching catalogue by id: " + id, e); + return Result.failure("Failed to fetch catalogue: " + e.getMessage()); + } + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CustomerService.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CustomerService.java new file mode 100644 index 0000000..e3fc8e7 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CustomerService.java @@ -0,0 +1,39 @@ +package dev.sonpx.loyalty.mcp.service; + +import dev.sonpx.loyalty.mcp.customer.client.CustomerClient; +import dev.sonpx.loyalty.mcp.customer.criteria.CustomerCriteria; +import dev.sonpx.loyalty.mcp.customer.model.Customer; +import dev.sonpx.loyalty.mcp.model.Result; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CustomerService { + + private final CustomerClient client; + + public Result> getAll(CustomerCriteria criteria) { + try { + List list = client.getAll(criteria, PageRequest.of(0, 50)); + return Result.of(list, "List of customers"); + } catch (Exception e) { + log.error("Error fetching customers", e); + return Result.failure("Failed to fetch customers: " + e.getMessage()); + } + } + + public Result findActiveById(String id) { + try { + Customer item = client.findActiveById(id); + return Result.of(item, "Customer details"); + } catch (Exception e) { + log.error("Error fetching customer by id: " + id, e); + return Result.failure("Failed to fetch customer: " + e.getMessage()); + } + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/TransactionService.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/TransactionService.java new file mode 100644 index 0000000..edf2990 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/TransactionService.java @@ -0,0 +1,39 @@ +package dev.sonpx.loyalty.mcp.service; + +import dev.sonpx.loyalty.mcp.transaction.client.TransactionClient; +import dev.sonpx.loyalty.mcp.transaction.criteria.TransactionCriteria; +import dev.sonpx.loyalty.mcp.transaction.model.Transaction; +import dev.sonpx.loyalty.mcp.model.Result; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TransactionService { + + private final TransactionClient client; + + public Result> getAll(TransactionCriteria criteria) { + try { + List list = client.getAll(criteria, PageRequest.of(0, 50)); + return Result.of(list, "List of transactions"); + } catch (Exception e) { + log.error("Error fetching transactions", e); + return Result.failure("Failed to fetch transactions: " + e.getMessage()); + } + } + + public Result findActiveById(String id) { + try { + Transaction item = client.findActiveById(id); + return Result.of(item, "Transaction details"); + } catch (Exception e) { + log.error("Error fetching transaction by id: " + id, e); + return Result.failure("Failed to fetch transaction: " + e.getMessage()); + } + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/AppParamTools.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/AppParamTools.java new file mode 100644 index 0000000..f8870fb --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/AppParamTools.java @@ -0,0 +1,33 @@ +package dev.sonpx.loyalty.mcp.tool; + +import dev.sonpx.loyalty.mcp.model.Result; +import dev.sonpx.loyalty.mcp.master.model.AppParamDto; +import dev.sonpx.loyalty.mcp.master.model.AppParamCriteria; +import dev.sonpx.loyalty.mcp.master.service.AppParamService; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class AppParamTools { + + private final AppParamService service; + + @McpTool(description = "Tìm kiếm tham số hệ thống (AppParam) trong hệ thống Loyalty. Để search = null nếu lấy tất cả.") + public Result> searchAppParam(String search) { + AppParamCriteria criteria = new AppParamCriteria(); + criteria.setSearch(search); + return service.search(criteria); + } + + @McpTool(description = "Lấy chi tiết MỘT tham số hệ thống (AppParam) theo ID.") + public Result getAppParamById(String id) { + if (id == null || id.isBlank()) { + return Result.failure("AppParam ID is required."); + } + return service.getById(id.trim()); + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignRuleTools.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignRuleTools.java index dacb755..33f3581 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignRuleTools.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignRuleTools.java @@ -1,16 +1,26 @@ package dev.sonpx.loyalty.mcp.tool; +import dev.sonpx.loyalty.mcp.mapper.McpDtoMapper; import dev.sonpx.loyalty.mcp.model.OperationResult; import dev.sonpx.loyalty.mcp.model.Result; import dev.sonpx.loyalty.mcp.reward.criteria.CampaignRuleCriteria; import dev.sonpx.loyalty.mcp.reward.dto.CampaignRuleSummaryDto; import dev.sonpx.loyalty.mcp.reward.model.CampaignRule; import dev.sonpx.loyalty.mcp.service.CampaignRuleService; +import dev.sonpx.loyalty.mcp.service.CampaignService; +import dev.sonpx.loyalty.mcp.tool.request.CreateCampaignRuleRequest; +import dev.sonpx.loyalty.mcp.tool.response.CampaignRuleResponse; import dev.sonpx.loyalty.mcp.util.SearchUtils; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validator; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.lang.Nullable; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientResponseException; /** * Created by SonPhung on Monday, 20-Jul-2026 @@ -20,6 +30,9 @@ import org.springframework.stereotype.Component; public class CampaignRuleTools { private final CampaignRuleService campaignRuleService; + private final CampaignService campaignService; + private final McpDtoMapper mapper; + private final Validator validator; @McpTool(description = """ TÌM KIẾM THỂ LỆ / RULE của chiến dịch. Trả về: tên rule, loại rule, chiến dịch gốc, công thức thưởng. @@ -27,7 +40,7 @@ public class CampaignRuleTools { - QUAN TRỌNG: Khi người dùng hỏi lấy danh sách chung ("danh sách rule", "xem thể lệ"), KHÔNG truyền điều kiện nào. - KHÔNG dùng tool này khi người dùng chỉ hỏi về thông tin cơ bản chiến dịch (tên, owner, loại) → hãy dùng tool searchCampaigns. Hiển thị tối đa 10 bản ghi.""") - public Result> searchRules(dev.sonpx.loyalty.mcp.tool.request.CampaignRuleSearchRequest request) { + public Result> searchRules(@Nullable dev.sonpx.loyalty.mcp.tool.request.CampaignRuleSearchRequest request) { CampaignRuleCriteria criteria = new CampaignRuleCriteria(); if (request != null) { if (request.campaignId() != null) { @@ -80,20 +93,37 @@ public class CampaignRuleTools { @McpTool(description = "Đếm số lượng RULE/THỂ LỆ, không phải chiến dịch. Để search = null nếu đếm tất cả.") - public Result countRules(String search) { + public Result countRules(@Nullable String search) { CampaignRuleCriteria criteria = new CampaignRuleCriteria(); criteria.setSearch(SearchUtils.sanitizeSearch(search)); return campaignRuleService.count(criteria); } @McpTool(description = "Lấy chi tiết MỘT RULE/THỂ LỆ theo ID. Bao gồm: công thức thưởng, giới hạn, lịch trình.") - public Result getRuleById(String id) { - return campaignRuleService.findActiveById(id); + public Result getRuleById(String id) { + if (id == null || id.isBlank()) { + return Result.failure("Rule ID is required."); + } + Result res = campaignRuleService.findActiveById(id.trim()); + if (res.getData() != null) { + return Result.of(mapper.toCampaignRuleResponse(res.getData()), res.getAgentInstruction()); + } + return Result.of(null, res.getAgentInstruction()); } @McpTool(description = "Lấy thông tin NHIỀU RULE/THỂ LỆ theo danh sách ID, phân tách bằng dấu phẩy.") - public Result> getRulesByIds(String ids) { - return campaignRuleService.findActiveByIds(ids); + public Result> getRulesByIds(String ids) { + if (ids == null || ids.isBlank()) { + return Result.failure("Rule IDs are required (comma-separated)."); + } + Result> res = campaignRuleService.findActiveByIds(ids.trim()); + if (res.getData() != null) { + List dtoList = res.getData().stream() + .map(mapper::toCampaignRuleResponse) + .collect(Collectors.toList()); + return Result.of(dtoList, res.getAgentInstruction()); + } + return Result.of(null, res.getAgentInstruction()); } @McpTool(description = "Tạo một Mà RULE mới (rule ID), không phải mã chiến dịch.") @@ -103,16 +133,95 @@ public class CampaignRuleTools { @McpTool(description = "Kiểm tra tính hợp lệ của một Mà RULE (rule ID).") public Result checkRuleId(String id) { - return campaignRuleService.checkId(id); + if (id == null || id.isBlank()) { + return Result.failure("Rule ID is required."); + } + return campaignRuleService.checkId(id.trim()); } @McpTool(description = "Tạo một RULE/THỂ LỆ mới cho chiến dịch. Cần có campaignId để liên kết.") - public Result> createRule(CampaignRule dto) { - return campaignRuleService.create(dto); + public Result> createRule(CreateCampaignRuleRequest dto) { + if (dto == null) { + return Result.failure("Rule data is required."); + } + + Set> violations = validator.validate(dto); + if (!violations.isEmpty()) { + String errorMsg = violations.stream() + .map(v -> v.getPropertyPath() + " " + v.getMessage()) + .collect(Collectors.joining(", ")); + return Result.failure("Lỗi xác thực dữ liệu đầu vào: " + errorMsg); + } + + try { + // Idempotency guard: check if rule with this ID already exists + // Prevents duplicate creation when LLM retries on timeout + if (dto.getRuleId() != null) { + try { + CampaignRule existing = campaignRuleService.findActiveById(dto.getRuleId().trim()).getData(); + if (existing != null) { + return Result.failure("Rule ID '" + dto.getRuleId() + "' đã tồn tại. Vui lòng dùng mã khác hoặc gọi generateRuleId để tạo mã mới."); + } + } catch (Exception ignored) { + // 404 or other error means ID doesn't exist yet — safe to proceed + } + } + + // Campaign existence validation: ensure the referenced campaign exists + if (dto.getCampaignId() != null) { + try { + var campaignResult = campaignService.findActiveById(dto.getCampaignId().trim()); + if (campaignResult.getData() == null) { + return Result.failure("Campaign ID '" + dto.getCampaignId() + "' không tồn tại. Vui lòng kiểm tra lại mã chiến dịch."); + } + } catch (Exception e) { + return Result.failure("Campaign ID '" + dto.getCampaignId() + "' không tồn tại hoặc không thể truy cập. Vui lòng kiểm tra lại mã chiến dịch."); + } + } + + CampaignRule entity = mapper.toEntity(dto); + Result> res = campaignRuleService.create(entity); + if (res.getData() != null) { + OperationResult opRes = res.getData(); + OperationResult mappedOpRes = OperationResult.builder() + .errorCode(opRes.getErrorCode()) + .message(opRes.getMessage()) + .entity(opRes.getEntity() != null ? mapper.toCampaignRuleResponse(opRes.getEntity()) : null) + .build(); + return Result.of(mappedOpRes, res.getAgentInstruction()); + } + return Result.of(null, res.getAgentInstruction()); + } catch (RestClientResponseException e) { + return Result.failure("Lỗi từ Core API khi tạo rule (HTTP " + e.getStatusCode() + "): " + e.getResponseBodyAsString()); + } catch (Exception e) { + return Result.failure("Lỗi từ hệ thống khi tạo rule: " + e.getMessage()); + } } @McpTool(description = "Tìm kiếm các rule/thể lệ khớp với một giao dịch cụ thể. Yêu cầu truyền vào loại rule, mã giao dịch (transaction code), ngày giao dịch, ngày post và ngôn ngữ (ví dụ: 'vi' hoặc 'en'). Ngày tháng format yyyy-MM-dd'T'HH:mm:ss") - public Result> findMatchingRules(String ruleType, String transactionCode, String transactionDate, String postDate, String languageCode) { - return campaignRuleService.findMatchingRules(ruleType, transactionCode, transactionDate, postDate, languageCode); + public Result> findMatchingRules(String ruleType, String transactionCode, String transactionDate, String postDate, String languageCode) { + if (ruleType == null || ruleType.isBlank()) { + return Result.failure("Rule type is required."); + } + if (transactionCode == null || transactionCode.isBlank()) { + return Result.failure("Transaction code is required."); + } + if (transactionDate == null || transactionDate.isBlank()) { + return Result.failure("Transaction date is required."); + } + if (postDate == null || postDate.isBlank()) { + return Result.failure("Post date is required."); + } + if (languageCode == null || languageCode.isBlank()) { + return Result.failure("Language code is required (e.g. 'vi' or 'en')."); + } + Result> res = campaignRuleService.findMatchingRules(ruleType.trim(), transactionCode.trim(), transactionDate.trim(), postDate.trim(), languageCode.trim()); + if (res.getData() != null) { + List dtoList = res.getData().stream() + .map(mapper::toCampaignRuleResponse) + .collect(Collectors.toList()); + return Result.of(dtoList, res.getAgentInstruction()); + } + return Result.of(null, res.getAgentInstruction()); } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignTools.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignTools.java index 28cd45d..5448e35 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignTools.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignTools.java @@ -1,16 +1,26 @@ package dev.sonpx.loyalty.mcp.tool; +import dev.sonpx.loyalty.mcp.mapper.McpDtoMapper; import dev.sonpx.loyalty.mcp.model.OperationResult; import dev.sonpx.loyalty.mcp.model.Result; import dev.sonpx.loyalty.mcp.reward.criteria.CampaignCriteria; import dev.sonpx.loyalty.mcp.reward.dto.CampaignSummaryDto; import dev.sonpx.loyalty.mcp.reward.model.Campaign; import dev.sonpx.loyalty.mcp.service.CampaignService; +import dev.sonpx.loyalty.mcp.tool.request.CreateCampaignRequest; +import dev.sonpx.loyalty.mcp.tool.response.CampaignResponse; import dev.sonpx.loyalty.mcp.util.SearchUtils; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validator; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.lang.Nullable; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestClientResponseException; /** * Created by SonPhung on Monday, 20-Jul-2026 @@ -20,13 +30,15 @@ import org.springframework.stereotype.Component; public class CampaignTools { private final CampaignService campaignService; + private final McpDtoMapper mapper; + private final Validator validator; @McpTool(description = """ TÌM KIẾM CHIẾN DỊCH (Campaign). Trả về thông tin CƠ BẢN của chiến dịch: tên, mã, owner, loại, thời gian. - Param `request`: đối tượng chứa các điều kiện lọc (có thể để null nếu không cần lọc). - QUAN TRỌNG: Khi người dùng hỏi lấy danh sách chung ("danh sách chiến dịch", "xem các chiến dịch"), KHÔNG truyền filter nào. - KHÔNG dùng tool này khi người dùng hỏi về: rule, thể lệ, quy tắc, điều kiện, công thức thưởng → hãy dùng tool searchRules.""") - public Result> searchCampaigns(dev.sonpx.loyalty.mcp.tool.request.CampaignSearchRequest request) { + public Result> searchCampaigns(@Nullable dev.sonpx.loyalty.mcp.tool.request.CampaignSearchRequest request) { CampaignCriteria criteria = new CampaignCriteria(); if (request != null) { if (request.campaignId() != null) { @@ -60,20 +72,37 @@ public class CampaignTools { @McpTool(description = "Đếm số lượng CHIẾN DỊCH (Campaign), không phải rule. Để search = null nếu đếm tất cả.") - public Result countCampaigns(String search) { + public Result countCampaigns(@Nullable String search) { CampaignCriteria criteria = new CampaignCriteria(); criteria.setSearch(SearchUtils.sanitizeSearch(search)); return campaignService.count(criteria); } @McpTool(description = "Lấy chi tiết MỘT CHIẾN DỊCH theo ID. Trả về thông tin cơ bản của chiến dịch, KHÔNG bao gồm rule/thể lệ.") - public Result getCampaignById(String id) { - return campaignService.findActiveById(id); + public Result getCampaignById(String id) { + if (id == null || id.isBlank()) { + return Result.failure("Campaign ID is required."); + } + Result res = campaignService.findActiveById(id.trim()); + if (res.getData() != null) { + return Result.of(mapper.toCampaignResponse(res.getData()), res.getAgentInstruction()); + } + return Result.of(null, res.getAgentInstruction()); } @McpTool(description = "Lấy thông tin NHIỀU CHIẾN DỊCH theo danh sách ID, phân tách bằng dấu phẩy. Không bao gồm rule.") - public Result> getCampaignsByIds(String ids) { - return campaignService.findActiveByIds(ids); + public Result> getCampaignsByIds(String ids) { + if (ids == null || ids.isBlank()) { + return Result.failure("Campaign IDs are required (comma-separated)."); + } + Result> res = campaignService.findActiveByIds(ids.trim()); + if (res.getData() != null) { + List dtoList = res.getData().stream() + .map(mapper::toCampaignResponse) + .collect(Collectors.toList()); + return Result.of(dtoList, res.getAgentInstruction()); + } + return Result.of(null, res.getAgentInstruction()); } @McpTool(description = "Tạo một Mà CHIẾN DỊCH mới (campaign ID), không phải mã rule.") @@ -83,11 +112,56 @@ public class CampaignTools { @McpTool(description = "Kiểm tra tính hợp lệ của một Mà CHIẾN DỊCH (campaign ID).") public Result checkCampaignId(String id) { - return campaignService.checkId(id); + if (id == null || id.isBlank()) { + return Result.failure("Campaign ID is required."); + } + return campaignService.checkId(id.trim()); } @McpTool(description = "Tạo một CHIẾN DỊCH mới (Campaign). Sau khi tạo xong, cần thêm rule/thể lệ bằng tool createRule.") - public Result> createCampaign(Campaign dto) { - return campaignService.create(dto); + public Result> createCampaign(CreateCampaignRequest dto) { + if (dto == null) { + return Result.failure("Campaign data is required."); + } + + Set> violations = validator.validate(dto); + if (!violations.isEmpty()) { + String errorMsg = violations.stream() + .map(v -> v.getPropertyPath() + " " + v.getMessage()) + .collect(Collectors.joining(", ")); + return Result.failure("Lỗi xác thực dữ liệu đầu vào: " + errorMsg); + } + + try { + // Idempotency guard: check if campaign with this ID already exists + // Prevents duplicate creation when LLM retries on timeout + if (dto.getCampaignId() != null) { + try { + Campaign existing = campaignService.findActiveById(dto.getCampaignId().trim()).getData(); + if (existing != null) { + return Result.failure("Campaign ID '" + dto.getCampaignId() + "' đã tồn tại. Vui lòng dùng mã khác hoặc gọi generateCampaignId để tạo mã mới."); + } + } catch (Exception ignored) { + // 404 or other error means ID doesn't exist yet — safe to proceed + } + } + + Campaign entity = mapper.toEntity(dto); + Result> res = campaignService.create(entity); + if (res.getData() != null) { + OperationResult opRes = res.getData(); + OperationResult mappedOpRes = OperationResult.builder() + .errorCode(opRes.getErrorCode()) + .message(opRes.getMessage()) + .entity(opRes.getEntity() != null ? mapper.toCampaignResponse(opRes.getEntity()) : null) + .build(); + return Result.of(mappedOpRes, res.getAgentInstruction()); + } + return Result.of(null, res.getAgentInstruction()); + } catch (RestClientResponseException e) { + return Result.failure("Lỗi từ Core API khi tạo chiến dịch (HTTP " + e.getStatusCode() + "): " + e.getResponseBodyAsString()); + } catch (Exception e) { + return Result.failure("Lỗi từ hệ thống khi tạo chiến dịch: " + e.getMessage()); + } } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CatalogueTools.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CatalogueTools.java new file mode 100644 index 0000000..1bbcf78 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CatalogueTools.java @@ -0,0 +1,34 @@ +package dev.sonpx.loyalty.mcp.tool; + +import dev.sonpx.loyalty.mcp.model.Result; +import dev.sonpx.loyalty.mcp.catalogue.criteria.CatalogueCriteria; +import dev.sonpx.loyalty.mcp.catalogue.model.Catalogue; +import dev.sonpx.loyalty.mcp.service.CatalogueService; +import dev.sonpx.loyalty.mcp.util.SearchUtils; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CatalogueTools { + + private final CatalogueService service; + + @McpTool(description = "TÌM KIẾM danh sách catalogue. Có thể truyền search term hoặc null.") + public Result> searchCatalogues(@Nullable String search) { + CatalogueCriteria criteria = new CatalogueCriteria(); + criteria.setSearch(SearchUtils.sanitizeSearch(search)); + return service.getAll(criteria); + } + + @McpTool(description = "Lấy chi tiết catalogue theo ID.") + public Result getCatalogueById(String id) { + if (id == null || id.isBlank()) { + return Result.failure("ID is required."); + } + return service.findActiveById(id.trim()); + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CounterDefinitionTools.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CounterDefinitionTools.java index ad12bd4..75620b0 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CounterDefinitionTools.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CounterDefinitionTools.java @@ -3,6 +3,8 @@ package dev.sonpx.loyalty.mcp.tool; import dev.sonpx.loyalty.mcp.model.Result; import dev.sonpx.loyalty.mcp.reward.model.CounterDefinition; import dev.sonpx.loyalty.mcp.service.CounterDefinitionService; +import dev.sonpx.loyalty.mcp.mapper.McpDtoMapper; +import dev.sonpx.loyalty.mcp.tool.response.CounterDefinitionResponse; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.ai.mcp.annotation.McpTool; @@ -13,14 +15,27 @@ import org.springframework.stereotype.Component; public class CounterDefinitionTools { private final CounterDefinitionService counterDefinitionService; + private final McpDtoMapper mcpDtoMapper; @McpTool(description = "Lấy thông tin chi tiết một Counter theo ID.") - public Result getCounterDefinitionById(String counterId) { - return counterDefinitionService.findById(counterId); + public Result getCounterDefinitionById(String counterId) { + if (counterId == null || counterId.isBlank()) { + return Result.failure("Counter ID is required."); + } + Result result = counterDefinitionService.findById(counterId.trim()); + Result response = Result.of(mcpDtoMapper.toDto(result.getData())); + response.instruction(result.getAgentInstruction()); + return response; } @McpTool(description = "Lấy thông tin nhiều Counter theo danh sách ID, phân tách bằng dấu phẩy.") - public Result> getCounterDefinitionsByIds(String ids) { - return counterDefinitionService.findActiveByIds(ids); + public Result> getCounterDefinitionsByIds(String ids) { + if (ids == null || ids.isBlank()) { + return Result.failure("Counter IDs are required (comma-separated)."); + } + Result> result = counterDefinitionService.findActiveByIds(ids.trim()); + Result> response = Result.of(mcpDtoMapper.toCounterDefinitionDtoList(result.getData())); + response.instruction(result.getAgentInstruction()); + return response; } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CustomerTools.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CustomerTools.java new file mode 100644 index 0000000..6995c31 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CustomerTools.java @@ -0,0 +1,34 @@ +package dev.sonpx.loyalty.mcp.tool; + +import dev.sonpx.loyalty.mcp.model.Result; +import dev.sonpx.loyalty.mcp.customer.criteria.CustomerCriteria; +import dev.sonpx.loyalty.mcp.customer.model.Customer; +import dev.sonpx.loyalty.mcp.service.CustomerService; +import dev.sonpx.loyalty.mcp.util.SearchUtils; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CustomerTools { + + private final CustomerService service; + + @McpTool(description = "TÌM KIẾM danh sách customer. Có thể truyền search term hoặc null.") + public Result> searchCustomers(@Nullable String search) { + CustomerCriteria criteria = new CustomerCriteria(); + criteria.setSearch(SearchUtils.sanitizeSearch(search)); + return service.getAll(criteria); + } + + @McpTool(description = "Lấy chi tiết customer theo ID.") + public Result getCustomerById(String id) { + if (id == null || id.isBlank()) { + return Result.failure("ID is required."); + } + return service.findActiveById(id.trim()); + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/DeductionSequenceTools.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/DeductionSequenceTools.java index a94c6ce..ffa8f86 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/DeductionSequenceTools.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/DeductionSequenceTools.java @@ -3,6 +3,8 @@ package dev.sonpx.loyalty.mcp.tool; import dev.sonpx.loyalty.mcp.model.Result; import dev.sonpx.loyalty.mcp.reward.model.DeductionSequences; import dev.sonpx.loyalty.mcp.service.DeductionSequenceService; +import dev.sonpx.loyalty.mcp.mapper.McpDtoMapper; +import dev.sonpx.loyalty.mcp.tool.response.DeductionSequenceResponse; import lombok.RequiredArgsConstructor; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.stereotype.Component; @@ -12,14 +14,24 @@ import org.springframework.stereotype.Component; public class DeductionSequenceTools { private final DeductionSequenceService deductionSequenceService; + private final McpDtoMapper mcpDtoMapper; @McpTool(description = "Lấy thông tin cấu hình trừ điểm (Deduction Sequence) theo ID.") - public Result getDeductionSequenceById(String deductionSeqId) { - return deductionSequenceService.findById(deductionSeqId); + public Result getDeductionSequenceById(String deductionSeqId) { + if (deductionSeqId == null || deductionSeqId.isBlank()) { + return Result.failure("Deduction Sequence ID is required."); + } + Result result = deductionSequenceService.findById(deductionSeqId.trim()); + Result response = Result.of(mcpDtoMapper.toDto(result.getData())); + response.instruction(result.getAgentInstruction()); + return response; } @McpTool(description = "Lấy thông tin cấu hình trừ điểm (Deduction Sequence) mặc định của hệ thống.") - public Result getDefaultDeductionSequence() { - return deductionSequenceService.findDefault(); + public Result getDefaultDeductionSequence() { + Result result = deductionSequenceService.findDefault(); + Result response = Result.of(mcpDtoMapper.toDto(result.getData())); + response.instruction(result.getAgentInstruction()); + return response; } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/DynamicAttributeTools.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/DynamicAttributeTools.java new file mode 100644 index 0000000..a1b615d --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/DynamicAttributeTools.java @@ -0,0 +1,33 @@ +package dev.sonpx.loyalty.mcp.tool; + +import dev.sonpx.loyalty.mcp.model.Result; +import dev.sonpx.loyalty.mcp.attribute.model.DynamicAttributeDto; +import dev.sonpx.loyalty.mcp.attribute.model.DynamicAttributeCriteria; +import dev.sonpx.loyalty.mcp.attribute.service.DynamicAttributeService; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class DynamicAttributeTools { + + private final DynamicAttributeService service; + + @McpTool(description = "Tìm kiếm thuộc tính động (DynamicAttribute) trong hệ thống Loyalty. Để search = null nếu lấy tất cả.") + public Result> searchDynamicAttribute(String search) { + DynamicAttributeCriteria criteria = new DynamicAttributeCriteria(); + criteria.setSearch(search); + return service.search(criteria); + } + + @McpTool(description = "Lấy chi tiết MỘT thuộc tính động (DynamicAttribute) theo ID.") + public Result getDynamicAttributeById(String id) { + if (id == null || id.isBlank()) { + return Result.failure("DynamicAttribute ID is required."); + } + return service.getById(id.trim()); + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/JourneyTools.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/JourneyTools.java new file mode 100644 index 0000000..36ceb78 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/JourneyTools.java @@ -0,0 +1,33 @@ +package dev.sonpx.loyalty.mcp.tool; + +import dev.sonpx.loyalty.mcp.model.Result; +import dev.sonpx.loyalty.mcp.marketing.model.JourneyDto; +import dev.sonpx.loyalty.mcp.marketing.model.JourneyCriteria; +import dev.sonpx.loyalty.mcp.marketing.service.JourneyService; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class JourneyTools { + + private final JourneyService service; + + @McpTool(description = "Tìm kiếm hành trình khách hàng (Journey) trong hệ thống Loyalty. Để search = null nếu lấy tất cả.") + public Result> searchJourney(String search) { + JourneyCriteria criteria = new JourneyCriteria(); + criteria.setSearch(search); + return service.search(criteria); + } + + @McpTool(description = "Lấy chi tiết MỘT hành trình khách hàng (Journey) theo ID.") + public Result getJourneyById(String id) { + if (id == null || id.isBlank()) { + return Result.failure("Journey ID is required."); + } + return service.getById(id.trim()); + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/PoolDefinitionTools.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/PoolDefinitionTools.java index 03275cb..3e4301b 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/PoolDefinitionTools.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/PoolDefinitionTools.java @@ -1,10 +1,14 @@ package dev.sonpx.loyalty.mcp.tool; +import dev.sonpx.loyalty.mcp.mapper.McpDtoMapper; import dev.sonpx.loyalty.mcp.model.Result; import dev.sonpx.loyalty.mcp.reward.model.PoolDefinition; import dev.sonpx.loyalty.mcp.service.PoolDefinitionService; +import dev.sonpx.loyalty.mcp.tool.response.PoolDefinitionResponse; import java.util.List; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.lang.Nullable; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.stereotype.Component; @@ -13,19 +17,37 @@ import org.springframework.stereotype.Component; public class PoolDefinitionTools { private final PoolDefinitionService poolDefinitionService; + private final McpDtoMapper mapper; @McpTool(description = "Lấy thông tin chi tiết một Point Pool theo ID.") - public Result getPoolDefinitionById(String poolId) { - return poolDefinitionService.findById(poolId); + public Result getPoolDefinitionById(String poolId) { + if (poolId == null || poolId.isBlank()) { + return Result.failure("Pool ID is required."); + } + Result res = poolDefinitionService.findById(poolId.trim()); + if (res.getData() != null) { + return Result.of(mapper.toPoolDefinitionResponse(res.getData()), res.getAgentInstruction()); + } + return Result.of(null, res.getAgentInstruction()); } @McpTool(description = "Lấy thông tin nhiều Point Pool theo danh sách ID, phân tách bằng dấu phẩy.") - public Result> getPoolDefinitionsByIds(String ids) { - return poolDefinitionService.findActiveByIds(ids); + public Result> getPoolDefinitionsByIds(String ids) { + if (ids == null || ids.isBlank()) { + return Result.failure("Pool IDs are required (comma-separated)."); + } + Result> res = poolDefinitionService.findActiveByIds(ids.trim()); + if (res.getData() != null) { + List dtoList = res.getData().stream() + .map(mapper::toPoolDefinitionResponse) + .collect(Collectors.toList()); + return Result.of(dtoList, res.getAgentInstruction()); + } + return Result.of(null, res.getAgentInstruction()); } @McpTool(description = "TÌM KIẾM Point Pool. Trả về thông tin của pool. Param `request`: đối tượng chứa các điều kiện lọc (có thể để null nếu không cần lọc).") - public Result> searchPools(dev.sonpx.loyalty.mcp.tool.request.PoolDefinitionSearchRequest request) { + public Result> searchPools(@Nullable dev.sonpx.loyalty.mcp.tool.request.PoolDefinitionSearchRequest request) { dev.sonpx.loyalty.mcp.reward.criteria.PoolDefinitionCriteria criteria = new dev.sonpx.loyalty.mcp.reward.criteria.PoolDefinitionCriteria(); if (request != null) { if (request.poolId() != null) { @@ -59,6 +81,13 @@ public class PoolDefinitionTools { criteria.setAtgGroupId(filter); } } - return poolDefinitionService.getAll(criteria); + Result> res = poolDefinitionService.getAll(criteria); + if (res.getData() != null) { + List dtoList = res.getData().stream() + .map(mapper::toPoolDefinitionResponse) + .collect(Collectors.toList()); + return Result.of(dtoList, res.getAgentInstruction()); + } + return Result.of(null, res.getAgentInstruction()); } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/TransactionCodeTools.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/TransactionCodeTools.java index 25e89a3..4a74c82 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/TransactionCodeTools.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/TransactionCodeTools.java @@ -3,6 +3,8 @@ package dev.sonpx.loyalty.mcp.tool; import dev.sonpx.loyalty.mcp.model.Result; import dev.sonpx.loyalty.mcp.reward.model.TransactionCode; import dev.sonpx.loyalty.mcp.service.TransactionCodeService; +import dev.sonpx.loyalty.mcp.mapper.McpDtoMapper; +import dev.sonpx.loyalty.mcp.tool.response.TransactionCodeResponse; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.ai.mcp.annotation.McpTool; @@ -13,19 +15,35 @@ import org.springframework.stereotype.Component; public class TransactionCodeTools { private final TransactionCodeService transactionCodeService; + private final McpDtoMapper mcpDtoMapper; @McpTool(description = "Lấy thông tin chi tiết một Mã giao dịch (Transaction Code) theo mã.") - public Result getTransactionCode(String transactionCode) { - return transactionCodeService.findByCode(transactionCode); + public Result getTransactionCode(String transactionCode) { + if (transactionCode == null || transactionCode.isBlank()) { + return Result.failure("Transaction code is required."); + } + Result result = transactionCodeService.findByCode(transactionCode.trim()); + Result response = Result.of(mcpDtoMapper.toDto(result.getData())); + response.instruction(result.getAgentInstruction()); + return response; } @McpTool(description = "Lấy thông tin nhiều Mã giao dịch theo danh sách mã, phân tách bằng dấu phẩy.") - public Result> getTransactionCodes(String transactionCodes) { - return transactionCodeService.findByCodes(transactionCodes); + public Result> getTransactionCodes(String transactionCodes) { + if (transactionCodes == null || transactionCodes.isBlank()) { + return Result.failure("Transaction codes are required (comma-separated)."); + } + Result> result = transactionCodeService.findByCodes(transactionCodes.trim()); + Result> response = Result.of(mcpDtoMapper.toTransactionCodeDtoList(result.getData())); + response.instruction(result.getAgentInstruction()); + return response; } @McpTool(description = "Kiểm tra xem một Mã giao dịch có tồn tại hay không.") public Result checkTransactionCodeExists(String transactionCode) { - return transactionCodeService.exists(transactionCode); + if (transactionCode == null || transactionCode.isBlank()) { + return Result.failure("Transaction code is required."); + } + return transactionCodeService.exists(transactionCode.trim()); } } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/TransactionTools.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/TransactionTools.java new file mode 100644 index 0000000..8e0a4e0 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/TransactionTools.java @@ -0,0 +1,34 @@ +package dev.sonpx.loyalty.mcp.tool; + +import dev.sonpx.loyalty.mcp.model.Result; +import dev.sonpx.loyalty.mcp.transaction.criteria.TransactionCriteria; +import dev.sonpx.loyalty.mcp.transaction.model.Transaction; +import dev.sonpx.loyalty.mcp.service.TransactionService; +import dev.sonpx.loyalty.mcp.util.SearchUtils; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class TransactionTools { + + private final TransactionService service; + + @McpTool(description = "TÌM KIẾM danh sách transaction. Có thể truyền search term hoặc null.") + public Result> searchTransactions(@Nullable String search) { + TransactionCriteria criteria = new TransactionCriteria(); + criteria.setSearch(SearchUtils.sanitizeSearch(search)); + return service.getAll(criteria); + } + + @McpTool(description = "Lấy chi tiết transaction theo ID.") + public Result getTransactionById(String id) { + if (id == null || id.isBlank()) { + return Result.failure("ID is required."); + } + return service.findActiveById(id.trim()); + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/UserTools.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/UserTools.java new file mode 100644 index 0000000..95d9019 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/UserTools.java @@ -0,0 +1,33 @@ +package dev.sonpx.loyalty.mcp.tool; + +import dev.sonpx.loyalty.mcp.model.Result; +import dev.sonpx.loyalty.mcp.identity.model.UserResponseDto; +import dev.sonpx.loyalty.mcp.identity.model.UserCriteria; +import dev.sonpx.loyalty.mcp.identity.service.UserService; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class UserTools { + + private final UserService service; + + @McpTool(description = "Tìm kiếm người dùng (User) trong hệ thống Loyalty. Để search = null nếu lấy tất cả.") + public Result> searchUser(String search) { + UserCriteria criteria = new UserCriteria(); + criteria.setSearch(search); + return service.search(criteria); + } + + @McpTool(description = "Lấy chi tiết MỘT người dùng (User) theo ID.") + public Result getUserById(String id) { + if (id == null || id.isBlank()) { + return Result.failure("User ID is required."); + } + return service.getById(id.trim()); + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/request/CampaignRuleSearchRequest.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/request/CampaignRuleSearchRequest.java index 4ea17d5..23f3d9b 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/request/CampaignRuleSearchRequest.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/request/CampaignRuleSearchRequest.java @@ -1,29 +1,38 @@ package dev.sonpx.loyalty.mcp.tool.request; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyDescription; public record CampaignRuleSearchRequest( + @JsonProperty(required = false) @JsonPropertyDescription("Search by Campaign ID (exact match). Leave null if not filtering by Campaign ID.") String campaignId, + @JsonProperty(required = false) @JsonPropertyDescription("Search by Rule ID (exact match). Leave null if not filtering by Rule ID.") String ruleId, + @JsonProperty(required = false) @JsonPropertyDescription("Search by Rule Name (partial or exact match). Leave null if not filtering by Rule Name.") String ruleName, + @JsonProperty(required = false) @JsonPropertyDescription("Search by Rule Description (partial or exact match). Leave null if not filtering by Description.") String description, + @JsonProperty(required = false) @JsonPropertyDescription("Filter rules effective from this date/time (ISO-8601 format, e.g., '2026-07-27T10:00:00'). Leave null if not filtering by effective from.") String effectiveFrom, + @JsonProperty(required = false) @JsonPropertyDescription("Filter rules effective to this date/time (ISO-8601 format, e.g., '2026-07-27T10:00:00'). Leave null if not filtering by effective to.") String effectiveTo, + @JsonProperty(required = false) @JsonPropertyDescription("Search by Rule Type (exact match). Leave null if not filtering by Rule Type.") String ruleType, + @JsonProperty(required = false) @JsonPropertyDescription("Search by Pool ID (exact match). Leave null if not filtering by Pool ID.") String poolId ) {} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/request/CampaignSearchRequest.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/request/CampaignSearchRequest.java index 0bf9bad..e552a7d 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/request/CampaignSearchRequest.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/request/CampaignSearchRequest.java @@ -1,20 +1,26 @@ package dev.sonpx.loyalty.mcp.tool.request; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyDescription; public record CampaignSearchRequest( + @JsonProperty(required = false) @JsonPropertyDescription("Search by Campaign ID (partial or exact match). Leave null if not filtering by ID.") String campaignId, + @JsonProperty(required = false) @JsonPropertyDescription("Search by Owner Name/Department (partial or exact match). Leave null if not filtering by owner.") String ownerName, + @JsonProperty(required = false) @JsonPropertyDescription("Search by Campaign Name (partial or exact match). Leave null if not filtering by name.") String name, + @JsonProperty(required = false) @JsonPropertyDescription("Search by Campaign Description (partial or exact match). Leave null if not filtering by description.") String description, + @JsonProperty(required = false) @JsonPropertyDescription("Search by Campaign Type (exact match, e.g. POINT, VOUCHER). Leave null if not filtering by type.") String campaignType ) {} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/request/CreateCampaignRequest.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/request/CreateCampaignRequest.java new file mode 100644 index 0000000..287b1ea --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/request/CreateCampaignRequest.java @@ -0,0 +1,50 @@ +package dev.sonpx.loyalty.mcp.tool.request; + +import dev.sonpx.loyalty.mcp.reward.enums.CampaignType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import lombok.Getter; +import lombok.Setter; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; + +@Getter +@Setter +public class CreateCampaignRequest { + + @NotBlank + @Size(min = 1, max = 10) + @JsonPropertyDescription("Mã chiến dịch (tối đa 10 ký tự, ví dụ: 'CMP01')") + private String campaignId; + + @Size(max = 50) + @JsonPropertyDescription("Tên người hoặc phòng ban sở hữu chiến dịch") + private String ownerName; + + @NotBlank + @Size(min = 1, max = 50) + @JsonPropertyDescription("Tên hiển thị của chiến dịch") + private String name; + + @Size(max = 500) + @JsonPropertyDescription("Mô tả chi tiết về chiến dịch") + private String description; + + @JsonPropertyDescription("Loại chiến dịch: BASE (B) - chiến dịch cơ sở, TACTICAL (T) - chiến dịch chiến thuật") + private CampaignType campaignType; + + @JsonPropertyDescription("Số lượng khách hàng mục tiêu dự kiến của chiến dịch") + private Long noOfCustomerTarget; + + @JsonPropertyDescription("Mục tiêu giá trị giao dịch trung bình (Target ATV)") + private Double targetAtv; + + @JsonPropertyDescription("Ngày bắt đầu có hiệu lực của chiến dịch") + private LocalDate effectiveFrom; + + @JsonPropertyDescription("Ngày kết thúc hiệu lực của chiến dịch") + private LocalDate effectiveTo; + + @JsonPropertyDescription("Số lượng quy tắc (rules) cấu hình trong chiến dịch") + private Integer numOfRule; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/request/CreateCampaignRuleRequest.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/request/CreateCampaignRuleRequest.java new file mode 100644 index 0000000..6bd4fa1 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/request/CreateCampaignRuleRequest.java @@ -0,0 +1,67 @@ +package dev.sonpx.loyalty.mcp.tool.request; + +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import dev.sonpx.loyalty.mcp.enums.ExpiryPolicy; +import dev.sonpx.loyalty.mcp.reward.enums.NotificationChannel; +import dev.sonpx.loyalty.mcp.reward.enums.RuleType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; +import java.util.List; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CreateCampaignRuleRequest { + + @NotBlank + @JsonPropertyDescription("Tham chiếu đến Campaign chứa rule này (tối đa 10 ký tự)") + private String campaignId; + + @NotBlank + @Size(min = 1, max = 10) + @JsonPropertyDescription("Mã định danh của rule (tối đa 10 ký tự)") + private String ruleId; + + @NotBlank + @Size(min = 1, max = 100) + @JsonPropertyDescription("Tên của rule") + private String ruleName; + + @NotNull + @JsonPropertyDescription("Loại rule: RED (Đổi điểm), REP (Đổi quà), AWD (Thưởng), ADJ (Điều chỉnh), MAWD (Thưởng Marketing), CEP (Sự kiện)") + private RuleType ruleType; + + @Size(max = 500) + @JsonPropertyDescription("Mô tả chi tiết về rule") + private String description; + + @JsonPropertyDescription("Thời gian bắt đầu có hiệu lực của rule") + private LocalDateTime effectiveFrom; + + @JsonPropertyDescription("Thời gian kết thúc hiệu lực của rule") + private LocalDateTime effectiveTo; + + @JsonPropertyDescription("Tham chiếu đến Pool chính") + private String poolId; + + @JsonPropertyDescription("Mã sản phẩm/quà tặng") + private String itemCode; + + @JsonPropertyDescription("Số lượng/giá trị phần thưởng (sẽ được map vào CampaignFormulaTwo.fixedAmt)") + private Double rewardAmount; + + @JsonPropertyDescription("Kênh gửi thông báo (ví dụ: SMS, EMAIL, NOTIFICATION)") + private NotificationChannel channel; + + @JsonPropertyDescription("Thứ tự ưu tiên áp dụng các Formula") + private List formulaSequence; + + @JsonPropertyDescription("Chính sách hết hạn của điểm/quà") + private ExpiryPolicy expiryPolicy; + + @JsonPropertyDescription("Tham số cho chính sách hết hạn (ví dụ: số tháng)") + private Integer expiryPolicyParam; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/request/PoolDefinitionSearchRequest.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/request/PoolDefinitionSearchRequest.java index 0d85039..97907b5 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/request/PoolDefinitionSearchRequest.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/request/PoolDefinitionSearchRequest.java @@ -1,23 +1,30 @@ package dev.sonpx.loyalty.mcp.tool.request; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyDescription; public record PoolDefinitionSearchRequest( + @JsonProperty(required = false) @JsonPropertyDescription("Search by Pool ID (exact match). Leave null if not filtering by Pool ID.") String poolId, + @JsonProperty(required = false) @JsonPropertyDescription("Search by Pool Name (partial or exact match). Leave null if not filtering by Pool Name.") String poolName, + @JsonProperty(required = false) @JsonPropertyDescription("Search by Pool Type (exact match). Leave null if not filtering by Pool Type.") String poolType, + @JsonProperty(required = false) @JsonPropertyDescription("Search by Entity Level (exact match). Leave null if not filtering by Entity Level.") String entityLevel, + @JsonProperty(required = false) @JsonPropertyDescription("Search by Expiry Policy (exact match). Leave null if not filtering by Expiry Policy.") String expiryPolicy, + @JsonProperty(required = false) @JsonPropertyDescription("Search by ATG Group ID (exact match). Leave null if not filtering by ATG Group ID.") String atgGroupId ) {} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/response/CampaignResponse.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/response/CampaignResponse.java new file mode 100644 index 0000000..14c8f7d --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/response/CampaignResponse.java @@ -0,0 +1,42 @@ +package dev.sonpx.loyalty.mcp.tool.response; + +import dev.sonpx.loyalty.mcp.reward.enums.CampaignType; +import java.time.LocalDate; +import lombok.Getter; +import lombok.Setter; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; + +@Getter +@Setter +public class CampaignResponse { + + @JsonPropertyDescription("Mã chiến dịch") + private String campaignId; + + @JsonPropertyDescription("Tên người hoặc phòng ban sở hữu chiến dịch") + private String ownerName; + + @JsonPropertyDescription("Tên hiển thị của chiến dịch") + private String name; + + @JsonPropertyDescription("Mô tả chi tiết về chiến dịch") + private String description; + + @JsonPropertyDescription("Loại chiến dịch (ví dụ: POINT, VOUCHER)") + private CampaignType campaignType; + + @JsonPropertyDescription("Số lượng khách hàng mục tiêu dự kiến") + private Long noOfCustomerTarget; + + @JsonPropertyDescription("Mục tiêu giá trị giao dịch trung bình (Target ATV)") + private Double targetAtv; + + @JsonPropertyDescription("Ngày bắt đầu có hiệu lực") + private LocalDate effectiveFrom; + + @JsonPropertyDescription("Ngày kết thúc hiệu lực") + private LocalDate effectiveTo; + + @JsonPropertyDescription("Số lượng quy tắc (rules) cấu hình trong chiến dịch") + private Integer numOfRule; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/response/CampaignRuleResponse.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/response/CampaignRuleResponse.java new file mode 100644 index 0000000..420b611 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/response/CampaignRuleResponse.java @@ -0,0 +1,57 @@ +package dev.sonpx.loyalty.mcp.tool.response; + +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import dev.sonpx.loyalty.mcp.enums.ExpiryPolicy; +import dev.sonpx.loyalty.mcp.reward.enums.NotificationChannel; +import dev.sonpx.loyalty.mcp.reward.enums.RuleType; +import java.time.LocalDateTime; +import java.util.List; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CampaignRuleResponse { + + @JsonPropertyDescription("Tham chiếu đến Campaign chứa rule này") + private String campaignId; + + @JsonPropertyDescription("Mã định danh của rule") + private String ruleId; + + @JsonPropertyDescription("Tên của rule") + private String ruleName; + + @JsonPropertyDescription("Loại rule") + private RuleType ruleType; + + @JsonPropertyDescription("Mô tả chi tiết về rule") + private String description; + + @JsonPropertyDescription("Thời gian bắt đầu có hiệu lực của rule") + private LocalDateTime effectiveFrom; + + @JsonPropertyDescription("Thời gian kết thúc hiệu lực của rule") + private LocalDateTime effectiveTo; + + @JsonPropertyDescription("Tham chiếu đến Pool chính") + private String poolId; + + @JsonPropertyDescription("Mã sản phẩm/quà tặng") + private String itemCode; + + @JsonPropertyDescription("Số lượng/giá trị phần thưởng") + private Double rewardAmount; + + @JsonPropertyDescription("Kênh gửi thông báo") + private NotificationChannel channel; + + @JsonPropertyDescription("Thứ tự ưu tiên áp dụng các Formula") + private List formulaSequence; + + @JsonPropertyDescription("Chính sách hết hạn của điểm/quà") + private ExpiryPolicy expiryPolicy; + + @JsonPropertyDescription("Tham số cho chính sách hết hạn (ví dụ: số tháng)") + private Integer expiryPolicyParam; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/response/CounterDefinitionResponse.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/response/CounterDefinitionResponse.java new file mode 100644 index 0000000..8a1be45 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/response/CounterDefinitionResponse.java @@ -0,0 +1,65 @@ +package dev.sonpx.loyalty.mcp.tool.response; + +import dev.sonpx.loyalty.mcp.enums.CounterEntity; +import dev.sonpx.loyalty.mcp.enums.CounterPeriod; +import dev.sonpx.loyalty.mcp.reward.enums.CounterCountType; +import dev.sonpx.loyalty.mcp.reward.enums.CounterResetType; +import dev.sonpx.loyalty.mcp.reward.enums.CounterUpdateType; +import dev.sonpx.loyalty.mcp.reward.enums.LateValuePosting; +import java.time.LocalDate; +import lombok.Getter; +import lombok.Setter; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; + +@Getter +@Setter +public class CounterDefinitionResponse { + + @JsonPropertyDescription("Mã bộ đếm") + private String counterId; + + @JsonPropertyDescription("Tên bộ đếm") + private String counterName; + + @JsonPropertyDescription("Mô tả chi tiết") + private String description; + + @JsonPropertyDescription("Ngày bắt đầu hiệu lực") + private LocalDate effectiveFrom; + + @JsonPropertyDescription("Ngày kết thúc hiệu lực") + private LocalDate effectiveTo; + + @JsonPropertyDescription("Đối tượng đếm (cấp độ đếm)") + private CounterEntity entity; + + @JsonPropertyDescription("Chu kỳ của bộ đếm") + private CounterPeriod bucketPeriodUnit; + + @JsonPropertyDescription("Ngày kết thúc chu kỳ (nếu là cố định)") + private LocalDate bucketEndDate; + + @JsonPropertyDescription("Độ dài của chu kỳ") + private Integer bucketPeriodDuration; + + @JsonPropertyDescription("Loại giá trị cần đếm") + private CounterCountType whatToCount; + + @JsonPropertyDescription("Cách thức reset bộ đếm") + private CounterResetType resetType; + + @JsonPropertyDescription("Giá trị reset") + private Double resetValue; + + @JsonPropertyDescription("Có sử dụng loại ngày bắt đầu đầu tiên không") + private Boolean firstDateType; + + @JsonPropertyDescription("Ngày bắt đầu đầu tiên") + private LocalDate firstStartDate; + + @JsonPropertyDescription("Thời điểm cập nhật trạng thái bộ đếm") + private CounterUpdateType updateStateWhen; + + @JsonPropertyDescription("Cách xử lý giá trị trễ") + private LateValuePosting lateValuePosting; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/response/DeductionSequenceResponse.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/response/DeductionSequenceResponse.java new file mode 100644 index 0000000..26d9c1b --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/response/DeductionSequenceResponse.java @@ -0,0 +1,32 @@ +package dev.sonpx.loyalty.mcp.tool.response; + +import java.time.LocalDate; +import lombok.Getter; +import lombok.Setter; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; + +@Getter +@Setter +public class DeductionSequenceResponse { + + @JsonPropertyDescription("Mã chuỗi trừ điểm") + private String sequenceId; + + @JsonPropertyDescription("Tên chuỗi trừ điểm") + private String sequenceName; + + @JsonPropertyDescription("Mô tả chuỗi trừ điểm") + private String description; + + @JsonPropertyDescription("Ngày bắt đầu hiệu lực") + private LocalDate effectiveFrom; + + @JsonPropertyDescription("Ngày kết thúc hiệu lực") + private LocalDate effectiveTo; + + @JsonPropertyDescription("Có phải là chuỗi mặc định không") + private Boolean isDefault; + + @JsonPropertyDescription("Tiêu chí sắp xếp") + private String sortCriteria; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/response/PoolDefinitionResponse.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/response/PoolDefinitionResponse.java new file mode 100644 index 0000000..5a09cc4 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/response/PoolDefinitionResponse.java @@ -0,0 +1,63 @@ +package dev.sonpx.loyalty.mcp.tool.response; + +import dev.sonpx.loyalty.mcp.enums.EntityLevel; +import dev.sonpx.loyalty.mcp.enums.ExpiryPolicy; +import dev.sonpx.loyalty.mcp.reward.enums.PoolType; +import dev.sonpx.loyalty.mcp.reward.enums.PrecisionOfBalance; +import java.time.LocalDate; +import lombok.Getter; +import lombok.Setter; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; + +@Getter +@Setter +public class PoolDefinitionResponse { + + @JsonPropertyDescription("Mã định danh pool") + private String poolId; + + @JsonPropertyDescription("Tên của pool") + private String poolName; + + @JsonPropertyDescription("Mô tả chi tiết pool") + private String description; + + @JsonPropertyDescription("Loại pool") + private PoolType poolType; + + @JsonPropertyDescription("Chính sách hết hạn") + private ExpiryPolicy expiryPolicy; + + @JsonPropertyDescription("Tham số cho chính sách hết hạn (ví dụ: số tháng)") + private Integer expiryPolicyParam; + + @JsonPropertyDescription("Ngày hết hạn cố định") + private LocalDate fixedDate; + + @JsonPropertyDescription("Thời gian chờ (ripening period)") + private Integer ripeningPeriod; + + @JsonPropertyDescription("Ngày trong tháng (dùng cho chính sách hết hạn)") + private Integer dayOfMonth; + + @JsonPropertyDescription("Tháng trong năm (dùng cho chính sách hết hạn)") + private Integer monthOfYear; + + @JsonPropertyDescription("Mã PCR") + private String pcrCode; + + @JsonPropertyDescription("Cho phép số dư âm") + private Boolean allowNegativeBalance; + + @JsonPropertyDescription("Độ chính xác của số dư") + private PrecisionOfBalance precisionOfBalance; + + @JsonPropertyDescription("Mã nhóm ATG") + private String atgGroupId; + + @JsonPropertyDescription("Thời gian ân hạn (grace period)") + private Integer gracePeriod; + + @JsonPropertyDescription("Cấp độ Entity") + private EntityLevel entityLevel; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/response/TransactionCodeResponse.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/response/TransactionCodeResponse.java new file mode 100644 index 0000000..ef48ee7 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/response/TransactionCodeResponse.java @@ -0,0 +1,22 @@ +package dev.sonpx.loyalty.mcp.tool.response; + +import lombok.Getter; +import lombok.Setter; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; + +@Getter +@Setter +public class TransactionCodeResponse { + + @JsonPropertyDescription("Mã giao dịch") + private String code; + + @JsonPropertyDescription("Mô tả mã giao dịch") + private String description; + + @JsonPropertyDescription("Cờ chỉ thị hoàn tác") + private Boolean reversalInd; + + @JsonPropertyDescription("Mã giao dịch từ hệ thống bên ngoài") + private String externalTransactionCode; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/transaction/client/TransactionClient.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/transaction/client/TransactionClient.java new file mode 100644 index 0000000..b532188 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/transaction/client/TransactionClient.java @@ -0,0 +1,19 @@ +package dev.sonpx.loyalty.mcp.transaction.client; + +import dev.sonpx.loyalty.mcp.transaction.criteria.TransactionCriteria; +import dev.sonpx.loyalty.mcp.transaction.model.Transaction; +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 TransactionClient { + + @GetExchange("/api/transaction-history/csr/list") + List getAll(TransactionCriteria criteria, Pageable pageable); + + @GetExchange("/api/transaction-history/csr/id/{id}") + Transaction findActiveById(@PathVariable String id); +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/transaction/criteria/TransactionCriteria.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/transaction/criteria/TransactionCriteria.java new file mode 100644 index 0000000..63506b8 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/transaction/criteria/TransactionCriteria.java @@ -0,0 +1,11 @@ +package dev.sonpx.loyalty.mcp.transaction.criteria; + +import dev.sonpx.loyalty.mcp.filter.StringFilter; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = false) +public class TransactionCriteria { + private String search; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/transaction/model/Transaction.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/transaction/model/Transaction.java new file mode 100644 index 0000000..aab2028 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/transaction/model/Transaction.java @@ -0,0 +1,104 @@ +/* + * 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.transaction.model; + +import dev.sonpx.loyalty.mcp.model.ReferenceData; + +import java.time.LocalDateTime; +import java.util.List; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +/** + * @author AnhDT + * Created on 2024/04/04 + */ +@Getter +@Setter +@Builder +public class Transaction { + + private String csn; + + private String cifNbr; + + private String referenceNo; + + private String requestId; + + /** + * Account Detail + */ + private String transactedPaNo; + + private ReferenceData transactedPaLevel; + + private ReferenceData transactedPaType; + + private String transactedCardNo; + + /** + * Transaction Detail + */ + private String invoiceNo; + + private String sourceReferenceNo; + + private List olsTransactionCodes; + + private String grossAmount; + + private String nettAmount; + + private String earnedPoint; + + private String redeemedPoint; + + private String adjustedPoint; + + private String earnedAmount; + + private String redeemedAmount; + + private String adjustedAmount; + + private LocalDateTime transactionDate; + + private LocalDateTime postDate; + + private LocalDateTime settlementDate; + + private String cancellationInd; + + private List ruleIds; + + private ReferenceData mcc; + + private ReferenceData store; + + private ReferenceData terminal; + + private String transactionDescription; + + private ReferenceData currencyCode; + + private ReferenceData countryCode; + + private TransactionMode transactionMode; + + private TransactionChannel transactionChannel; + + private String salaryCredit; + + private String posEntryMode; + + private ReferenceData reasonCode; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/transaction/model/TransactionChannel.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/transaction/model/TransactionChannel.java new file mode 100644 index 0000000..df09107 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/transaction/model/TransactionChannel.java @@ -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.transaction.model; + +import java.util.Arrays; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + + +public enum TransactionChannel { + CALL_CENTER("CC"), + ATM("ATM"), + MOBILE_APP("MB"), + REWARD_REDEMPTION_WEB("WEB"); + + @JsonValue + private final String code; + + @JsonCreator + public static TransactionChannel fromCode(String code) { + return Arrays.stream(values()) + .filter(e -> e.code.equalsIgnoreCase(code)) + .findFirst() + .orElse(null); + } + + TransactionChannel(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/transaction/model/TransactionMode.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/transaction/model/TransactionMode.java new file mode 100644 index 0000000..75bdcf8 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/transaction/model/TransactionMode.java @@ -0,0 +1,40 @@ +/* + * 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.transaction.model; + +import java.util.Arrays; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + + +public enum TransactionMode { + E_COMMERCE("1"), + PAY_WAVE("2"), + RECURRING("3"); + + @JsonValue + private final String code; + + @JsonCreator + public static TransactionMode fromCode(String code) { + return Arrays.stream(values()) + .filter(e -> e.code.equalsIgnoreCase(code)) + .findFirst() + .orElse(null); + } + + TransactionMode(String code) { + this.code = code; + } + + public String getCode() { + return this.code; + } +} diff --git a/loyalty-mcp-server/src/main/resources/application-prod.yml b/loyalty-mcp-server/src/main/resources/application-prod.yml new file mode 100644 index 0000000..3631b0f --- /dev/null +++ b/loyalty-mcp-server/src/main/resources/application-prod.yml @@ -0,0 +1,12 @@ +logging: + level: + root: WARN + dev.sonpx.loyalty: INFO + pattern: + console: + +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus diff --git a/loyalty-mcp-server/src/main/resources/application.yml b/loyalty-mcp-server/src/main/resources/application.yml index 5f41c53..8f7ddde 100644 --- a/loyalty-mcp-server/src/main/resources/application.yml +++ b/loyalty-mcp-server/src/main/resources/application.yml @@ -1,8 +1,10 @@ server: port: 9331 - shutdown: immediate + shutdown: graceful spring: + lifecycle: + timeout-per-shutdown-phase: 30s mvc: async: request-timeout: 3600000 @@ -19,16 +21,32 @@ spring: client: registration: keycloak: - client-id: ols-cli - client-secret: ${KEYCLOAK_CLIENT_SECRET:1jV8NSAeybIqmUK0AWhI8hgdo1q5itj7} + client-id: ${KEYCLOAK_CLIENT_ID:ols-cli} + client-secret: ${KEYCLOAK_CLIENT_SECRET} authorization-grant-type: client_credentials provider: keycloak: - token-uri: ${KEYCLOAK_TOKEN_URI:http://192.168.99.235/auth/realms/ols-cn-sit/protocol/openid-connect/token} + token-uri: ${KEYCLOAK_TOKEN_URI} loyalty: core: - base-url: ${LOYALTY_CORE_BASE_URL:http://192.168.99.242:8081} + base-url: ${LOYALTY_CORE_BASE_URL:http://localhost:8081} + +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: diff --git a/loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/aspect/GlobalToolExceptionHandlerAspectTest.java b/loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/aspect/GlobalToolExceptionHandlerAspectTest.java new file mode 100644 index 0000000..e3f4211 --- /dev/null +++ b/loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/aspect/GlobalToolExceptionHandlerAspectTest.java @@ -0,0 +1,116 @@ +package dev.sonpx.loyalty.mcp.aspect; + +import dev.sonpx.loyalty.mcp.model.Result; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.Signature; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class GlobalToolExceptionHandlerAspectTest { + + private GlobalToolExceptionHandlerAspect aspect; + private ProceedingJoinPoint pjp; + private Signature signature; + + @BeforeEach + void setUp() { + aspect = new GlobalToolExceptionHandlerAspect(); + pjp = mock(ProceedingJoinPoint.class); + signature = mock(Signature.class); + when(pjp.getSignature()).thenReturn(signature); + when(signature.getName()).thenReturn("testTool"); + } + + @Test + void handle400_returnsValidationError() throws Throwable { + String errorBody = "{\"message\": \"campaignId must not be blank\"}"; + when(pjp.proceed()).thenThrow(HttpClientErrorException.create( + errorBody, HttpStatusCode.valueOf(400), "Bad Request", + HttpHeaders.EMPTY, errorBody.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8)); + + Result result = (Result) aspect.handleException(pjp); + assertThat(result.getAgentInstruction()).contains("Dữ liệu không hợp lệ"); + assertThat(result.getAgentInstruction()).contains("campaignId must not be blank"); + } + + @Test + void handle404_returnsNotFoundError() throws Throwable { + String errorBody = "Not Found"; + when(pjp.proceed()).thenThrow(HttpClientErrorException.create( + errorBody, HttpStatusCode.valueOf(404), "Not Found", + HttpHeaders.EMPTY, errorBody.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8)); + + Result result = (Result) aspect.handleException(pjp); + assertThat(result.getAgentInstruction()).contains("Không tìm thấy đối tượng"); + } + + @Test + void handle409_returnsConflictError() throws Throwable { + String errorBody = "Duplicate entry"; + when(pjp.proceed()).thenThrow(HttpClientErrorException.create( + errorBody, HttpStatusCode.valueOf(409), "Conflict", + HttpHeaders.EMPTY, errorBody.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8)); + + Result result = (Result) aspect.handleException(pjp); + assertThat(result.getAgentInstruction()).contains("đã tồn tại hoặc xung đột"); + } + + @Test + void handle401_returnsAuthError() throws Throwable { + String errorBody = "Unauthorized"; + when(pjp.proceed()).thenThrow(HttpClientErrorException.create( + errorBody, HttpStatusCode.valueOf(401), "Unauthorized", + HttpHeaders.EMPTY, errorBody.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8)); + + Result result = (Result) aspect.handleException(pjp); + assertThat(result.getAgentInstruction()).contains("Không có quyền truy cập"); + } + + @Test + void handle403_returnsAuthError() throws Throwable { + String errorBody = "Forbidden"; + when(pjp.proceed()).thenThrow(HttpClientErrorException.create( + errorBody, HttpStatusCode.valueOf(403), "Forbidden", + HttpHeaders.EMPTY, errorBody.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8)); + + Result result = (Result) aspect.handleException(pjp); + assertThat(result.getAgentInstruction()).contains("Không có quyền truy cập"); + } + + @Test + void handle500_returnsSystemError() throws Throwable { + String errorBody = "Internal Server Error"; + when(pjp.proceed()).thenThrow(HttpServerErrorException.create( + HttpStatusCode.valueOf(500), "Internal Server Error", + HttpHeaders.EMPTY, errorBody.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8)); + + Result result = (Result) aspect.handleException(pjp); + assertThat(result.getAgentInstruction()).contains("Lỗi hệ thống nội bộ"); + } + + @Test + void handleGenericException_returnsGenericError() throws Throwable { + when(pjp.proceed()).thenThrow(new NullPointerException("Something null")); + + Result result = (Result) aspect.handleException(pjp); + assertThat(result.getAgentInstruction()).contains("Lỗi hệ thống"); + } + + @Test + void handleSuccess_passesThrough() throws Throwable { + Result expected = Result.of("success", "instruction"); + when(pjp.proceed()).thenReturn(expected); + + Object result = aspect.handleException(pjp); + assertThat(result).isEqualTo(expected); + } +} diff --git a/loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/tool/CampaignRuleToolsTest.java b/loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/tool/CampaignRuleToolsTest.java new file mode 100644 index 0000000..cb0f3be --- /dev/null +++ b/loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/tool/CampaignRuleToolsTest.java @@ -0,0 +1,166 @@ +package dev.sonpx.loyalty.mcp.tool; + +import dev.sonpx.loyalty.mcp.model.Result; +import dev.sonpx.loyalty.mcp.reward.model.CampaignRule; +import dev.sonpx.loyalty.mcp.service.CampaignRuleService; +import dev.sonpx.loyalty.mcp.service.CampaignService; +import jakarta.validation.Validator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import dev.sonpx.loyalty.mcp.mapper.McpDtoMapper; +import dev.sonpx.loyalty.mcp.tool.response.CampaignRuleResponse; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +class CampaignRuleToolsTest { + + private CampaignRuleService campaignRuleService; + private CampaignService campaignService; + private McpDtoMapper mapper; + private Validator validator; + private CampaignRuleTools campaignRuleTools; + + @BeforeEach + void setUp() { + campaignRuleService = mock(CampaignRuleService.class); + campaignService = mock(CampaignService.class); + mapper = mock(McpDtoMapper.class); + validator = mock(Validator.class); + when(validator.validate(any())).thenReturn(Set.of()); + campaignRuleTools = new CampaignRuleTools(campaignRuleService, campaignService, mapper, validator); + } + + // --- getRuleById validation --- + + @Test + void getRuleById_nullId_returnsValidationError() { + Result result = campaignRuleTools.getRuleById(null); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + verify(campaignRuleService, never()).findActiveById(anyString()); + } + + @Test + void getRuleById_blankId_returnsValidationError() { + Result result = campaignRuleTools.getRuleById(" "); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + } + + @Test + void getRuleById_validId_callsService() { + when(campaignRuleService.findActiveById("R01")).thenReturn(Result.of(new CampaignRule())); + campaignRuleTools.getRuleById("R01"); + verify(campaignRuleService).findActiveById("R01"); + } + + // --- getRulesByIds validation --- + + @Test + void getRulesByIds_nullIds_returnsValidationError() { + Result> result = campaignRuleTools.getRulesByIds(null); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + } + + // --- checkRuleId validation --- + + @Test + void checkRuleId_nullId_returnsValidationError() { + Result result = campaignRuleTools.checkRuleId(null); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + } + + // --- createRule validation --- + + @Test + void createRule_nullDto_returnsValidationError() { + var result = campaignRuleTools.createRule(null); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + } + + // --- createRule campaign existence validation --- + + @Test + void createRule_nonExistentCampaign_returnsValidationError() { + var dto = new dev.sonpx.loyalty.mcp.tool.request.CreateCampaignRuleRequest(); + dto.setCampaignId("NONEXIST"); + dto.setRuleId("R01"); + dto.setRuleName("Test Rule"); + dto.setRuleType(dev.sonpx.loyalty.mcp.reward.enums.RuleType.AWARD); + + // Rule doesn't exist (idempotency check passes) + when(campaignRuleService.findActiveById("R01")).thenReturn(Result.of(null)); + // Campaign doesn't exist + when(campaignService.findActiveById("NONEXIST")).thenReturn(Result.of(null)); + + var result = campaignRuleTools.createRule(dto); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + assertThat(result.getAgentInstruction()).contains("không tồn tại"); + } + + @Test + void createRule_campaignServiceThrows_returnsValidationError() { + var dto = new dev.sonpx.loyalty.mcp.tool.request.CreateCampaignRuleRequest(); + dto.setCampaignId("CMP01"); + dto.setRuleId("R01"); + dto.setRuleName("Test Rule"); + dto.setRuleType(dev.sonpx.loyalty.mcp.reward.enums.RuleType.AWARD); + + // Rule doesn't exist (idempotency check passes) + when(campaignRuleService.findActiveById("R01")).thenReturn(Result.of(null)); + // Campaign service throws + when(campaignService.findActiveById("CMP01")).thenThrow(new RuntimeException("connection error")); + + var result = campaignRuleTools.createRule(dto); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + } + + // --- findMatchingRules validation --- + + @Test + void findMatchingRules_nullRuleType_returnsValidationError() { + var result = campaignRuleTools.findMatchingRules(null, "TXN01", "2026-01-01T00:00:00", "2026-01-01T00:00:00", "vi"); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + assertThat(result.getAgentInstruction()).contains("Rule type"); + } + + @Test + void findMatchingRules_blankTransactionCode_returnsValidationError() { + var result = campaignRuleTools.findMatchingRules("AWD", " ", "2026-01-01T00:00:00", "2026-01-01T00:00:00", "vi"); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + assertThat(result.getAgentInstruction()).contains("Transaction code"); + } + + @Test + void findMatchingRules_blankTransactionDate_returnsValidationError() { + var result = campaignRuleTools.findMatchingRules("AWD", "TXN01", "", "2026-01-01T00:00:00", "vi"); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + assertThat(result.getAgentInstruction()).contains("Transaction date"); + } + + @Test + void findMatchingRules_nullPostDate_returnsValidationError() { + var result = campaignRuleTools.findMatchingRules("AWD", "TXN01", "2026-01-01T00:00:00", null, "vi"); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + assertThat(result.getAgentInstruction()).contains("Post date"); + } + + @Test + void findMatchingRules_blankLanguageCode_returnsValidationError() { + var result = campaignRuleTools.findMatchingRules("AWD", "TXN01", "2026-01-01T00:00:00", "2026-01-01T00:00:00", ""); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + assertThat(result.getAgentInstruction()).contains("Language code"); + } + + @Test + void findMatchingRules_allValid_callsService() { + when(campaignRuleService.findMatchingRules(anyString(), anyString(), anyString(), anyString(), anyString())) + .thenReturn(Result.of(List.of())); + campaignRuleTools.findMatchingRules("AWD", "TXN01", "2026-01-01T00:00:00", "2026-01-01T00:00:00", "vi"); + verify(campaignRuleService).findMatchingRules("AWD", "TXN01", "2026-01-01T00:00:00", "2026-01-01T00:00:00", "vi"); + } +} diff --git a/loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/tool/CampaignToolsTest.java b/loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/tool/CampaignToolsTest.java new file mode 100644 index 0000000..a5aca46 --- /dev/null +++ b/loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/tool/CampaignToolsTest.java @@ -0,0 +1,101 @@ +package dev.sonpx.loyalty.mcp.tool; + +import dev.sonpx.loyalty.mcp.model.Result; +import dev.sonpx.loyalty.mcp.reward.model.Campaign; +import dev.sonpx.loyalty.mcp.service.CampaignService; +import jakarta.validation.Validator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import dev.sonpx.loyalty.mcp.mapper.McpDtoMapper; +import dev.sonpx.loyalty.mcp.tool.response.CampaignResponse; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +class CampaignToolsTest { + + private CampaignService campaignService; + private McpDtoMapper mapper; + private Validator validator; + private CampaignTools campaignTools; + + @BeforeEach + void setUp() { + campaignService = mock(CampaignService.class); + mapper = mock(McpDtoMapper.class); + validator = mock(Validator.class); + when(validator.validate(any())).thenReturn(Set.of()); + campaignTools = new CampaignTools(campaignService, mapper, validator); + } + + // --- getCampaignById validation --- + + @Test + void getCampaignById_nullId_returnsValidationError() { + Result result = campaignTools.getCampaignById(null); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + verify(campaignService, never()).findActiveById(anyString()); + } + + @Test + void getCampaignById_blankId_returnsValidationError() { + Result result = campaignTools.getCampaignById(" "); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + verify(campaignService, never()).findActiveById(anyString()); + } + + @Test + void getCampaignById_validId_callsService() { + when(campaignService.findActiveById("CMP01")).thenReturn(Result.of(new Campaign())); + Result result = campaignTools.getCampaignById("CMP01"); + verify(campaignService).findActiveById("CMP01"); + } + + @Test + void getCampaignById_trimsWhitespace() { + when(campaignService.findActiveById("CMP01")).thenReturn(Result.of(new Campaign())); + campaignTools.getCampaignById(" CMP01 "); + verify(campaignService).findActiveById("CMP01"); + } + + // --- getCampaignsByIds validation --- + + @Test + void getCampaignsByIds_nullIds_returnsValidationError() { + Result> result = campaignTools.getCampaignsByIds(null); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + } + + @Test + void getCampaignsByIds_blankIds_returnsValidationError() { + Result> result = campaignTools.getCampaignsByIds(""); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + } + + // --- checkCampaignId validation --- + + @Test + void checkCampaignId_nullId_returnsValidationError() { + Result result = campaignTools.checkCampaignId(null); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + } + + @Test + void checkCampaignId_blankId_returnsValidationError() { + Result result = campaignTools.checkCampaignId(" "); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + } + + // --- createCampaign validation --- + + @Test + void createCampaign_nullDto_returnsValidationError() { + var result = campaignTools.createCampaign(null); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + } +} diff --git a/loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/tool/PoolDefinitionToolsTest.java b/loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/tool/PoolDefinitionToolsTest.java new file mode 100644 index 0000000..6296692 --- /dev/null +++ b/loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/tool/PoolDefinitionToolsTest.java @@ -0,0 +1,62 @@ +package dev.sonpx.loyalty.mcp.tool; + +import dev.sonpx.loyalty.mcp.model.Result; +import dev.sonpx.loyalty.mcp.reward.model.PoolDefinition; +import dev.sonpx.loyalty.mcp.service.PoolDefinitionService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import dev.sonpx.loyalty.mcp.mapper.McpDtoMapper; +import dev.sonpx.loyalty.mcp.tool.response.PoolDefinitionResponse; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +class PoolDefinitionToolsTest { + + private PoolDefinitionService poolDefinitionService; + private McpDtoMapper mapper; + private PoolDefinitionTools poolDefinitionTools; + + @BeforeEach + void setUp() { + poolDefinitionService = mock(PoolDefinitionService.class); + mapper = mock(McpDtoMapper.class); + poolDefinitionTools = new PoolDefinitionTools(poolDefinitionService, mapper); + } + + @Test + void getPoolDefinitionById_nullId_returnsValidationError() { + Result result = poolDefinitionTools.getPoolDefinitionById(null); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + verify(poolDefinitionService, never()).findById(anyString()); + } + + @Test + void getPoolDefinitionById_blankId_returnsValidationError() { + Result result = poolDefinitionTools.getPoolDefinitionById(""); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + } + + @Test + void getPoolDefinitionById_validId_callsService() { + when(poolDefinitionService.findById("POOL01")).thenReturn(Result.of(new PoolDefinition())); + poolDefinitionTools.getPoolDefinitionById("POOL01"); + verify(poolDefinitionService).findById("POOL01"); + } + + @Test + void getPoolDefinitionsByIds_nullIds_returnsValidationError() { + Result> result = poolDefinitionTools.getPoolDefinitionsByIds(null); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + } + + @Test + void getPoolDefinitionsByIds_blankIds_returnsValidationError() { + Result> result = poolDefinitionTools.getPoolDefinitionsByIds(" "); + assertThat(result.getAgentInstruction()).contains("VALIDATION_ERROR"); + } +} diff --git a/scaffold.py b/scaffold.py new file mode 100644 index 0000000..66eab30 --- /dev/null +++ b/scaffold.py @@ -0,0 +1,162 @@ +import os + +MCP_DIR = '/Users/sonphung/git/loyalty-agent-service/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp' + +domains = [ + ('identity', 'User', 'UserResponseDto', '/api/user/csr/list', '/api/user/csr/id/{id}', 'identityHttpServiceProxyFactory'), + ('master', 'AppParam', 'AppParamDto', '/api/app-param/csr/list', '/api/app-param/csr/id/{id}', 'masterHttpServiceProxyFactory'), + ('marketing', 'Journey', 'JourneyDto', '/api/journey/csr/list', '/api/journey/csr/id/{id}', 'marketingHttpServiceProxyFactory'), + ('attribute', 'DynamicAttribute', 'DynamicAttributeDto', '/api/dynamic-attribute/csr/list', '/api/dynamic-attribute/csr/id/{id}', 'attributeHttpServiceProxyFactory') +] + +for domain, entity, dto, list_api, id_api, proxy_factory in domains: + pkg = f"dev.sonpx.loyalty.mcp.{domain}" + base_dir = os.path.join(MCP_DIR, domain) + os.makedirs(base_dir, exist_ok=True) + + # 1. Criteria + criteria = f"""package {pkg}.model; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class {entity}Criteria {{ + private String search; + private int page = 1; + private int size = 10; +}} +""" + with open(os.path.join(base_dir, 'model', f'{entity}Criteria.java'), 'w') as f: + f.write(criteria) + + # 2. Client + client = f"""package {pkg}.service; + +import dev.sonpx.loyalty.mcp.model.Result; +import {pkg}.model.{dto}; +import {pkg}.model.{entity}Criteria; +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 {entity}Client {{ + + @GetExchange("{list_api}") + Result> search(@ModelAttribute {entity}Criteria criteria); + + @GetExchange("{id_api}") + Result<{dto}> getById(@PathVariable("id") String id); +}} +""" + os.makedirs(os.path.join(base_dir, 'service'), exist_ok=True) + with open(os.path.join(base_dir, 'service', f'{entity}Client.java'), 'w') as f: + f.write(client) + + # 3. Service + service = f"""package {pkg}.service; + +import dev.sonpx.loyalty.mcp.model.Result; +import {pkg}.model.{dto}; +import {pkg}.model.{entity}Criteria; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class {entity}Service {{ + + private final {entity}Client client; + + public Result> search({entity}Criteria criteria) {{ + try {{ + return client.search(criteria); + }} catch (Exception e) {{ + log.error("Failed to search {entity}", e); + return Result.of(null, "FAILED", e.getMessage()); + }} + }} + + public Result<{dto}> getById(String id) {{ + try {{ + return client.getById(id); + }} catch (Exception e) {{ + log.error("Failed to get {entity} by id", e); + return Result.of(null, "FAILED", e.getMessage()); + }} + }} +}} +""" + with open(os.path.join(base_dir, 'service', f'{entity}Service.java'), 'w') as f: + f.write(service) + + # 4. Tools + tools = f"""package dev.sonpx.loyalty.mcp.tool; + +import dev.sonpx.loyalty.mcp.model.Result; +import {pkg}.model.{dto}; +import {pkg}.model.{entity}Criteria; +import {pkg}.service.{entity}Service; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.ai.tool.annotation.Tool; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class {entity}Tools {{ + + private final {entity}Service service; + + @Tool(description = "Search {entity} in the loyalty system") + public Result> search{entity}(String search) {{ + {entity}Criteria criteria = new {entity}Criteria(); + criteria.setSearch(search); + return service.search(criteria); + }} + + @Tool(description = "Get {entity} details by ID") + public Result<{dto}> get{entity}ById(String id) {{ + return service.getById(id); + }} +}} +""" + tools_dir = os.path.join(MCP_DIR, 'tool') + with open(os.path.join(tools_dir, f'{entity}Tools.java'), 'w') as f: + f.write(tools) + + # 5. Client Bean Configuration + config = f"""package {pkg}.config; + +import {pkg}.service.{entity}Client; +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 {entity}Config {{ + + @Bean + public {entity}Client {domain.lower()}Client( + @Qualifier("{proxy_factory}") HttpServiceProxyFactory factory) {{ + return factory.createClient({entity}Client.class); + }} +}} +""" + os.makedirs(os.path.join(base_dir, 'config'), exist_ok=True) + with open(os.path.join(base_dir, 'config', f'{entity}Config.java'), 'w') as f: + f.write(config) + +print("Scaffold complete.") diff --git a/src/components/chat/ChatBottombar.tsx b/src/components/chat/ChatBottombar.tsx index 9d446d4..e3c362f 100644 --- a/src/components/chat/ChatBottombar.tsx +++ b/src/components/chat/ChatBottombar.tsx @@ -7,18 +7,24 @@ import { useChatStore } from '@/store/useChatStore' import { useConnectionStore } from '@/store/useConnectionStore' import { SendHorizontal, AlertCircle, Clock, Mic } from 'lucide-react' -export function ChatBottombar() { +export function ChatBottombar({ onSend }: { onSend?: (prompt: string) => void }) { const [input, setInput] = useState('') const sendMessage = useChatStore((state) => state.sendMessage) + const conversationId = useChatStore((state) => state.conversationId) const connectionStatus = useConnectionStore((state) => state.status) const queuedCount = useConnectionStore((state) => state.queuedCount) const isQueueFull = queuedCount >= 20 const isDisconnected = connectionStatus === 'disconnected' || connectionStatus === 'error' + const isReady = !isQueueFull const handleSend = () => { - if (!input.trim() || isQueueFull) return - sendMessage(input.trim()) + if (!input.trim() || !isReady) return + if (onSend) { + onSend(input.trim()) + } else { + sendMessage(input.trim()) + } setInput('') } @@ -40,11 +46,13 @@ export function ChatBottombar() { placeholder={ isQueueFull ? 'Mất kết nối. Hàng chờ đã đầy, vui lòng chờ...' - : 'Hỏi Loyalty Agent...' + : !conversationId + ? 'Đang khởi tạo cuộc trò chuyện...' + : 'Hỏi Loyalty Agent...' } className="flex-1 min-h-[36px] max-h-[120px] py-1.5 px-1 bg-transparent border-0 focus-visible:ring-0 focus-visible:ring-offset-0 text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/60 resize-none overflow-y-auto" rows={1} - disabled={isQueueFull} + disabled={!isReady} />
@@ -71,7 +79,7 @@ export function ChatBottombar() { size="icon" className="h-8 w-8 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground shadow-sm disabled:opacity-30 shrink-0 transition-all active:scale-95" onClick={handleSend} - disabled={!input.trim() || isQueueFull} + disabled={!input.trim() || !isReady} > diff --git a/src/components/chat/ChatLayout.tsx b/src/components/chat/ChatLayout.tsx index 7c5028d..7ecaebe 100644 --- a/src/components/chat/ChatLayout.tsx +++ b/src/components/chat/ChatLayout.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import { ChatList } from './ChatList' import { ChatBottombar } from './ChatBottombar' import { ChatMobileSidebar } from './ChatMobileSidebar' @@ -52,6 +52,19 @@ export function ChatLayout() { } }, [messages, activeId, conversations, fetchConversations]) + const handleSendMessage = useCallback(async (prompt: string) => { + let currentId = activeId + if (!currentId) { + const newConv = await createNewConversation() + if (newConv) { + currentId = newConv.id + } + } + if (currentId) { + useChatStore.getState().sendMessage(prompt) + } + }, [activeId, createNewConversation]) + return (
{/* Mobile Drawer & Sidebar */} @@ -97,8 +110,8 @@ export function ChatLayout() { {/* Center Content & Floating Input */}
- - + +
{/* Right Info Panel */} diff --git a/src/components/chat/ChatList.tsx b/src/components/chat/ChatList.tsx index 0491a13..f684d26 100644 --- a/src/components/chat/ChatList.tsx +++ b/src/components/chat/ChatList.tsx @@ -7,7 +7,7 @@ import type { Message } from '@/store/useChatStore' import { ArrowDown } from 'lucide-react' import { Button } from '@/components/ui/button' -export function ChatList({ messages }: { messages: Message[] }) { +export function ChatList({ messages, onSend }: { messages: Message[], onSend?: (prompt: string) => void }) { const scrollRef = useRef(null) const bottomRef = useRef(null) const [showScrollBottom, setShowScrollBottom] = useState(false) @@ -32,7 +32,7 @@ export function ChatList({ messages }: { messages: Message[] }) { }, [messages]) if (messages.length === 0) { - return + return } return ( diff --git a/src/components/chat/ToolStatusIndicator.tsx b/src/components/chat/ToolStatusIndicator.tsx index d97b71c..16d0b4b 100644 --- a/src/components/chat/ToolStatusIndicator.tsx +++ b/src/components/chat/ToolStatusIndicator.tsx @@ -6,11 +6,39 @@ interface ToolStatusIndicatorProps { } const TOOL_NAME_MAP: Record = { + // Campaign tools searchCampaigns: '🔍 Đang tìm kiếm chiến dịch...', - getCampaignDetails: '📋 Đang lấy thông tin chi tiết chiến dịch...', + countCampaigns: '📊 Đang đếm chiến dịch...', + getCampaignById: '📋 Đang lấy thông tin chiến dịch...', + getCampaignsByIds: '📋 Đang lấy thông tin các chiến dịch...', + generateCampaignId: '🔑 Đang tạo mã chiến dịch...', + checkCampaignId: '✅ Đang kiểm tra mã chiến dịch...', createCampaign: '✨ Đang khởi tạo chiến dịch mới...', - executeWorkflow: '⚡ Đang xử lý quy trình...', - queryCustomerPoints: '💳 Đang tra cứu điểm thưởng khách hàng...', + // Rule tools + searchRules: '🔍 Đang tìm kiếm thể lệ...', + countRules: '📊 Đang đếm thể lệ...', + getRuleById: '📋 Đang lấy thông tin thể lệ...', + getRulesByIds: '📋 Đang lấy thông tin các thể lệ...', + generateRuleId: '🔑 Đang tạo mã thể lệ...', + checkRuleId: '✅ Đang kiểm tra mã thể lệ...', + createRule: '✨ Đang tạo thể lệ mới...', + findMatchingRules: '🎯 Đang tìm thể lệ phù hợp...', + // Pool tools + searchPools: '🏦 Đang tìm kiếm pool điểm...', + getPoolDefinitionById: '🏦 Đang lấy thông tin pool...', + getPoolDefinitionsByIds: '🏦 Đang lấy thông tin các pool...', + // Customer tools + searchCustomers: '👤 Đang tìm kiếm khách hàng...', + // Transaction tools + searchTransactions: '💰 Đang tìm kiếm giao dịch...', + // Reference data tools + searchTransactionCodes: '🏷️ Đang tìm mã giao dịch...', + searchCounterDefinitions: '📊 Đang tìm bộ đếm...', + searchDeductionSequences: '📑 Đang tìm trình tự khấu trừ...', + searchCatalogues: '🎁 Đang tìm danh mục...', + searchAppParams: '⚙️ Đang tìm tham số hệ thống...', + searchDynamicAttributes: '🔧 Đang tìm thuộc tính...', + searchJourneys: '🗺️ Đang tìm hành trình...', } export function ToolStatusIndicator({ toolName }: ToolStatusIndicatorProps) { diff --git a/src/components/chat/WelcomeScreen.tsx b/src/components/chat/WelcomeScreen.tsx index 838dc36..1da059c 100644 --- a/src/components/chat/WelcomeScreen.tsx +++ b/src/components/chat/WelcomeScreen.tsx @@ -37,9 +37,17 @@ const ACTION_MATRIX = [ }, ] -export function WelcomeScreen() { +export function WelcomeScreen({ onSend }: { onSend?: (prompt: string) => void }) { const sendMessage = useChatStore((s) => s.sendMessage) + const handleAction = (prompt: string) => { + if (onSend) { + onSend(prompt) + } else { + sendMessage(prompt) + } + } + return (
@@ -57,7 +65,7 @@ export function WelcomeScreen() { return (
sendMessage(item.prompt)} + onClick={() => handleAction(item.prompt)} className="group cursor-pointer p-4 rounded-2xl bg-card border border-border/70 hover:border-primary/50 hover:shadow-md hover:bg-secondary/40 transition-all duration-200 flex flex-col justify-between min-h-[140px] relative overflow-hidden" >
diff --git a/src/services/StompService.ts b/src/services/StompService.ts index 1db126b..76c8741 100644 --- a/src/services/StompService.ts +++ b/src/services/StompService.ts @@ -65,12 +65,16 @@ class StompService { onWebSocketClose: () => { console.warn('[StompService] WebSocket connection closed') useConnectionStore.getState().setStatus('disconnected') - this.scheduleReconnect() + if (this.client) { + this.scheduleReconnect() + } }, onStompError: (frame) => { console.error('[StompService] STOMP error frame received:', frame.headers['message']) useConnectionStore.getState().setStatus('error') - this.scheduleReconnect() + if (this.client) { + this.scheduleReconnect() + } }, onWebSocketError: (event) => { console.error('[StompService] WebSocket error:', event) @@ -105,13 +109,18 @@ class StompService { this.reconnectAttempt++ console.log(`[StompService] Scheduling reconnect attempt #${this.reconnectAttempt} in ${delay}ms`) - this.reconnectTimeoutId = setTimeout(() => { + this.reconnectTimeoutId = setTimeout(async () => { this.reconnectTimeoutId = null if (this.client) { - this.client.activate() - } else { - this.connect() + const oldClient = this.client + this.client = null // Clear reference to prevent loops in onWebSocketClose + try { + await oldClient.deactivate() + } catch (e) { + console.error('[StompService] Error deactivating old client:', e) + } } + this.connect() }, delay) } diff --git a/start-all.ps1 b/start-all.ps1 index 3760eba..97d6531 100644 --- a/start-all.ps1 +++ b/start-all.ps1 @@ -44,7 +44,7 @@ Write-Host "==========================================" # 1. Start MCP Server Write-Host "Starting loyalty-mcp-server..." $mcpLog = Join-Path $LogDir "loyalty-mcp-server.log" -$mcpProcess = Start-Process -FilePath "cmd.exe" -ArgumentList "/c ..\mvnw.cmd spring-boot:run -Dmaven.test.skip=true > `"$mcpLog`" 2>&1" -WorkingDirectory (Join-Path $ProjectRoot "loyalty-mcp-server") -PassThru -WindowStyle Hidden +$mcpProcess = Start-Process -FilePath "cmd.exe" -ArgumentList "/c ..\mvnw.cmd -q spring-boot:run -Dmaven.test.skip=true > `"$mcpLog`" 2>&1" -WorkingDirectory (Join-Path $ProjectRoot "loyalty-mcp-server") -PassThru -WindowStyle Hidden Write-Host "loyalty-mcp-server started with PID: $($mcpProcess.Id)" Write-Host "Waiting for loyalty-mcp-server to initialize..." @@ -58,7 +58,7 @@ Write-Host "loyalty-mcp-server is up!" # 2. Start Agent Service Write-Host "Starting loyalty-agent..." $agentLog = Join-Path $LogDir "loyalty-agent.log" -$agentProcess = Start-Process -FilePath "cmd.exe" -ArgumentList "/c ..\mvnw.cmd spring-boot:run -Dmaven.test.skip=true > `"$agentLog`" 2>&1" -WorkingDirectory (Join-Path $ProjectRoot "loyalty-agent") -PassThru -WindowStyle Hidden +$agentProcess = Start-Process -FilePath "cmd.exe" -ArgumentList "/c ..\mvnw.cmd -q spring-boot:run -Dmaven.test.skip=true > `"$agentLog`" 2>&1" -WorkingDirectory (Join-Path $ProjectRoot "loyalty-agent") -PassThru -WindowStyle Hidden Write-Host "loyalty-agent started with PID: $($agentProcess.Id)" Write-Host "Waiting for loyalty-agent to initialize..." diff --git a/start-all.sh b/start-all.sh index 78942b0..a822b41 100755 --- a/start-all.sh +++ b/start-all.sh @@ -37,6 +37,12 @@ if [ -n "$MAVEN_PIDS" ]; then pkill -9 -f "MavenWrapperMain.*$PROJECT_ROOT" 2>/dev/null || true fi +SPRING_BOOT_PIDS=$(pgrep -f "LoyaltyAgentApplication|McpServerApplication" || true) +if [ -n "$SPRING_BOOT_PIDS" ]; then + echo "Killing orphaned Spring Boot applications..." + pkill -9 -f "LoyaltyAgentApplication|McpServerApplication" 2>/dev/null || true +fi + echo "==========================================" echo "Starting Loyalty Agent Services" echo "==========================================" @@ -44,7 +50,7 @@ echo "==========================================" # 1. Start MCP Server echo "Starting loyalty-mcp-server..." cd "$PROJECT_ROOT/loyalty-mcp-server" -../mvnw spring-boot:run -Dmaven.test.skip=true > "$LOG_DIR/loyalty-mcp-server.log" 2>&1 & +../mvnw -q spring-boot:run -Dmaven.test.skip=true > "$LOG_DIR/loyalty-mcp-server.log" 2>&1 & MCP_PID=$! echo "loyalty-mcp-server started with PID: $MCP_PID" @@ -57,14 +63,14 @@ echo "loyalty-mcp-server is up!" # Start nodemon watcher for MCP Server echo "Starting nodemon watcher for loyalty-mcp-server..." -npx -y nodemon --watch src -e java,xml,yml,yaml,properties --exec "$PROJECT_ROOT/mvnw compile" > "$LOG_DIR/loyalty-mcp-server-nodemon.log" 2>&1 & +npx -y nodemon --quiet --watch src -e java,xml,yml,yaml,properties --exec "$PROJECT_ROOT/mvnw -q compile" > "$LOG_DIR/loyalty-mcp-server-nodemon.log" 2>&1 & MCP_NODEMON_PID=$! # 2. Start Agent Service echo "Starting loyalty-agent..." cd "$PROJECT_ROOT/loyalty-agent" -../mvnw spring-boot:run -Dmaven.test.skip=true > "$LOG_DIR/loyalty-agent.log" 2>&1 & +../mvnw -q spring-boot:run -Dmaven.test.skip=true > "$LOG_DIR/loyalty-agent.log" 2>&1 & AGENT_PID=$! echo "loyalty-agent started with PID: $AGENT_PID" @@ -77,7 +83,7 @@ echo "loyalty-agent is up!" # Start nodemon watcher for Agent Service echo "Starting nodemon watcher for loyalty-agent..." -npx -y nodemon --watch src -e java,xml,yml,yaml,properties --exec "$PROJECT_ROOT/mvnw compile" > "$LOG_DIR/loyalty-agent-nodemon.log" 2>&1 & +npx -y nodemon --quiet --watch src -e java,xml,yml,yaml,properties --exec "$PROJECT_ROOT/mvnw -q compile" > "$LOG_DIR/loyalty-agent-nodemon.log" 2>&1 & AGENT_NODEMON_PID=$! diff --git a/sub_agent_deep_dive.md b/sub_agent_deep_dive.md deleted file mode 100644 index 1cda439..0000000 --- a/sub_agent_deep_dive.md +++ /dev/null @@ -1,544 +0,0 @@ -# Sub-Agent Architecture — Deep Dive (5 câu hỏi) - -Dựa trên feedback và khảo sát codebase MCP server + loyalty-agent. - ---- - -## 1. MCP Tool Design cho Loyalty Multi-Module - -### Hiện trạng - -``` -loyalty-mcp-server/ - └── tool/ - ├── CampaignTools.java (7 tools: search, count, getById, getByIds, generateId, checkId, create) - └── CampaignRuleTools.java (7 tools: search, count, getById, getByIds, generateId, checkId, create) -``` - -Tổng: **14 tools**, tất cả expose qua 1 MCP SSE endpoint duy nhất (`/sse`). Agent nhận hết 14 tool definitions → context window bị ăn ~1000+ tokens chỉ cho tool schema. - -### Loyalty có nhiều module hơn - -Nhìn vào `client/api/` đã có sẵn `MemberTierApi.java`, `PointPoolApi.java`. Tương lai sẽ có: - -``` -┌─────────────────────────────────────────────────────────┐ -│ LOYALTY CORE MODULES │ -├──────────────┬──────────────┬──────────────┬────────────┤ -│ Reward │ Member │ Transaction │ Partner │ -│ │ │ │ │ -│ • Campaign │ • Profile │ • TxnCode │ • Merchant │ -│ • Rule │ • Tier │ • TxnHistory │ • Store │ -│ • Formula │ • Card │ • Counter │ • Offer │ -│ • Schedule │ • Segment │ • Statement │ │ -│ • Counter │ • Point Pool │ • Batch │ │ -│ • Alert │ • Enrollment │ │ │ -└──────────────┴──────────────┴──────────────┴────────────┘ -``` - -Nếu mỗi module có 5-7 tools → **tổng 30-50 tools**. 1 agent không thể xử lý tốt. - -### Đề xuất: Tool Groups + Agent Scoping - -**Phương án A — Grouped Tools trong 1 MCP Server (Đơn giản nhất)** - -Giữ 1 MCP server, nhưng tools tổ chức theo package/group. Agent-side filter theo tên prefix: - -``` -loyalty-mcp-server/ - └── tool/ - ├── reward/ - │ ├── CampaignTools.java → tool names: reward_searchCampaigns, reward_getCampaignById... - │ └── CampaignRuleTools.java → tool names: reward_searchRules, reward_getRuleById... - ├── member/ - │ ├── MemberTools.java → tool names: member_searchMembers, member_getMemberById... - │ └── TierTools.java → tool names: member_searchTiers... - └── transaction/ - └── TransactionTools.java → tool names: txn_searchTransactions... -``` - -Agent-side filtering: - -```java -// Trong QueryAgent — chỉ load read tools của reward module -List tools = toolInterceptor.getWrappedTools(sessionId, messageId) - .stream() - .filter(t -> { - String name = t.getToolDefinition().name(); - return name.startsWith("reward_") && !name.contains("create") && !name.contains("generate"); - }) - .toList(); -``` - -> [!TIP] -> **Ưu điểm**: Không cần đổi MCP server architecture. Chỉ rename tools + filter ở agent. -> **Nhược điểm**: Tất cả tool definitions vẫn load vào memory agent, chỉ filter khi truyền cho ChatClient. - ---- - -**Phương án B — Multi-MCP Endpoint (Mạnh hơn, phức tạp hơn)** - -Mỗi domain module là 1 MCP endpoint riêng. Agent chỉ connect tới endpoint cần thiết: - -```yaml -# application.yml — loyalty-agent -spring: - ai: - mcp: - client: - sse: - connections: - reward: - url: http://localhost:9331/sse # Reward tools only - member: - url: http://localhost:9331/member/sse # Member tools - transaction: - url: http://localhost:9331/txn/sse # Transaction tools -``` - -Nhưng Spring AI MCP client hiện tại auto-discover **tất cả** connections. Để chọn lọc, cần custom `ToolCallbackProvider` filtering. - -> [!NOTE] -> **Khuyến nghị cho bước đầu**: Dùng **Phương án A** (1 MCP server, tool name prefix, agent-side filter). Đơn giản, hiệu quả, không cần thay đổi infrastructure. - ---- - -**Phương án C — MCP Server per Module (Micro-MCP)** - -Tách thành nhiều MCP server process: - -``` -┌───────────────┐ ┌───────────────┐ ┌───────────────┐ -│ reward-mcp │ │ member-mcp │ │ txn-mcp │ -│ :9331/sse │ │ :9332/sse │ │ :9333/sse │ -│ Campaign* │ │ Member* │ │ Transaction* │ -│ Rule* │ │ Tier* │ │ Counter* │ -└───────────────┘ └───────────────┘ └───────────────┘ -``` - -> [!WARNING] -> Overkill cho giai đoạn hiện tại. Chỉ hợp lý khi mỗi module có team riêng hoặc deploy riêng. - -### Tóm tắt: Phương án nào? - -| Phương án | Độ phức tạp | Khi nào dùng | -|---|---|---| -| **A. Prefix + Filter** | ⭐ Thấp | **Nên bắt đầu từ đây** — 5-20 tools | -| **B. Multi-MCP Endpoint** | ⭐⭐ Trung bình | 20-50 tools, cần isolation | -| **C. Micro-MCP Server** | ⭐⭐⭐ Cao | 50+ tools, multi-team | - ---- - -## 2. Shared Memory & Context Injection cho Creator Agent - -### Vấn đề - -User flow thực tế khi tạo campaign + rule: - -``` -User: "danh sách chiến dịch" → QueryAgent, searchCampaigns -User: "xem thể lệ Happy Birthday" → QueryAgent, searchRules -User: "tạo thêm 1 rule mới tương tự" → CreatorAgent — CẦN BIẾT context trước đó! - Cần biết: campaignId, rule template, - formula type, existing rules... -``` - -CreatorAgent cần: -1. **Chat history** — biết user đang nói về campaign nào -2. **Domain context** — query các thông tin liên quan (pools, tiers, counters...) -3. **Validation data** — kiểm tra ID hợp lệ, trùng lặp... - -### Kiến trúc Memory - -``` -┌──────────────────────────────────────────────────────────────┐ -│ SHARED MEMORY LAYER │ -│ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ InMemoryChatMemory (existing) │ │ -│ │ Key: conversationId (sessionId) │ │ -│ │ Shared across ALL agents │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ SessionContext (NEW) │ │ -│ │ Key: sessionId │ │ -│ │ Stores: activeCampaignId, lastQueryResult, │ │ -│ │ workflowState, userPreferences │ │ -│ └─────────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────────┘ - │ │ │ - ┌────┴────┐ ┌────┴────┐ ┌────┴────┐ - │ Chat │ │ Query │ │ Creator │ - │ Agent │ │ Agent │ │ Agent │ - │ │ │ writes │ │ reads │ - │ │ │ context│ │ context │ - └─────────┘ └─────────┘ └─────────┘ -``` - -**Chat history (InMemoryChatMemory)** đã shared — tất cả agent dùng chung `conversationId` làm key. Khi user chuyển từ QueryAgent sang CreatorAgent, history vẫn giữ nguyên. - -**SessionContext** mới — lưu structured data: - -```java -public class SessionContext { - private String activeCampaignId; // Campaign đang được nói tới - private String activeCampaignName; // Tên để inject vào prompt - private String lastToolResult; // Kết quả tool gần nhất (compressed) - private Map metadata; // Flexible key-value -} -``` - -### Context Enrichment cho Creator Agent - -Khi Creator Agent bắt đầu tạo rule, nó cần query nhiều thông tin. Có 2 cách: - -**Cách 1: LLM tự gọi tools (hiện tại)** - -Để LLM quyết định gọi tools nào để lấy thông tin. Vấn đề: model nhỏ hay quên hoặc gọi sai. - -**Cách 2: Code-driven Context Injection (khuyên dùng)** - -```java -public class CreatorAgent implements Agent { - - @Override - public void execute(ChatRequest request, String sessionId) { - SessionContext ctx = sessionContextStore.get(sessionId); - - // Auto-enrich: trước khi gọi LLM, query sẵn thông tin cần thiết - StringBuilder enrichedContext = new StringBuilder(); - - if (ctx.getActiveCampaignId() != null) { - // Auto-fetch campaign details - Campaign campaign = campaignService.findById(ctx.getActiveCampaignId()); - enrichedContext.append("Campaign hiện tại: ") - .append(campaign.getName()) - .append(" (ID: ").append(campaign.getId()).append(")\n"); - - // Auto-fetch existing rules - List existingRules = ruleService.findByCampaignId(campaign.getId()); - enrichedContext.append("Rules hiện có: ").append(existingRules.size()).append("\n"); - - // Auto-fetch available pools - List pools = poolService.getAll(); - enrichedContext.append("Point pools: ") - .append(pools.stream().map(PoolDefinition::getName).toList()).append("\n"); - } - - chatClient.prompt() - .system(CREATOR_SYSTEM_PROMPT) - .user(enrichedContext + "\n\n" + request.prompt()) - ... - } -} -``` - -> [!IMPORTANT] -> **Code-driven enrichment** tốt hơn "LLM tự gọi tool" vì: -> 1. Deterministic — không phụ thuộc vào khả năng reasoning của model nhỏ -> 2. Nhanh hơn — gọi API trực tiếp, không qua LLM → tool → LLM loop -> 3. Đầy đủ hơn — developer biết rõ cần context gì - ---- - -## 3. Multi-Ollama Endpoints (Thinking vs Non-Thinking) - -### Setup - -``` -┌──────────────────┐ ┌──────────────────┐ -│ Máy A (hiện tại)│ │ Máy B (mới) │ -│ 192.168.99.10 │ │ 192.168.99.XX │ -│ │ │ │ -│ qwen3.5:4b │ │ qwen2.5:3b │ -│ (thinking ON) │ │ (non-thinking) │ -│ GPU: ... │ │ GPU: ... │ -│ │ │ │ -│ Dùng cho: │ │ Dùng cho: │ -│ - Query Agent │ │ - Chat Agent │ -│ - Creator Agent │ │ - Formatter │ -└──────────────────┘ └──────────────────┘ -``` - -### Spring AI Config — Model Registry - -```yaml -# application.yml -spring: - ai: - ollama: - # Default (thinking model) - base-url: http://192.168.99.10:11434 - chat: - options: - model: qwen3.5:4b - temperature: 0.3 - num_ctx: 32768 - -# Custom — non-thinking endpoint -agent: - models: - chat: - base-url: http://192.168.99.XX:11434 - model: qwen2.5:3b - temperature: 0.7 - query: - base-url: http://192.168.99.10:11434 - model: qwen3.5:4b - temperature: 0.3 - creator: - base-url: http://192.168.99.10:11434 - model: qwen3.5:4b - temperature: 0.2 -``` - -### Implementation - -```java -@Configuration -public class MultiModelConfig { - - @Bean - public OllamaApi thinkingOllamaApi() { - return OllamaApi.builder() - .baseUrl("http://192.168.99.10:11434") - .build(); - } - - @Bean - public OllamaApi fastOllamaApi() { - return OllamaApi.builder() - .baseUrl("http://192.168.99.XX:11434") - .build(); - } - - @Bean("chatAgent.chatClient") - public ChatClient chatAgentClient( - @Qualifier("fastOllamaApi") OllamaApi api, - ToolCallingManager toolCallingManager) { - var model = OllamaChatModel.builder() - .ollamaApi(api) - .defaultOptions(OllamaChatOptions.builder() - .model("qwen2.5:3b") - .temperature(0.7) - .build()) - .toolCallingManager(toolCallingManager) - .build(); - return ChatClient.builder(model).build(); - } - - @Bean("queryAgent.chatClient") - public ChatClient queryAgentClient( - @Qualifier("thinkingOllamaApi") OllamaApi api, - ToolCallingManager toolCallingManager) { - var model = OllamaChatModel.builder() - .ollamaApi(api) - .defaultOptions(OllamaChatOptions.builder() - .model("qwen3.5:4b") - .temperature(0.3) - .build()) - .toolCallingManager(toolCallingManager) - .build(); - return ChatClient.builder(model).build(); - } -} -``` - -> [!TIP] -> **Thay model dễ dàng**: Khi có model tốt hơn (ví dụ `qwen3:8b-no-think`), chỉ cần đổi trong config YAML, không sửa code. - ---- - -## 4. Latency Optimization — Tránh Chain Agents - -### Vấn đề - -Chain 2 agents nối tiếp → latency gấp đôi: - -``` -BAD: Query Agent (10s) → Formatter Agent (8s) = 18s tổng -``` - -### Giải pháp: KHÔNG chain — mỗi agent tự hoàn chỉnh - -``` -GOOD: Query Agent (10s) — tự format trong cùng 1 prompt = 10s tổng -``` - -**Cách thực hiện**: Thay vì Formatter Agent riêng, nhúng formatting instruction vào `agent_instruction` (đã có sẵn!): - -```java -// Trong CampaignService.java (MCP server) — ĐÃ CÓ SẴN -return Result.of(campaigns, """ - Trả lời tự nhiên bằng tiếng Việt. Không dùng markdown table. - Hiển thị mỗi chiến dịch theo dạng danh sách đánh số: - **[Tên]** (`[campaignId]`) — Owner: [ownerName]... - """); -``` - -`agent_instruction` pattern hiện tại **đã là formatter** — nằm trong tool result, không tốn thêm LLM call. Rất thông minh. - -### Nơi nào thực sự cần parallel/async - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Creator Agent — Tạo Rule Flow │ -│ │ -│ 1. User: "tạo rule mới" │ -│ │ -│ 2. Context Enrichment (PARALLEL, code-driven, ~500ms): │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ getCampaign() │ │ getRules() │ │ getPools() │ │ -│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ -│ └─────────────────┼─────────────────┘ │ -│ ▼ │ -│ 3. LLM Call (SINGLE, ~8-12s): │ -│ System: Creator prompt + enriched context │ -│ User: "tạo rule mới" │ -│ → LLM hỏi thêm hoặc confirm │ -│ │ -│ Total: ~9-13s (vs 17s+ hiện tại vì LLM phải tự gọi tool) │ -└─────────────────────────────────────────────────────────────┘ -``` - -**Parallel context fetching** (Java `CompletableFuture`): - -```java -// Context enrichment — chạy song song, mất ~500ms thay vì 3 LLM calls -CompletableFuture campaignFuture = CompletableFuture.supplyAsync( - () -> campaignService.findById(campaignId)); -CompletableFuture> rulesFuture = CompletableFuture.supplyAsync( - () -> ruleService.findByCampaignId(campaignId)); -CompletableFuture> poolsFuture = CompletableFuture.supplyAsync( - () -> poolService.getAll()); - -// Wait all (parallel) — ~500ms -CompletableFuture.allOf(campaignFuture, rulesFuture, poolsFuture).join(); - -// Inject into prompt -String context = buildContext(campaignFuture.get(), rulesFuture.get(), poolsFuture.get()); -``` - -### Tóm tắt chiến lược latency - -| Kỹ thuật | Tiết kiệm | Áp dụng | -|---|---|---| -| Tắt thinking cho Chat Agent | 17s → 2-3s | Chat Agent | -| Formatter = `agent_instruction` | Bỏ 1 LLM call (~8s) | Query Agent | -| Parallel context fetch | 3 API calls: 1.5s → 0.5s | Creator Agent | -| Code-driven enrichment | Bỏ 2-3 tool-call loops (~20s) | Creator Agent | - ---- - -## 5. On-Premise Fallback Strategy (Không dùng Cloud) - -### Constraint: Bảo mật dữ liệu → chỉ chạy on-premise - -Bỏ hoàn toàn phương án cloud. Thay vào đó: - -### Strategy: Multi-Tier On-Premise Models - -``` -┌─────────────────────────────────────────────────────────────┐ -│ ON-PREMISE MODEL TIERS │ -│ │ -│ Tier 1 (Fast, Light) Tier 2 (Strong, Slow) │ -│ ┌──────────────────┐ ┌──────────────────┐ │ -│ │ Máy B │ │ Máy A (GPU mạnh) │ │ -│ │ qwen2.5:3b │ │ qwen3.5:4b │ │ -│ │ non-thinking │ │ thinking │ │ -│ │ ~2-3s/response │ │ ~8-15s/response │ │ -│ │ │ │ │ │ -│ │ Dùng cho: │ │ Dùng cho: │ │ -│ │ - Chat Agent │ │ - Query Agent │ │ -│ │ - Simple Q&A │ │ - Creator Agent │ │ -│ │ - Validation │ │ - Complex reasoning│ │ -│ └──────────────────┘ └──────────────────┘ │ -│ │ -│ Tương lai: Tier 3 (nếu có GPU mạnh hơn) │ -│ ┌──────────────────┐ │ -│ │ Máy C (A100/H100) │ │ -│ │ qwen3.5:14b │ │ -│ │ cho critical tasks │ │ -│ └──────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ -``` - -### Khi nào escalate từ Tier 1 → Tier 2? - -Không cần "confidence-based routing" phức tạp. Dùng **Intent-based routing** đơn giản: - -```java -// Router quyết định dựa trên intent, không dựa trên confidence -switch (intent) { - case CONVERSATION → chatAgent; // Tier 1 (fast model) - case CAMPAIGN_QUERY, - RULE_QUERY, - UNKNOWN → queryAgent; // Tier 2 (thinking model) - case CAMPAIGN_CREATE, - RULE_CREATE → creatorAgent; // Tier 2 (thinking model) -} -``` - -### Retry Strategy (nếu model trả kết quả kém) - -Thay vì fallback lên cloud, **retry cùng model với prompt khác**: - -```java -// Nếu tool result rỗng hoặc LLM trả "tôi không biết" -if (isLowQualityResponse(response)) { - // Retry 1: thêm few-shot example vào prompt - // Retry 2: simplify câu hỏi - // Retry 3: báo user "Không tìm thấy, vui lòng thử cách khác" -} -``` - -> [!IMPORTANT] -> **Không retry bằng model khác** — retry bằng **prompt khác** trên cùng model hiệu quả hơn cho small LLMs. Thêm 1 ví dụ cụ thể vào prompt thường giải quyết 80% trường hợp model trả sai. - ---- - -## Tổng kết — Bước tiếp theo - -``` -┌──────────────────────────────────────────────────────────────┐ -│ KIẾN TRÚC ĐỀ XUẤT CUỐI │ -│ │ -│ Máy B (fast) Máy A (strong) │ -│ ┌────────────┐ ┌────────────┐ │ -│ │ qwen2.5:3b │ │ qwen3.5:4b │ │ -│ └─────┬──────┘ └─────┬──────┘ │ -│ │ │ │ -│ ▼ ▼ │ -│ ┌──────────┐ ┌──────────────────┐ │ -│ │ChatAgent │ │ QueryAgent │ │ -│ │(no tools)│ │ (read tools, │ │ -│ │ │ │ prefix filter) │ │ -│ └──────────┘ └──────────────────┘ │ -│ ┌──────────────────┐ │ -│ │ CreatorAgent │ │ -│ │ (write tools, │ │ -│ │ code-driven │ │ -│ │ enrichment) │ │ -│ └──────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────┐ │ -│ │ Shared Memory Layer │ │ -│ │ InMemoryChatMemory + SessionContext │ │ -│ └─────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────┐ │ -│ │ MCP Server (1 process) │ │ -│ │ Tools: reward_*, member_*, txn_* │ │ -│ │ Agent-side filtering by prefix │ │ -│ └─────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────────┘ -``` - -Nếu anh thấy hướng này ổn, mình có thể tạo change proposal (`/opsx-propose`) để bắt đầu implement từng phase: - -1. **Phase 1**: Agent interface + ChatAgent (tắt thinking, model riêng) — cải thiện "hello" ngay -2. **Phase 2**: Tool prefix naming + QueryAgent (filtered tools) — giảm tool confusion -3. **Phase 3**: CreatorAgent + SessionContext + code-driven enrichment — nâng chất lượng tạo campaign/rule -4. **Phase 4**: Multi-Ollama config + ModelRegistry — hoàn thiện infra