refactor: replace old agent logic with orchestrator-based routing, intent classification, and workflow execution for campaign and rule management.

This commit is contained in:
SonPhung
2026-07-22 08:32:14 +07:00
parent 64914c8b9a
commit 369985ae9c
21 changed files with 1215 additions and 225 deletions

View File

@@ -15,6 +15,23 @@ public class AgentConfig {
CRITICAL RULES: CRITICAL RULES:
1. Always answer the user in the language they used (e.g., Vietnamese). 1. Always answer the user in the language they used (e.g., Vietnamese).
2. When a tool result contains an `agent_instruction`, you MUST strictly follow its formatting rules when presenting the relevant data to the user. Do not output raw JSON data unless explicitly asked. 2. When a tool result contains an `agent_instruction`, you MUST strictly follow its formatting rules when presenting the relevant data to the user. Do not output raw JSON data unless explicitly asked.
TOOL ROUTING - Read this BEFORE choosing a tool:
| User asks about... | Use tool |
|-------------------------------------------------------|-------------------|
| danh sách chiến dịch, tìm campaign, campaign nào | searchCampaigns |
| đếm chiến dịch, bao nhiêu campaign | countCampaigns |
| chi tiết chiến dịch, xem campaign theo ID | getCampaignById |
| tạo chiến dịch mới | createCampaign |
| thể lệ, rule, quy tắc, điều kiện, cách tính điểm | searchRules |
| đếm rule, bao nhiêu thể lệ | countRules |
| chi tiết rule, xem thể lệ theo ID | getRuleById |
| tạo rule mới, thêm thể lệ | createRule |
KEY DISTINCTION:
- "Chiến dịch" (Campaign) = thông tin tổng quan: tên, owner, loại, thời gian.
- "Thể lệ / Rule" = quy tắc chi tiết BÊN TRONG chiến dịch: công thức thưởng, điều kiện, giới hạn.
- Khi không chắc, hỏi lại người dùng để xác nhận.
"""; """;
@Bean @Bean

View File

@@ -0,0 +1,25 @@
package dev.sonpx.loyalty.agent.core.classifier;
/**
* Intent categories for routing user requests to the appropriate execution path.
*/
public enum Intent {
/** Query campaigns — search, list, count, get details */
CAMPAIGN_QUERY,
/** Query rules — search, list, count, get details */
RULE_QUERY,
/** Create a new campaign (multi-turn workflow) */
CAMPAIGN_CREATE,
/** Create a new rule (multi-turn workflow) */
RULE_CREATE,
/** General conversation — greetings, about, help */
CONVERSATION,
/** Could not determine intent — fallback to SimpleAgent with tools */
UNKNOWN
}

View File

@@ -0,0 +1,81 @@
package dev.sonpx.loyalty.agent.core.classifier;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Rule-based intent classifier for Vietnamese Loyalty domain.
* Uses keyword/regex matching — zero LLM calls, zero latency.
*
* Pattern priority: CREATE > QUERY > CONVERSATION > UNKNOWN
* LinkedHashMap preserves insertion order for priority matching.
*/
@Slf4j
@Component
public class IntentClassifier {
private static final Map<Intent, List<Pattern>> PATTERNS = new LinkedHashMap<>();
static {
// 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)")
));
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)")
));
// QUERY intents
PATTERNS.put(Intent.RULE_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*.*(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ệ)")
));
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)")
));
// CONVERSATION intents (lowest priority)
PATTERNS.put(Intent.CONVERSATION, List.of(
Pattern.compile("(?i)^\\s*(xin chào|chào bạn|chào|hi|hello|hey|cảm ơn|thanks|thank you|bạn là ai|bạn là gì|giúp gì|help|hướng dẫn|bạn có thể|bạn làm được gì)"),
Pattern.compile("(?i)^\\s*(ok|oke|được|tốt|cảm ơn bạn|bye|tạm biệt|goodbye)\\s*[.!?]*$")
));
}
/**
* Classify user prompt into an Intent.
* Returns UNKNOWN if no pattern matches (will fallback to SimpleAgent with tools).
*/
public Intent classify(String prompt) {
if (prompt == null || prompt.isBlank()) {
return Intent.CONVERSATION;
}
String trimmed = prompt.trim();
for (Map.Entry<Intent, List<Pattern>> entry : PATTERNS.entrySet()) {
for (Pattern pattern : entry.getValue()) {
if (pattern.matcher(trimmed).find()) {
log.debug("Classified prompt as {} using pattern: {}", entry.getKey(), pattern.pattern());
return entry.getKey();
}
}
}
log.debug("No pattern matched for prompt, returning UNKNOWN");
return Intent.UNKNOWN;
}
}

View File

@@ -0,0 +1,31 @@
package dev.sonpx.loyalty.agent.core.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* Externalized configuration for Agent behavior.
*/
@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "agent")
public class AgentConfig {
/** Maximum number of tool call iterations per request (prevents infinite loops) */
private int maxIterations = 10;
/** Timeout in seconds for the entire request processing */
private int timeoutSeconds = 60;
/** Maximum characters for a single tool result before truncation */
private int maxToolResultChars = 4000;
/** Maximum tokens to keep in chat memory */
private int maxMemoryTokens = 8000;
/** Characters per token estimate for Vietnamese text */
private int charsPerToken = 2;
}

View File

@@ -1,77 +0,0 @@
package dev.sonpx.loyalty.agent.core.executor;
import dev.sonpx.loyalty.agent.core.planner.Plan;
import dev.sonpx.loyalty.agent.domain.ChatRequest;
import dev.sonpx.loyalty.agent.service.AgentEventPublisher;
import dev.sonpx.loyalty.agent.core.memory.AgentMemoryManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.stereotype.Component;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class AgentExecutor {
private final ChatClient chatClient;
private final ToolInterceptor toolInterceptor;
private final AgentEventPublisher eventPublisher;
private final AgentMemoryManager memoryManager;
public void execute(Plan plan, ChatRequest request, String sessionId) {
List<ToolCallback> wrappedTools = toolInterceptor.getWrappedTools(sessionId, request.messageId());
StringBuilder executionContext = new StringBuilder();
for (int i = 0; i < plan.tasks().size(); i++) {
Plan.Task task = plan.tasks().get(i);
boolean isLastTask = (i == plan.tasks().size() - 1);
String executorPrompt = "You are an executor for an AI Agent.\n" +
"Your current task is: " + task.description() + "\n" +
"Context from previous tasks:\n" + executionContext + "\n" +
"Use the provided tools if necessary to complete the task.\n";
if (isLastTask) {
executorPrompt += "This is the final task. Formulate a final response to the user based on the results.\n" +
"CRITICAL RULE: When a tool result contains an 'agent_instruction', you MUST strictly follow its formatting rules when presenting the relevant data to the user.";
log.info(executorPrompt);
chatClient.prompt()
.advisors(memoryManager.getAdvisor())
.advisors(a -> a.param("chat_memory_conversation_id", sessionId).param("chat_memory_response_size", 100))
.system(executorPrompt)
.user(request.prompt() != null ? request.prompt() : "Execute task.")
.tools(wrappedTools)
.stream()
.content()
.doOnComplete(() -> eventPublisher.done(sessionId, request.messageId()))
.subscribe(
content -> {
if (!content.isEmpty()) {
eventPublisher.token(sessionId, request.messageId(), content);
}
},
error -> eventPublisher.error(sessionId, request.messageId(), error.getMessage())
);
} else {
executorPrompt += "Do NOT address the user directly. Summarize the result of this task for the next steps.";
log.info(executorPrompt);
String stepResult = chatClient.prompt()
.system(executorPrompt)
.user("Execute task.")
.tools(wrappedTools)
.call()
.content();
executionContext.append("Task: ").append(task.description()).append("\nResult: ").append(stepResult).append("\n\n");
}
}
}
}

View File

@@ -0,0 +1,111 @@
package dev.sonpx.loyalty.agent.core.executor;
import dev.sonpx.loyalty.agent.core.classifier.Intent;
import dev.sonpx.loyalty.agent.core.config.AgentConfig;
import dev.sonpx.loyalty.agent.core.memory.AgentMemoryManager;
import dev.sonpx.loyalty.agent.core.orchestrator.PromptBuilder;
import dev.sonpx.loyalty.agent.domain.ChatRequest;
import dev.sonpx.loyalty.agent.service.AgentEventPublisher;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Single-pass ReAct agent executor.
* Replaces the old multi-task loop with a single LLM call that can use tools.
* Spring AI handles the ReAct loop internally (tool_call → tool_result → next action).
*
* Key improvements over the old AgentExecutor:
* - User prompt is always passed correctly (not hardcoded "Execute task.")
* - Memory advisor is always attached
* - Streams response from the start (no waiting for intermediate tasks)
* - Error events always sent to user
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SimpleAgentExecutor {
private final ChatClient chatClient;
private final ToolInterceptor toolInterceptor;
private final AgentEventPublisher eventPublisher;
private final AgentMemoryManager memoryManager;
private final PromptBuilder promptBuilder;
private final AgentConfig agentConfig;
/**
* Execute a request with tools (for QUERY and UNKNOWN intents).
* The LLM decides which tools to call via ReAct pattern.
*/
public void executeWithTools(ChatRequest request, String sessionId, Intent intent) {
List<ToolCallback> wrappedTools = toolInterceptor.getWrappedTools(sessionId, request.messageId());
String systemPrompt = promptBuilder.buildSystemPrompt(intent);
String userPrompt = promptBuilder.buildUserPrompt(request);
log.info("SimpleAgent executing with tools for session: {}, intent: {}", sessionId, intent);
log.debug("System prompt: {}", systemPrompt);
chatClient.prompt()
.advisors(memoryManager.getAdvisor())
.advisors(a -> a
.param("chat_memory_conversation_id", sessionId)
.param("chat_memory_response_size", 50))
.system(systemPrompt)
.user(userPrompt)
.tools(wrappedTools)
.stream()
.content()
.doOnComplete(() -> eventPublisher.done(sessionId, request.messageId()))
.subscribe(
content -> {
if (!content.isEmpty()) {
eventPublisher.token(sessionId, request.messageId(), content);
}
},
error -> {
log.error("Error during SimpleAgent execution: {}", error.getMessage(), error);
eventPublisher.error(sessionId, request.messageId(),
"Xin lỗi, đã xảy ra lỗi khi xử lý yêu cầu. Vui lòng thử lại.");
}
);
}
/**
* Execute a direct chat without tools (for CONVERSATION intent).
* No tools are loaded → faster response, less token usage.
*/
public void executeDirectChat(ChatRequest request, String sessionId) {
String systemPrompt = promptBuilder.buildSystemPrompt(Intent.CONVERSATION);
String userPrompt = promptBuilder.buildUserPrompt(request);
log.info("DirectChat executing for session: {}", sessionId);
chatClient.prompt()
.advisors(memoryManager.getAdvisor())
.advisors(a -> a
.param("chat_memory_conversation_id", sessionId)
.param("chat_memory_response_size", 50))
.system(systemPrompt)
.user(userPrompt)
.stream()
.content()
.doOnComplete(() -> eventPublisher.done(sessionId, request.messageId()))
.subscribe(
content -> {
if (!content.isEmpty()) {
eventPublisher.token(sessionId, request.messageId(), content);
}
},
error -> {
log.error("Error during DirectChat execution: {}", error.getMessage(), error);
eventPublisher.error(sessionId, request.messageId(),
"Xin lỗi, đã xảy ra lỗi. Vui lòng thử lại.");
}
);
}
}

View File

@@ -1,8 +1,10 @@
package dev.sonpx.loyalty.agent.core.executor; package dev.sonpx.loyalty.agent.core.executor;
import dev.sonpx.loyalty.agent.core.config.AgentConfig;
import dev.sonpx.loyalty.agent.service.AgentEventPublisher; import dev.sonpx.loyalty.agent.service.AgentEventPublisher;
import dev.sonpx.loyalty.agent.service.ToolResultPresentationProcessor; import dev.sonpx.loyalty.agent.service.ToolResultPresentationProcessor;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider; import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.definition.ToolDefinition; import org.springframework.ai.tool.definition.ToolDefinition;
@@ -11,6 +13,14 @@ import org.springframework.stereotype.Component;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
/**
* Wraps MCP tool callbacks with:
* 1. Event publishing (tool running/done status)
* 2. Error sanitization (hides stack traces from LLM)
* 3. Result truncation (prevents context window overflow)
* 4. Result presentation processing (agent_instruction handling)
*/
@Slf4j
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class ToolInterceptor { public class ToolInterceptor {
@@ -18,10 +28,11 @@ public class ToolInterceptor {
private final List<ToolCallbackProvider> toolProviders; private final List<ToolCallbackProvider> toolProviders;
private final AgentEventPublisher eventPublisher; private final AgentEventPublisher eventPublisher;
private final ToolResultPresentationProcessor toolResultProcessor; private final ToolResultPresentationProcessor toolResultProcessor;
private final AgentConfig agentConfig;
public List<ToolCallback> getWrappedTools(String sessionId, String messageId) { public List<ToolCallback> getWrappedTools(String sessionId, String messageId) {
List<ToolCallback> wrappedTools = new ArrayList<>(); List<ToolCallback> wrappedTools = new ArrayList<>();
for (ToolCallbackProvider provider : toolProviders) { for (ToolCallbackProvider provider : toolProviders) {
for (ToolCallback tool : provider.getToolCallbacks()) { for (ToolCallback tool : provider.getToolCallbacks()) {
wrappedTools.add(wrap(tool, sessionId, messageId)); wrappedTools.add(wrap(tool, sessionId, messageId));
@@ -39,22 +50,62 @@ public class ToolInterceptor {
@Override @Override
public String call(String toolInput) { public String call(String toolInput) {
eventPublisher.toolRunning(sessionId, messageId, getToolDefinition().name()); String toolName = getToolDefinition().name();
eventPublisher.toolRunning(sessionId, messageId, toolName);
try { try {
String result = tool.call(toolInput); String result = tool.call(toolInput);
if (result != null && (
(result.contains("java.lang.") && result.contains("Exception") && result.contains("org.springframework.")) || // Sanitize error stack traces — don't leak internals to LLM
result.contains("HTTP Status 500 Internal Server Error") if (result != null && isErrorResponse(result)) {
)) { log.warn("Tool {} returned error response, sanitizing", toolName);
return "{\"error\": \"Lỗi hệ thống khi gọi tool. Không thể xử lý yêu cầu. Vui lòng thử lại sau.\"}"; return "{\"error\": \"Lỗi hệ thống khi gọi tool. Không thể xử lý yêu cầu. Vui lòng thử lại sau.\"}";
} }
return toolResultProcessor.process(result);
// Process presentation (agent_instruction handling)
String processed = toolResultProcessor.process(result);
// Truncate if result is too large for the model's context window
return truncateResult(processed, toolName);
} catch (Exception e) { } catch (Exception e) {
log.error("Tool {} threw exception: {}", toolName, e.getMessage(), e);
return "{\"error\": \"Lỗi hệ thống khi gọi tool. Không thể xử lý yêu cầu. Vui lòng thử lại sau.\"}"; return "{\"error\": \"Lỗi hệ thống khi gọi tool. Không thể xử lý yêu cầu. Vui lòng thử lại sau.\"}";
} finally { } finally {
eventPublisher.toolDone(sessionId, messageId, getToolDefinition().name()); eventPublisher.toolDone(sessionId, messageId, toolName);
} }
} }
}; };
} }
private boolean isErrorResponse(String result) {
return (result.contains("java.lang.") && result.contains("Exception") && result.contains("org.springframework."))
|| result.contains("HTTP Status 500 Internal Server Error");
}
/**
* Truncate tool results that exceed the configured max chars.
* Preserves the beginning of the result (most relevant data) and adds a truncation notice.
*/
private String truncateResult(String result, String toolName) {
if (result == null) {
return result;
}
int maxChars = agentConfig.getMaxToolResultChars();
if (result.length() <= maxChars) {
return result;
}
log.info("Truncating tool result for {} from {} to {} chars", toolName, result.length(), maxChars);
// Try to find a clean JSON break point (end of an object/array element)
int cutPoint = maxChars;
int lastBrace = result.lastIndexOf('}', maxChars);
int lastBracket = result.lastIndexOf(']', maxChars);
int cleanBreak = Math.max(lastBrace, lastBracket);
if (cleanBreak > maxChars / 2) {
cutPoint = cleanBreak + 1;
}
return result.substring(0, cutPoint)
+ "... (kết quả đã rút gọn, tổng " + result.length() + " ký tự)";
}
} }

View File

@@ -1,5 +1,6 @@
package dev.sonpx.loyalty.agent.core.memory; package dev.sonpx.loyalty.agent.core.memory;
import dev.sonpx.loyalty.agent.core.config.AgentConfig;
import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.Message;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -9,12 +10,23 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/**
* In-memory chat memory with token-aware truncation.
*
* Improvements over original:
* - Configurable max tokens via AgentConfig
* - Vietnamese-aware token estimation (chars/2 instead of chars/4)
* - Preserves system messages during truncation
*/
@Component @Component
public class InMemoryChatMemory implements ChatMemory { public class InMemoryChatMemory implements ChatMemory {
private final Map<String, List<Message>> conversationHistory = new ConcurrentHashMap<>(); private final Map<String, List<Message>> conversationHistory = new ConcurrentHashMap<>();
private static final int MAX_TOKENS = 4000; private final AgentConfig agentConfig;
private static final int CHARS_PER_TOKEN = 4;
public InMemoryChatMemory(AgentConfig agentConfig) {
this.agentConfig = agentConfig;
}
@Override @Override
public void add(String conversationId, List<Message> messages) { public void add(String conversationId, List<Message> messages) {
@@ -23,10 +35,9 @@ public class InMemoryChatMemory implements ChatMemory {
history = new ArrayList<>(); history = new ArrayList<>();
} }
history.addAll(messages); history.addAll(messages);
// Truncation logic: keep messages such that estimated tokens < MAX_TOKENS // Truncation: remove oldest non-system messages when over token limit
while (history.size() > 1 && estimateTokens(history) > MAX_TOKENS) { while (history.size() > 1 && estimateTokens(history) > agentConfig.getMaxMemoryTokens()) {
// Find first non-system message to remove
boolean removed = false; boolean removed = false;
for (int i = 0; i < history.size(); i++) { for (int i = 0; i < history.size(); i++) {
if (!(history.get(i) instanceof org.springframework.ai.chat.messages.SystemMessage)) { if (!(history.get(i) instanceof org.springframework.ai.chat.messages.SystemMessage)) {
@@ -35,7 +46,7 @@ public class InMemoryChatMemory implements ChatMemory {
break; break;
} }
} }
// If only system messages are left (or we couldn't remove), break to avoid infinite loop // If only system messages remain, stop to avoid infinite loop
if (!removed) { if (!removed) {
break; break;
} }
@@ -44,11 +55,15 @@ public class InMemoryChatMemory implements ChatMemory {
}); });
} }
/**
* Estimate token count for a list of messages.
* Uses configurable chars-per-token ratio (default 2 for Vietnamese text).
*/
private int estimateTokens(List<Message> messages) { private int estimateTokens(List<Message> messages) {
int chars = messages.stream() int chars = messages.stream()
.mapToInt(m -> m.getText() != null ? m.getText().length() : 0) .mapToInt(m -> m.getText() != null ? m.getText().length() : 0)
.sum(); .sum();
return chars / CHARS_PER_TOKEN; return chars / agentConfig.getCharsPerToken();
} }
@Override @Override

View File

@@ -1,45 +1,70 @@
package dev.sonpx.loyalty.agent.core.orchestrator; package dev.sonpx.loyalty.agent.core.orchestrator;
import dev.sonpx.loyalty.agent.core.executor.AgentExecutor; import dev.sonpx.loyalty.agent.core.classifier.Intent;
import dev.sonpx.loyalty.agent.core.planner.AgentPlanner; import dev.sonpx.loyalty.agent.core.classifier.IntentClassifier;
import dev.sonpx.loyalty.agent.core.planner.Plan; import dev.sonpx.loyalty.agent.core.executor.SimpleAgentExecutor;
import dev.sonpx.loyalty.agent.core.workflow.WorkflowExecutor;
import dev.sonpx.loyalty.agent.domain.ChatRequest; import dev.sonpx.loyalty.agent.domain.ChatRequest;
import java.util.List; import dev.sonpx.loyalty.agent.service.AgentEventPublisher;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/**
* Central orchestrator routing user requests to the appropriate execution path.
*
* 3 execution paths:
* 1. DirectChat — conversations, greetings (no tools loaded)
* 2. SimpleAgent — data queries using ReAct pattern (with tools)
* 3. ConversationWorkflow — multi-turn guided creation (campaign/rule)
*
* Key improvements:
* - IntentClassifier (rule-based, zero LLM) replaces old AgentPlanner
* - Active workflows take priority over intent classification
* - Exceptions always send error events to user (no more silent failures)
*/
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class AgentOrchestrator { public class AgentOrchestrator {
private final AgentPlanner planner; private final IntentClassifier intentClassifier;
private final AgentExecutor executor; private final SimpleAgentExecutor simpleAgent;
private final WorkflowExecutor workflowExecutor;
private final AgentEventPublisher eventPublisher;
public void process(ChatRequest request, String sessionId) { public void process(ChatRequest request, String sessionId) {
try { try {
log.info("Generating plan for session: {}", sessionId); // Priority 1: Continue active workflow (if user is mid-conversation for creation)
Plan plan; if (workflowExecutor.hasActiveWorkflow(sessionId)) {
try { log.info("Continuing active workflow for session: {}", sessionId);
plan = planner.generatePlan(request, sessionId); workflowExecutor.continueWorkflow(request, sessionId);
log.info("Generated plan for session: {}, plan: {}", sessionId, plan); return;
} catch (Exception e) {
log.warn("Failed to generate plan from LLM, falling back to default plan. Error: {}", e.getMessage());
plan = null;
} }
if (plan == null || plan.tasks() == null || plan.tasks().isEmpty()) { // Priority 2: Classify intent and route
log.warn("Generated plan is empty or failed, falling back to a single task"); Intent intent = intentClassifier.classify(request.prompt());
plan = new Plan(List.of(new Plan.Task("1", "Answer user request"))); log.info("Classified intent: {} for session: {}", intent, sessionId);
switch (intent) {
case CONVERSATION -> {
log.info("Routing to DirectChat (no tools)");
simpleAgent.executeDirectChat(request, sessionId);
}
case CAMPAIGN_CREATE, RULE_CREATE -> {
log.info("Starting ConversationWorkflow for: {}", intent);
workflowExecutor.startWorkflow(intent, request, sessionId);
}
case CAMPAIGN_QUERY, RULE_QUERY, UNKNOWN -> {
log.info("Routing to SimpleAgent with tools");
simpleAgent.executeWithTools(request, sessionId, intent);
}
} }
log.info("Generated plan with {} tasks", plan.tasks().size());
executor.execute(plan, request, sessionId);
} catch (Exception e) { } catch (Exception e) {
log.error("Error processing request: {}", e.getMessage(), e); log.error("Error processing request for session: {}", sessionId, e);
// The previous orchestrator didn't handle general process exception through eventPublisher // Always notify user of errors — never swallow exceptions silently
// directly here because it was in the stream onError, but let's just log it or rethrow. eventPublisher.error(sessionId, request.messageId(),
"Xin lỗi, đã xảy ra lỗi khi xử lý yêu cầu. Vui lòng thử lại.");
} }
} }
} }

View File

@@ -1,15 +1,66 @@
package dev.sonpx.loyalty.agent.core.orchestrator; package dev.sonpx.loyalty.agent.core.orchestrator;
import dev.sonpx.loyalty.agent.core.classifier.Intent;
import dev.sonpx.loyalty.agent.domain.ChatRequest; import dev.sonpx.loyalty.agent.domain.ChatRequest;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
/**
* Builds system and user prompts optimized for small models (qwen3.5:4b).
* Key principles for small models:
* - Clear, concise instructions
* - Explicit tool guidance
* - Vietnamese language constraint
* - Domain scope limitation
*/
@Component @Component
public class PromptBuilder { public class PromptBuilder {
public String build(ChatRequest request) { private static final String SYSTEM_IDENTITY = """
Bạn là Trợ lý Loyalty, chuyên quản lý chiến dịch khách hàng thân thiết và thể lệ thưởng.
QUY TẮC BẮT BUỘC:
1. Luôn trả lời bằng tiếng Việt
2. Chỉ hỗ trợ về hệ thống Loyalty (chiến dịch, rule, thưởng điểm, đổi quà)
3. KHÔNG bịa thông tin — nếu cần dữ liệu, hãy gọi tool
4. Nếu câu hỏi không liên quan đến Loyalty → trả lời: "Xin lỗi, tôi chỉ hỗ trợ về hệ thống Loyalty"
5. Khi tool trả về `agent_instruction`, tuân theo chính xác hướng dẫn đó
6. Trả lời ngắn gọn, rõ ràng, đúng trọng tâm""";
private static final String TOOL_GUIDANCE = """
HƯỚNG DẪN CHỌN TOOL:
- Hỏi về chiến dịch → dùng searchCampaigns hoặc getCampaignById
- Hỏi về thể lệ/rule → dùng searchRules hoặc getRuleById
- Đếm số lượng chiến dịch → dùng countCampaigns
- Đếm số lượng rule → dùng countRules
- Tạo chiến dịch → dùng createCampaign (cần đủ thông tin)
- Tạo rule → dùng createRule (cần đủ thông tin)
- Tạo mã mới → dùng generateCampaignId hoặc generateRuleId""";
private static final String CONVERSATION_PROMPT = """
Bạn là Trợ lý Loyalty, chuyên quản lý chiến dịch khách hàng thân thiết.
Trả lời bằng tiếng Việt, ngắn gọn và thân thiện.
Nếu câu hỏi không liên quan đến Loyalty → trả lời: "Xin lỗi, tôi chỉ hỗ trợ về hệ thống Loyalty"
Bạn có thể giúp: tìm chiến dịch, xem thể lệ, tạo chiến dịch mới, tạo rule mới.""";
/**
* Build the system prompt based on the detected intent.
* DirectChat gets a lighter prompt (no tool guidance needed).
*/
public String buildSystemPrompt(Intent intent) {
if (intent == Intent.CONVERSATION) {
return CONVERSATION_PROMPT;
}
return SYSTEM_IDENTITY + TOOL_GUIDANCE;
}
/**
* Build the user prompt with context injections (conversationId, activeCampaignId).
*/
public String buildUserPrompt(ChatRequest request) {
List<String> context = new ArrayList<>(); List<String> context = new ArrayList<>();
if (StringUtils.hasText(request.conversationId())) { if (StringUtils.hasText(request.conversationId())) {
@@ -27,4 +78,12 @@ public class PromptBuilder {
} }
return String.join("\n", context) + "\n\n" + prompt; return String.join("\n", context) + "\n\n" + prompt;
} }
/**
* @deprecated Use {@link #buildUserPrompt(ChatRequest)} instead.
*/
@Deprecated
public String build(ChatRequest request) {
return buildUserPrompt(request);
}
} }

View File

@@ -1,50 +0,0 @@
package dev.sonpx.loyalty.agent.core.planner;
import dev.sonpx.loyalty.agent.core.memory.AgentMemoryManager;
import dev.sonpx.loyalty.agent.domain.ChatRequest;
import dev.sonpx.loyalty.agent.core.orchestrator.PromptBuilder;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class AgentPlanner {
private final ChatClient chatClient;
private final AgentMemoryManager memoryManager;
private final PromptBuilder promptBuilder;
public AgentPlanner(ChatClient chatClient, AgentMemoryManager memoryManager, PromptBuilder promptBuilder) {
this.chatClient = chatClient;
this.memoryManager = memoryManager;
this.promptBuilder = promptBuilder;
}
public Plan generatePlan(ChatRequest request, String sessionId) {
var converter = new BeanOutputConverter<>(Plan.class);
List<Message> history = memoryManager.getChatMemory().get(sessionId);
String historyText = history.stream()
.map(m -> m.getMessageType().name() + ": " + m.getText())
.collect(Collectors.joining("\n"));
String systemPrompt = "You are a task planner for an AI Agent.\n" +
"Break down the user's request into a sequence of executable atomic tasks.\n" +
"If it's a simple conversation or question, return a single task representing the answer generation.\n" +
"Do NOT try to answer the question yourself, just output the Plan.\n" +
"Conversation History:\n" + historyText + "\n\n" +
converter.getFormat();
String userText = promptBuilder.build(request);
return chatClient.prompt()
.system(systemPrompt)
.user(userText)
.call()
.entity(converter);
}
}

View File

@@ -1,7 +0,0 @@
package dev.sonpx.loyalty.agent.core.planner;
import java.util.List;
public record Plan(List<Task> tasks) {
public record Task(String id, String description) {}
}

View File

@@ -0,0 +1,228 @@
package dev.sonpx.loyalty.agent.core.workflow;
import dev.sonpx.loyalty.agent.core.classifier.Intent;
import dev.sonpx.loyalty.agent.core.executor.ToolInterceptor;
import dev.sonpx.loyalty.agent.core.memory.AgentMemoryManager;
import dev.sonpx.loyalty.agent.domain.ChatRequest;
import dev.sonpx.loyalty.agent.service.AgentEventPublisher;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Manages multi-turn conversation workflows for creating campaigns and rules.
*
* Flow:
* 1. User says "tạo chiến dịch" → WorkflowExecutor starts workflow, asks for info
* 2. User provides info → LLM extracts data, asks for more or confirms
* 3. User confirms → LLM calls createCampaign/createRule tool
*
* Key design decisions:
* - WorkflowState is stored per-session (in-memory ConcurrentHashMap)
* - 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
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class WorkflowExecutor {
private final ChatClient chatClient;
private final ToolInterceptor toolInterceptor;
private final AgentEventPublisher eventPublisher;
private final AgentMemoryManager memoryManager;
/** Active workflows per session */
private final Map<String, WorkflowState> activeWorkflows = new ConcurrentHashMap<>();
/**
* Check if there's an active workflow for this session.
*/
public boolean hasActiveWorkflow(String sessionId) {
WorkflowState state = activeWorkflows.get(sessionId);
return state != null && state.isActive();
}
/**
* Get the active workflow for a session.
*/
public WorkflowState getWorkflow(String sessionId) {
return activeWorkflows.get(sessionId);
}
/**
* Start a new workflow for creating a campaign or rule.
* Sends the initial question to the user.
*/
public void startWorkflow(Intent intent, ChatRequest request, String sessionId) {
WorkflowState state = new WorkflowState(intent);
activeWorkflows.put(sessionId, state);
log.info("Starting {} workflow for session: {}", intent, sessionId);
// Inject activeCampaignId if available (useful for rule creation)
if (request.activeCampaignId() != null && !request.activeCampaignId().isBlank()) {
state.putData("Campaign ID", request.activeCampaignId());
}
// Build the initial prompt based on workflow type
String systemPrompt = getPhasePrompt(state);
String userPrompt = request.prompt() != null ? request.prompt() : "Bắt đầu tạo";
streamResponse(systemPrompt, userPrompt, sessionId, request.messageId(), false);
}
/**
* Continue an active workflow with the user's response.
* Routes to the appropriate handler based on current phase.
*/
public void continueWorkflow(ChatRequest request, String sessionId) {
WorkflowState state = activeWorkflows.get(sessionId);
if (state == null || !state.isActive()) {
log.warn("No active workflow for session: {}", sessionId);
return;
}
String userInput = request.prompt();
// Check for cancellation at any phase
if (WorkflowPromptBuilder.isCancellation(userInput)) {
state.cancel();
activeWorkflows.remove(sessionId);
streamResponse(
"Người dùng đã hủy. Hãy nói: 'Đã hủy. Nếu cần giúp gì khác, hãy cho tôi biết.'",
userInput, sessionId, request.messageId(), false);
return;
}
log.info("Continuing workflow for session: {}, phase: {}", sessionId, state.getCurrentPhase());
switch (state.getCurrentPhase()) {
case BASIC_INFO -> handleBasicInfoResponse(state, request, sessionId);
case REWARD_CONFIG -> handleRewardConfigResponse(state, request, sessionId);
case CONFIRMING -> handleConfirmation(state, request, sessionId);
default -> log.warn("Unexpected workflow phase: {}", state.getCurrentPhase());
}
}
/**
* Handle user's response during BASIC_INFO phase.
* Use LLM to extract structured data from natural language.
*/
private void handleBasicInfoResponse(WorkflowState state, ChatRequest request, String sessionId) {
String systemPrompt = WorkflowPromptBuilder.extractDataPrompt(state);
// LLM extracts data and confirms — no tools needed yet
streamResponse(systemPrompt, request.prompt(), sessionId, request.messageId(), false);
// Advance phase after LLM responds
// The LLM will indicate if data is complete; user's next message drives phase transition
state.advancePhase();
}
/**
* Handle user's response during REWARD_CONFIG phase (rule only).
*/
private void handleRewardConfigResponse(WorkflowState state, ChatRequest request, String sessionId) {
// Check if user wants to skip optional config
if (WorkflowPromptBuilder.isSkipToConfirm(request.prompt())) {
state.advancePhase(); // Skip to CONFIRMING
String confirmPrompt = getPhasePrompt(state);
streamResponse(confirmPrompt, request.prompt(), sessionId, request.messageId(), false);
return;
}
String systemPrompt = WorkflowPromptBuilder.extractDataPrompt(state);
streamResponse(systemPrompt, request.prompt(), sessionId, request.messageId(), false);
// Advance to CONFIRMING
state.advancePhase();
}
/**
* Handle user's confirmation or rejection.
*/
private void handleConfirmation(WorkflowState state, ChatRequest request, String sessionId) {
if (WorkflowPromptBuilder.isConfirmation(request.prompt())) {
// User confirmed → execute creation with tools
log.info("User confirmed creation for session: {}", sessionId);
state.advancePhase(); // → COMPLETED
String executePrompt = (state.getWorkflowType() == Intent.CAMPAIGN_CREATE)
? WorkflowPromptBuilder.campaignExecutePrompt(state.getCollectedData())
: WorkflowPromptBuilder.ruleExecutePrompt(state.getCollectedData());
// This time we need tools to actually create the entity
streamResponse(executePrompt, request.prompt(), sessionId, request.messageId(), true);
// Cleanup
activeWorkflows.remove(sessionId);
} else {
// User wants to modify — go back to collect more info
String systemPrompt = WorkflowPromptBuilder.extractDataPrompt(state);
streamResponse(systemPrompt, request.prompt(), sessionId, request.messageId(), false);
}
}
/**
* Get the appropriate prompt for the current workflow phase.
*/
private String getPhasePrompt(WorkflowState state) {
boolean isCampaign = state.getWorkflowType() == Intent.CAMPAIGN_CREATE;
return switch (state.getCurrentPhase()) {
case BASIC_INFO -> isCampaign
? WorkflowPromptBuilder.campaignBasicInfoPrompt()
: WorkflowPromptBuilder.ruleBasicInfoPrompt();
case REWARD_CONFIG -> WorkflowPromptBuilder.ruleRewardConfigPrompt(state.getCollectedData());
case CONFIRMING -> isCampaign
? WorkflowPromptBuilder.campaignConfirmPrompt(state.getCollectedData())
: WorkflowPromptBuilder.ruleConfirmPrompt(state.getCollectedData());
default -> "Hãy giúp người dùng.";
};
}
/**
* Stream a response to the user via WebSocket.
*
* @param withTools if true, loads MCP tools for the LLM to call
*/
private void streamResponse(String systemPrompt, String userPrompt,
String sessionId, String messageId, boolean withTools) {
var builder = chatClient.prompt()
.advisors(memoryManager.getAdvisor())
.advisors(a -> a
.param("chat_memory_conversation_id", sessionId)
.param("chat_memory_response_size", 50))
.system(systemPrompt)
.user(userPrompt != null ? userPrompt : "Tiếp tục");
if (withTools) {
List<ToolCallback> wrappedTools = toolInterceptor.getWrappedTools(sessionId, messageId);
builder.tools(wrappedTools);
}
builder.stream()
.content()
.doOnComplete(() -> eventPublisher.done(sessionId, messageId))
.subscribe(
content -> {
if (!content.isEmpty()) {
eventPublisher.token(sessionId, messageId, content);
}
},
error -> {
log.error("Error during workflow execution: {}", error.getMessage(), error);
eventPublisher.error(sessionId, messageId,
"Xin lỗi, đã xảy ra lỗi. Vui lòng thử lại.");
}
);
}
}

View File

@@ -0,0 +1,183 @@
package dev.sonpx.loyalty.agent.core.workflow;
import dev.sonpx.loyalty.agent.core.classifier.Intent;
import java.util.List;
import java.util.Map;
/**
* Builds phase-specific prompts for the ConversationWorkflow.
* Each phase has a targeted prompt that tells the LLM exactly what to ask/do.
*
* Design for small models:
* - Short, clear prompts
* - Explicit field lists
* - Vietnamese domain terminology
*/
public class WorkflowPromptBuilder {
// ========== CAMPAIGN CREATION PROMPTS ==========
static String campaignBasicInfoPrompt() {
return """
Bạn đang giúp người dùng TẠO CHIẾN DỊCH MỚI.
Hãy hỏi người dùng các thông tin sau (bằng tiếng Việt, thân thiện):
1. Tên chiến dịch
2. Mô tả chiến dịch (ngắn gọn)
3. Loại chiến dịch: Base (B) hoặc Tactical (T)
4. Ngày bắt đầu và ngày kết thúc
5. Tên người/phòng ban sở hữu (owner)
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.""";
}
static String campaignConfirmPrompt(Map<String, Object> data) {
return """
Bạn đang giúp người dùng TẠO CHIẾN DỊCH MỚI.
Người dùng đã cung cấp các thông tin. Hãy:
1. Tóm tắt lại thông tin chiến dịch sẽ tạo (dạng danh sách)
2. Hỏi "Anh/chị xác nhận tạo chiến dịch này không?"
Thông tin đã thu thập:
""" + formatData(data) + """
KHÔNG gọi tool nào. Chỉ hiển thị tóm tắt và hỏi xác nhận.""";
}
static String campaignExecutePrompt(Map<String, Object> data) {
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:
""" + formatData(data) + """
Sau khi tạo xong, thông báo kết quả cho người dùng.""";
}
// ========== RULE CREATION PROMPTS ==========
static String ruleBasicInfoPrompt() {
return """
Bạn đang giúp người dùng TẠO RULE/THỂ LỆ MỚI cho chiến dịch.
Hãy hỏi người dùng các thông tin CƠ BẢN sau (bằng tiếng Việt, thân thiện):
1. Tên rule
2. Thuộc chiến dịch nào? (nếu biết campaign ID thì cung cấp, nếu không biết thì mô tả để tìm)
3. Loại rule:
- AWD: Thưởng điểm
- RED: Đổi điểm lấy quà
- IRED: Đổi quà đặc biệt
- ADJ: Điều chỉnh điểm
- CEP: Sự kiện trích xuất bộ đếm
- REP: Đổi điểm trích xuất
- TEP: Tích lũy theo giao dịch
- MAWD: Thưởng Marketing
4. Mô tả rule (ngắn gọn)
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.""";
}
static String ruleRewardConfigPrompt(Map<String, Object> data) {
return """
Bạn đang giúp người dùng TẠO RULE/THỂ LỆ MỚI.
Thông tin cơ bản đã có:
""" + formatData(data) + """
Bây giờ hãy hỏi thêm thông tin CẤU HÌNH THƯỞNG:
1. Pool thưởng (pool ID nếu biết, hoặc để trống)
2. Công thức thưởng: điểm cố định (fixed) hay theo tỷ lệ giao dịch (rate)?
3. Giá trị thưởng (ví dụ: 100 điểm, hoặc 2% giá trị giao dịch)
4. Giới hạn thưởng tối đa (cap) nếu có
5. Có áp dụng lịch trình chạy (schedule) không?
Hỏi TẤT CẢ các thông tin trên trong MỘT lần.
KHÔNG gọi tool nào. Chỉ hỏi thông tin.""";
}
static String ruleConfirmPrompt(Map<String, Object> data) {
return """
Bạn đang giúp người dùng TẠO RULE/THỂ LỆ MỚI.
Người dùng đã cung cấp đủ thông tin. Hãy:
1. Tóm tắt lại TOÀN BỘ thông tin rule sẽ tạo (dạng danh sách rõ ràng)
2. Hỏi "Anh/chị xác nhận tạo rule này không?"
Thông tin đã thu thập:
""" + formatData(data) + """
KHÔNG gọi tool nào. Chỉ hiển thị tóm tắt và hỏi xác nhận.""";
}
static String ruleExecutePrompt(Map<String, Object> data) {
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:
""" + formatData(data) + """
Sau khi tạo xong, thông báo kết quả cho người dùng.""";
}
// ========== COMMON PROMPTS ==========
static String extractDataPrompt(WorkflowState state) {
String target = (state.getWorkflowType() == Intent.CAMPAIGN_CREATE) ? "chiến dịch" : "rule";
return """
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.
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.
""".formatted(target, formatData(state.getCollectedData()));
}
/**
* Detect if user's response indicates confirmation.
*/
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).*");
}
/**
* Detect if user's response indicates cancellation.
*/
static boolean isCancellation(String userInput) {
if (userInput == null) return false;
String lower = userInput.trim().toLowerCase();
return lower.matches("(?i).*(hủy|cancel|thôi|không|bỏ|dừng|stop|không tạo).*");
}
/**
* Detect if user wants to skip optional config and proceed.
*/
static boolean isSkipToConfirm(String userInput) {
if (userInput == null) return false;
String lower = userInput.trim().toLowerCase();
return lower.matches("(?i).*(tạo luôn|bỏ qua|skip|không cần|đủ rồi|tạo thôi).*");
}
private static String formatData(Map<String, Object> data) {
if (data == null || data.isEmpty()) return "(chưa có)";
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, Object> entry : data.entrySet()) {
sb.append("- ").append(entry.getKey()).append(": ").append(entry.getValue()).append("\n");
}
return sb.toString();
}
}

View File

@@ -0,0 +1,91 @@
package dev.sonpx.loyalty.agent.core.workflow;
import dev.sonpx.loyalty.agent.core.classifier.Intent;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Tracks the state of a multi-turn conversation workflow (e.g., creating a campaign or rule).
* Stored per-session to maintain context across user turns.
*
* Workflow lifecycle: INIT → COLLECTING → CONFIRMING → COMPLETED / CANCELLED
*/
@Slf4j
@Getter
@Setter
public class WorkflowState {
public enum Phase {
/** Initial phase — asking for basic info */
BASIC_INFO,
/** Collecting reward/formula configuration (rule only) */
REWARD_CONFIG,
/** Showing summary and asking for confirmation */
CONFIRMING,
/** Workflow completed, data created */
COMPLETED,
/** User cancelled the workflow */
CANCELLED
}
private final Intent workflowType;
private Phase currentPhase;
private final Map<String, Object> collectedData;
private String lastQuestion;
public WorkflowState(Intent workflowType) {
this.workflowType = workflowType;
this.currentPhase = Phase.BASIC_INFO;
this.collectedData = new LinkedHashMap<>();
}
public void putData(String key, Object value) {
collectedData.put(key, value);
log.debug("Workflow data collected: {} = {}", key, value);
}
public Object getData(String key) {
return collectedData.get(key);
}
public boolean hasData(String key) {
return collectedData.containsKey(key) && collectedData.get(key) != null;
}
public boolean isActive() {
return currentPhase != Phase.COMPLETED && currentPhase != Phase.CANCELLED;
}
public void advancePhase() {
switch (currentPhase) {
case BASIC_INFO -> currentPhase = (workflowType == Intent.RULE_CREATE)
? Phase.REWARD_CONFIG
: Phase.CONFIRMING;
case REWARD_CONFIG -> currentPhase = Phase.CONFIRMING;
case CONFIRMING -> currentPhase = Phase.COMPLETED;
default -> { /* no-op */ }
}
log.info("Workflow advanced to phase: {}", currentPhase);
}
public void cancel() {
this.currentPhase = Phase.CANCELLED;
log.info("Workflow cancelled");
}
/**
* Build a summary of all collected data for confirmation display.
*/
public String buildSummary() {
StringBuilder sb = new StringBuilder();
sb.append("**Thông tin đã thu thập:**\n");
for (Map.Entry<String, Object> entry : collectedData.entrySet()) {
sb.append("- ").append(entry.getKey()).append(": ").append(entry.getValue()).append("\n");
}
return sb.toString();
}
}

View File

@@ -14,6 +14,7 @@ spring:
options: options:
model: qwen3.5:4b model: qwen3.5:4b
temperature: 0.3 temperature: 0.3
num_ctx: 32768
mcp: mcp:
client: client:
request-timeout: 60000 request-timeout: 60000

View File

@@ -0,0 +1,146 @@
package dev.sonpx.loyalty.agent.core.classifier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Tests for IntentClassifier — verifying Vietnamese keyword matching
* for all intent categories.
*/
class IntentClassifierTest {
private IntentClassifier classifier;
@BeforeEach
void setUp() {
classifier = new IntentClassifier();
}
// ========== CONVERSATION ==========
@ParameterizedTest
@ValueSource(strings = {
"xin chào",
"Chào bạn",
"hi",
"Hello",
"bạn là ai",
"cảm ơn",
"ok",
"bye"
})
@DisplayName("Should classify greetings as CONVERSATION")
void shouldClassifyGreetings(String input) {
assertEquals(Intent.CONVERSATION, classifier.classify(input));
}
// ========== CAMPAIGN QUERY ==========
@ParameterizedTest
@ValueSource(strings = {
"danh sách chiến dịch",
"Tìm chiến dịch ABC",
"xem chiến dịch",
"liệt kê campaign",
"có bao nhiêu chiến dịch",
"chiến dịch nào đang chạy",
"chi tiết campaign CMP01",
"campaign"
})
@DisplayName("Should classify campaign queries as CAMPAIGN_QUERY")
void shouldClassifyCampaignQuery(String input) {
assertEquals(Intent.CAMPAIGN_QUERY, classifier.classify(input));
}
// ========== RULE QUERY ==========
@ParameterizedTest
@ValueSource(strings = {
"danh sách rule",
"tìm thể lệ",
"xem quy tắc",
"thể lệ chiến dịch ABC",
"công thức thưởng",
"cách tính điểm",
"điều kiện thưởng"
})
@DisplayName("Should classify rule queries as RULE_QUERY")
void shouldClassifyRuleQuery(String input) {
assertEquals(Intent.RULE_QUERY, classifier.classify(input));
}
// ========== CAMPAIGN CREATE ==========
@ParameterizedTest
@ValueSource(strings = {
"tạo chiến dịch mới",
"thêm campaign",
"tạo mới chiến dịch",
"add campaign"
})
@DisplayName("Should classify campaign creation as CAMPAIGN_CREATE")
void shouldClassifyCampaignCreate(String input) {
assertEquals(Intent.CAMPAIGN_CREATE, classifier.classify(input));
}
// ========== RULE CREATE ==========
@ParameterizedTest
@ValueSource(strings = {
"tạo rule thưởng",
"thêm thể lệ mới",
"tạo mới quy tắc",
"add rule"
})
@DisplayName("Should classify rule creation as RULE_CREATE")
void shouldClassifyRuleCreate(String input) {
assertEquals(Intent.RULE_CREATE, classifier.classify(input));
}
// ========== UNKNOWN ==========
@ParameterizedTest
@ValueSource(strings = {
"thời tiết hôm nay",
"abc xyz",
"123456"
})
@DisplayName("Should classify unrecognized prompts as UNKNOWN")
void shouldClassifyUnknown(String input) {
assertEquals(Intent.UNKNOWN, classifier.classify(input));
}
// ========== EDGE CASES ==========
@Test
@DisplayName("Should handle null input as CONVERSATION")
void shouldHandleNull() {
assertEquals(Intent.CONVERSATION, classifier.classify(null));
}
@Test
@DisplayName("Should handle empty input as CONVERSATION")
void shouldHandleEmpty() {
assertEquals(Intent.CONVERSATION, classifier.classify(""));
}
@Test
@DisplayName("Should handle whitespace input as CONVERSATION")
void shouldHandleWhitespace() {
assertEquals(Intent.CONVERSATION, classifier.classify(" "));
}
@Test
@DisplayName("CREATE should have higher priority than QUERY")
void shouldPrioritizeCreateOverQuery() {
// "tạo chiến dịch" matches both CREATE and QUERY patterns
// CREATE should win because it's checked first
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"));
}
}

View File

@@ -24,37 +24,72 @@ public class CampaignRuleService {
public Result<List<CampaignRule>> getAll(CampaignRuleCriteria criteria) { public Result<List<CampaignRule>> getAll(CampaignRuleCriteria criteria) {
List<CampaignRule> rules = campaignRuleClient.getAll(criteria, Pageable.ofSize(10)); List<CampaignRule> rules = campaignRuleClient.getAll(criteria, Pageable.ofSize(10));
return Result.of(rules, "Format these campaign rules as a markdown table with columns ID, Campaign ID, Rule Code, Status."); return Result.of(rules, """
Trả lời tự nhiên bằng tiếng Việt. Không dùng markdown table.
Hiển thị mỗi rule theo dạng danh sách đánh số:
**[ruleName]** (`[ruleId]`) — Chiến dịch: [campaignId.description hoặc campaignId.code], Loại: [ruleType - dùng tên tiếng Việt], Hiệu lực: [effectiveFrom] → [effectiveTo]
Mô tả: [description, tóm tắt nếu quá dài, hoặc "Chưa có"]
Nếu không tìm thấy kết quả, thông báo nhẹ nhàng và gợi ý thử từ khóa khác.
Luôn kết thúc bằng câu gợi mở (ví dụ: xem chi tiết rule, xem chiến dịch gốc).
""");
} }
public Result<Long> count(CampaignRuleCriteria criteria) { public Result<Long> count(CampaignRuleCriteria criteria) {
Long count = campaignRuleClient.count(criteria); Long count = campaignRuleClient.count(criteria);
return Result.of(count, "Present the campaign rule count."); return Result.of(count, """
Trả lời tự nhiên bằng tiếng Việt. Ví dụ: "Có [count] thể lệ [phù hợp với điều kiện tìm kiếm]."
Nếu count = 0, gợi ý người dùng thử từ khóa khác hoặc tạo rule mới.
""");
} }
public Result<CampaignRule> findActiveById(String id) { public Result<CampaignRule> findActiveById(String id) {
CampaignRule rule = campaignRuleClient.findActiveById(id); CampaignRule rule = campaignRuleClient.findActiveById(id);
return Result.of(rule, "Format this campaign rule as a markdown table with its details."); return Result.of(rule, """
Trả lời tự nhiên bằng tiếng Việt. Trình bày chi tiết rule một cách rõ ràng:
- Thông tin cơ bản: Tên, Mã Rule, Loại rule (tên tiếng Việt), Chiến dịch liên kết
- Thời gian hiệu lực, Mô tả
- Cấu hình thưởng: Pool, Sub-Pool, Chính sách hết hạn (nếu có)
- Formula đang dùng (liệt kê tên formula có giá trị, ví dụ: "Dùng Formula 1 + Formula 4")
- Giới hạn thưởng (Award Limits), lịch trình (Schedule) nếu có
- Kênh thông báo (SMS/Email/Notification) nếu được cấu hình
Dùng bullet list hoặc key-value dễ đọc, KHÔNG dùng table.
Gợi ý người dùng có thể quay lại xem chiến dịch gốc hoặc các rule khác.
""");
} }
public Result<List<CampaignRule>> findActiveByIds(String ids) { public Result<List<CampaignRule>> findActiveByIds(String ids) {
List<String> idList = java.util.Arrays.asList(ids.split(",\\s*")); List<String> idList = java.util.Arrays.asList(ids.split(",\\s*"));
List<CampaignRule> rules = campaignRuleClient.findActiveByIds(idList); List<CampaignRule> rules = campaignRuleClient.findActiveByIds(idList);
return Result.of(rules, "Format these campaign rules as a markdown table with columns ID, Campaign ID, Rule Code, Status."); return Result.of(rules, """
Trả lời tự nhiên bằng tiếng Việt. Hiển thị danh sách rule theo dạng đánh số,
mỗi rule ghi: Tên, Mã Rule, Chiến dịch, Loại rule, Trạng thái.
Không dùng markdown table. Tóm tắt ngắn gọn nếu danh sách dài.
""");
} }
public Result<String> generateId() { public Result<String> generateId() {
String id = campaignRuleClient.generateId(); String id = campaignRuleClient.generateId();
return Result.of(id, "Present the newly generated campaign rule ID."); return Result.of(id, """
Trả lời tự nhiên bằng tiếng Việt. Ví dụ: "Mã rule mới đã được tạo: `[id]`."
Gợi ý người dùng có thể dùng mã này để tạo rule mới cho chiến dịch.
""");
} }
public Result<String> checkId(String id) { public Result<String> checkId(String id) {
String checkResult = campaignRuleClient.checkId(id); String checkResult = campaignRuleClient.checkId(id);
return Result.of(checkResult, "Present the result of the campaign rule ID check."); return Result.of(checkResult, """
Trả lời tự nhiên bằng tiếng Việt. Giải thích kết quả kiểm tra mã rule:
- Nếu hợp lệ/khả dụng: thông báo tích cực, gợi ý tạo rule.
- Nếu đã tồn tại/không hợp lệ: thông báo rõ ràng lý do, gợi ý tạo mã mới.
""");
} }
public Result<OperationResult<CampaignRule>> create(CampaignRule dto) { public Result<OperationResult<CampaignRule>> create(CampaignRule dto) {
OperationResult<CampaignRule> opResult = campaignRuleClient.create(dto); OperationResult<CampaignRule> opResult = campaignRuleClient.create(dto);
return Result.of(opResult, "Present the result of the campaign rule creation operation, formatting the created rule details if successful."); return Result.of(opResult, """
Trả lời tự nhiên bằng tiếng Việt.
- Nếu thành công: chúc mừng, hiển thị tóm tắt rule vừa tạo (Tên, ID, Chiến dịch, Loại, Thời gian), gợi ý bước tiếp (cấu hình formula, giới hạn thưởng, lịch trình).
- Nếu lỗi: thông báo lỗi rõ ràng, giải thích nguyên nhân, gợi ý cách khắc phục.
""");
} }
} }

View File

@@ -24,52 +24,70 @@ public class CampaignService {
public Result<List<Campaign>> getAll(CampaignCriteria criteria) { public Result<List<Campaign>> getAll(CampaignCriteria criteria) {
List<Campaign> campaigns = campaignClient.getAll(criteria, Pageable.ofSize(5)); List<Campaign> campaigns = campaignClient.getAll(criteria, Pageable.ofSize(5));
return Result.of(campaigns, """ return Result.of(campaigns, """
Khi trả về danh sách chiến dịch, BẮT BUỘC KHÔNG dùng markdown table. Hãy định dạng theo cấu trúc sau: 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ố:
1. **[Tên Chiến Dịch]** (ID: `[Mã ID]`) **[Tên]** (`[campaignId]`) — Owner: [ownerName hoặc "Chưa có"], Loại: [campaignType], Hiệu lực: [effectiveFrom] → [effectiveTo], Số rule: [numOfRule]
- 👤 Owner: [Tên Owner] (Nếu trống, ghi là: Không có) Mô tả: [description, tóm tắt nếu quá dài, hoặc "Chưa có"]
- 📝 Mô tả: [Mô tả] (Nếu trống, ghi là: Không có. Nếu dài hơn 100 ký tự, thêm "...") Nếu không tìm thấy kết quả, thông báo nhẹ nhàng và gợi ý thử từ khóa khác.
Ví dụ đầu ra ĐÚNG: Luôn kết thúc bằng câu gợi mở hướng dẫn người dùng bước tiếp theo (ví dụ: xem chi tiết, xem rule, tạo mới).
1. **Happy Birthday!** (ID: `LACO`)
- 👤 Owner: Không có
- 📝 Mô tả: Không có
2. **SummerSale GOTIT Campaign** (ID: `YZFG`)
- 👤 Owner: Ngan Ha
- 📝 Mô tả: Không có
Luôn kết thúc bằng một câu hỏi gợi mở để hướng dẫn người dùng bước tiếp theo.
"""); """);
} }
public Result<Long> count(CampaignCriteria criteria) { public Result<Long> count(CampaignCriteria criteria) {
Long count = campaignClient.count(criteria); Long count = campaignClient.count(criteria);
return Result.of(count, "Present the campaign count."); return Result.of(count, """
Trả lời tự nhiên bằng tiếng Việt. Ví dụ: "Hiện tại có [count] chiến dịch [phù hợp với điều kiện tìm kiếm]."
Nếu count = 0, gợi ý người dùng thử từ khóa khác hoặc tạo chiến dịch mới.
""");
} }
public Result<Campaign> findActiveById(String id) { public Result<Campaign> findActiveById(String id) {
Campaign campaign = campaignClient.findActiveById(id); Campaign campaign = campaignClient.findActiveById(id);
return Result.of(campaign, "Format this campaign as a markdown table with its details."); return Result.of(campaign, """
Trả lời tự nhiên bằng tiếng Việt. Trình bày thông tin chi tiết của chiến dịch một cách rõ ràng:
- Tên, Mã ID, Loại chiến dịch, Owner
- Thời gian hiệu lực (từ ngày → đến ngày)
- Mô tả (nếu có)
- Số lượng rule, Khách hàng mục tiêu, Target ATV (nếu có)
- Trạng thái, Người cập nhật cuối
Dùng bullet list hoặc dạng key-value dễ đọc, KHÔNG dùng table.
Gợi ý người dùng có thể xem danh sách rule của chiến dịch này.
""");
} }
public Result<List<Campaign>> findActiveByIds(String ids) { public Result<List<Campaign>> findActiveByIds(String ids) {
List<String> idList = java.util.Arrays.asList(ids.split(",\\s*")); List<String> idList = java.util.Arrays.asList(ids.split(",\\s*"));
List<Campaign> campaigns = campaignClient.findActiveByIds(idList); List<Campaign> campaigns = campaignClient.findActiveByIds(idList);
return Result.of(campaigns, "Format these campaigns as a markdown table with columns ID, Name, Start Date, End Date, Status."); return Result.of(campaigns, """
Trả lời tự nhiên bằng tiếng Việt. Hiển thị danh sách chiến dịch theo dạng đánh số,
mỗi chiến dịch ghi: Tên, Mã ID, Thời gian hiệu lực, Trạng thái.
Không dùng markdown table. Tóm tắt ngắn gọn nếu danh sách dài.
""");
} }
public Result<String> generateId() { public Result<String> generateId() {
String id = campaignClient.generateId(); String id = campaignClient.generateId();
return Result.of(id, "Present the newly generated campaign ID."); return Result.of(id, """
Trả lời tự nhiên bằng tiếng Việt. Ví dụ: "Mã chiến dịch mới đã được tạo: `[id]`."
Gợi ý người dùng có thể dùng mã này để tạo chiến dịch mới.
""");
} }
public Result<String> checkId(String id) { public Result<String> checkId(String id) {
String checkResult = campaignClient.checkId(id); String checkResult = campaignClient.checkId(id);
return Result.of(checkResult, "Present the result of the campaign ID check."); return Result.of(checkResult, """
Trả lời tự nhiên bằng tiếng Việt. Giải thích kết quả kiểm tra mã chiến dịch:
- Nếu hợp lệ/khả dụng: thông báo tích cực, gợi ý tạo chiến dịch.
- Nếu đã tồn tại/không hợp lệ: thông báo rõ ràng lý do, gợi ý tạo mã mới.
""");
} }
public Result<OperationResult<Campaign>> create(Campaign dto) { public Result<OperationResult<Campaign>> create(Campaign dto) {
OperationResult<Campaign> opResult = campaignClient.create(dto); OperationResult<Campaign> opResult = campaignClient.create(dto);
return Result.of(opResult, "Present the result of the campaign creation operation, formatting the created campaign details if successful."); return Result.of(opResult, """
Trả lời tự nhiên bằng tiếng Việt.
- Nếu thành công: chúc mừng người dùng, hiển thị tóm tắt chiến dịch vừa tạo (Tên, ID, Thời gian, Loại), gợi ý bước tiếp theo (thêm rule, cấu hình thể lệ).
- Nếu lỗi: thông báo lỗi rõ ràng, giải thích nguyên nhân nếu có, gợi ý cách khắc phục.
""");
} }
} }

View File

@@ -19,42 +19,46 @@ public class CampaignRuleTools {
private final CampaignRuleService campaignRuleService; private final CampaignRuleService campaignRuleService;
@McpTool(description = "Lấy danh sách các Campaign Rule (Thể lệ chiến dịch). Dùng tool này khi người dùng hỏi về rule, thể lệ, quy tắc, điều kiện của chiến dịch. Cho phép tìm kiếm với param search (ví dụ: nhập tên chiến dịch hoặc tên rule vào đây), hiển thị tối đa 10 bản ghi.") @McpTool(description = """
public Result<List<CampaignRule>> campaignRules(String search) { 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.
Dùng khi người dùng hỏi: "thể lệ", "rule", "quy tắc", "điều kiện", "công thức thưởng", "cách tính điểm".
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<List<CampaignRule>> searchRules(String search) {
CampaignRuleCriteria criteria = new CampaignRuleCriteria(); CampaignRuleCriteria criteria = new CampaignRuleCriteria();
criteria.setSearch(search); criteria.setSearch(search);
return campaignRuleService.getAll(criteria); return campaignRuleService.getAll(criteria);
} }
@McpTool(description = "Đếm số lượng Campaign Rule, cho phép tìm kiếm với param search") @McpTool(description = "Đếm số lượng RULE/THỂ LỆ, không phải chiến dịch. Cho phép tìm kiếm với param search.")
public Result<Long> countCampaignRules(String search) { public Result<Long> countRules(String search) {
CampaignRuleCriteria criteria = new CampaignRuleCriteria(); CampaignRuleCriteria criteria = new CampaignRuleCriteria();
criteria.setSearch(search); criteria.setSearch(search);
return campaignRuleService.count(criteria); return campaignRuleService.count(criteria);
} }
@McpTool(description = "Lấy thông tin chi tiết một Campaign Rule theo ID") @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<CampaignRule> getCampaignRuleById(String id) { public Result<CampaignRule> getRuleById(String id) {
return campaignRuleService.findActiveById(id); return campaignRuleService.findActiveById(id);
} }
@McpTool(description = "Lấy thông tin nhiều Campaign Rule theo danh sách ID, phân tách bằng dấu phẩy") @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<List<CampaignRule>> getCampaignRulesByIds(String ids) { public Result<List<CampaignRule>> getRulesByIds(String ids) {
return campaignRuleService.findActiveByIds(ids); return campaignRuleService.findActiveByIds(ids);
} }
@McpTool(description = "Tạo một ID Campaign Rule mới") @McpTool(description = "Tạo một MÃ RULE mới (rule ID), không phải mã chiến dịch.")
public Result<String> generateCampaignRuleId() { public Result<String> generateRuleId() {
return campaignRuleService.generateId(); return campaignRuleService.generateId();
} }
@McpTool(description = "Kiểm tra trạng thái hoặc tính hợp lệ của một ID Campaign Rule") @McpTool(description = "Kiểm tra tính hợp lệ của một MÃ RULE (rule ID).")
public Result<String> checkCampaignRuleId(String id) { public Result<String> checkRuleId(String id) {
return campaignRuleService.checkId(id); return campaignRuleService.checkId(id);
} }
@McpTool(description = "Tạo một Campaign Rule mới") @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<OperationResult<CampaignRule>> createCampaignRule(CampaignRule dto) { public Result<OperationResult<CampaignRule>> createRule(CampaignRule dto) {
return campaignRuleService.create(dto); return campaignRuleService.create(dto);
} }
} }

View File

@@ -19,41 +19,44 @@ public class CampaignTools {
private final CampaignService campaignService; private final CampaignService campaignService;
@McpTool(description = "Lấy danh sách chiến dịch (Campaign), cho phép tìm kiếm với param search. CHỈ dùng tool này khi cần tìm thông tin về chính chiến dịch đó. NẾU người dùng hỏi về RULE / THỂ LỆ của chiến dịch, HÃY DÙNG tool campaignRules.") @McpTool(description = """
public Result<List<Campaign>> campaigns(String search) { 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.
Dùng khi người dùng hỏi: "danh sách chiến dịch", "tìm chiến dịch", "chiến dịch nào", "campaign 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<List<Campaign>> searchCampaigns(String search) {
CampaignCriteria criteria = new CampaignCriteria(); CampaignCriteria criteria = new CampaignCriteria();
criteria.setSearch(search); criteria.setSearch(search);
return campaignService.getAll(criteria); return campaignService.getAll(criteria);
} }
@McpTool(description = "Đếm số lượng chiến dịch, cho phép tìm kiếm với param search") @McpTool(description = "Đếm số lượng CHIẾN DỊCH (Campaign), không phải rule. Cho phép tìm kiếm với param search.")
public Result<Long> countCampaigns(String search) { public Result<Long> countCampaigns(String search) {
CampaignCriteria criteria = new CampaignCriteria(); CampaignCriteria criteria = new CampaignCriteria();
criteria.setSearch(search); criteria.setSearch(search);
return campaignService.count(criteria); return campaignService.count(criteria);
} }
@McpTool(description = "Lấy thông tin chi tiết một chiến dịch theo ID") @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<Campaign> getCampaignById(String id) { public Result<Campaign> getCampaignById(String id) {
return campaignService.findActiveById(id); return campaignService.findActiveById(id);
} }
@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") @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<List<Campaign>> getCampaignsByIds(String ids) { public Result<List<Campaign>> getCampaignsByIds(String ids) {
return campaignService.findActiveByIds(ids); return campaignService.findActiveByIds(ids);
} }
@McpTool(description = "Tạo một ID chiến dịch mới") @McpTool(description = "Tạo một MÃ CHIẾN DỊCH mới (campaign ID), không phải mã rule.")
public Result<String> generateCampaignId() { public Result<String> generateCampaignId() {
return campaignService.generateId(); return campaignService.generateId();
} }
@McpTool(description = "Kiểm tra trạng thái hoặc tính hợp lệ của một ID chiến dịch") @McpTool(description = "Kiểm tra tính hợp lệ của một MÃ CHIẾN DỊCH (campaign ID).")
public Result<String> checkCampaignId(String id) { public Result<String> checkCampaignId(String id) {
return campaignService.checkId(id); return campaignService.checkId(id);
} }
@McpTool(description = "Tạo một chiến dịch mới") @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<OperationResult<Campaign>> createCampaign(Campaign dto) { public Result<OperationResult<Campaign>> createCampaign(Campaign dto) {
return campaignService.create(dto); return campaignService.create(dto);
} }