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:
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.
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

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;
import dev.sonpx.loyalty.agent.core.config.AgentConfig;
import dev.sonpx.loyalty.agent.service.AgentEventPublisher;
import dev.sonpx.loyalty.agent.service.ToolResultPresentationProcessor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.definition.ToolDefinition;
@@ -11,6 +13,14 @@ import org.springframework.stereotype.Component;
import java.util.ArrayList;
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
@RequiredArgsConstructor
public class ToolInterceptor {
@@ -18,10 +28,11 @@ public class ToolInterceptor {
private final List<ToolCallbackProvider> toolProviders;
private final AgentEventPublisher eventPublisher;
private final ToolResultPresentationProcessor toolResultProcessor;
private final AgentConfig agentConfig;
public List<ToolCallback> getWrappedTools(String sessionId, String messageId) {
List<ToolCallback> wrappedTools = new ArrayList<>();
for (ToolCallbackProvider provider : toolProviders) {
for (ToolCallback tool : provider.getToolCallbacks()) {
wrappedTools.add(wrap(tool, sessionId, messageId));
@@ -39,22 +50,62 @@ public class ToolInterceptor {
@Override
public String call(String toolInput) {
eventPublisher.toolRunning(sessionId, messageId, getToolDefinition().name());
String toolName = getToolDefinition().name();
eventPublisher.toolRunning(sessionId, messageId, toolName);
try {
String result = tool.call(toolInput);
if (result != null && (
(result.contains("java.lang.") && result.contains("Exception") && result.contains("org.springframework.")) ||
result.contains("HTTP Status 500 Internal Server Error")
)) {
// Sanitize error stack traces — don't leak internals to LLM
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 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) {
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.\"}";
} 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;
import dev.sonpx.loyalty.agent.core.config.AgentConfig;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;
import org.springframework.stereotype.Component;
@@ -9,12 +10,23 @@ import java.util.List;
import java.util.Map;
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
public class InMemoryChatMemory implements ChatMemory {
private final Map<String, List<Message>> conversationHistory = new ConcurrentHashMap<>();
private static final int MAX_TOKENS = 4000;
private static final int CHARS_PER_TOKEN = 4;
private final AgentConfig agentConfig;
public InMemoryChatMemory(AgentConfig agentConfig) {
this.agentConfig = agentConfig;
}
@Override
public void add(String conversationId, List<Message> messages) {
@@ -23,10 +35,9 @@ public class InMemoryChatMemory implements ChatMemory {
history = new ArrayList<>();
}
history.addAll(messages);
// Truncation logic: keep messages such that estimated tokens < MAX_TOKENS
while (history.size() > 1 && estimateTokens(history) > MAX_TOKENS) {
// Find first non-system message to remove
// Truncation: remove oldest non-system messages when over token limit
while (history.size() > 1 && estimateTokens(history) > agentConfig.getMaxMemoryTokens()) {
boolean removed = false;
for (int i = 0; i < history.size(); i++) {
if (!(history.get(i) instanceof org.springframework.ai.chat.messages.SystemMessage)) {
@@ -35,7 +46,7 @@ public class InMemoryChatMemory implements ChatMemory {
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) {
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) {
int chars = messages.stream()
.mapToInt(m -> m.getText() != null ? m.getText().length() : 0)
.sum();
return chars / CHARS_PER_TOKEN;
return chars / agentConfig.getCharsPerToken();
}
@Override

View File

@@ -1,45 +1,70 @@
package dev.sonpx.loyalty.agent.core.orchestrator;
import dev.sonpx.loyalty.agent.core.executor.AgentExecutor;
import dev.sonpx.loyalty.agent.core.planner.AgentPlanner;
import dev.sonpx.loyalty.agent.core.planner.Plan;
import dev.sonpx.loyalty.agent.core.classifier.Intent;
import dev.sonpx.loyalty.agent.core.classifier.IntentClassifier;
import dev.sonpx.loyalty.agent.core.executor.SimpleAgentExecutor;
import dev.sonpx.loyalty.agent.core.workflow.WorkflowExecutor;
import dev.sonpx.loyalty.agent.domain.ChatRequest;
import java.util.List;
import dev.sonpx.loyalty.agent.service.AgentEventPublisher;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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
@Service
@RequiredArgsConstructor
public class AgentOrchestrator {
private final AgentPlanner planner;
private final AgentExecutor executor;
private final IntentClassifier intentClassifier;
private final SimpleAgentExecutor simpleAgent;
private final WorkflowExecutor workflowExecutor;
private final AgentEventPublisher eventPublisher;
public void process(ChatRequest request, String sessionId) {
try {
log.info("Generating plan for session: {}", sessionId);
Plan plan;
try {
plan = planner.generatePlan(request, sessionId);
log.info("Generated plan for session: {}, plan: {}", sessionId, plan);
} catch (Exception e) {
log.warn("Failed to generate plan from LLM, falling back to default plan. Error: {}", e.getMessage());
plan = null;
// Priority 1: Continue active workflow (if user is mid-conversation for creation)
if (workflowExecutor.hasActiveWorkflow(sessionId)) {
log.info("Continuing active workflow for session: {}", sessionId);
workflowExecutor.continueWorkflow(request, sessionId);
return;
}
if (plan == null || plan.tasks() == null || plan.tasks().isEmpty()) {
log.warn("Generated plan is empty or failed, falling back to a single task");
plan = new Plan(List.of(new Plan.Task("1", "Answer user request")));
// Priority 2: Classify intent and route
Intent intent = intentClassifier.classify(request.prompt());
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) {
log.error("Error processing request: {}", e.getMessage(), e);
// The previous orchestrator didn't handle general process exception through eventPublisher
// directly here because it was in the stream onError, but let's just log it or rethrow.
log.error("Error processing request for session: {}", sessionId, e);
// Always notify user of errors — never swallow exceptions silently
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;
import dev.sonpx.loyalty.agent.core.classifier.Intent;
import dev.sonpx.loyalty.agent.domain.ChatRequest;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Component;
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
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<>();
if (StringUtils.hasText(request.conversationId())) {
@@ -27,4 +78,12 @@ public class PromptBuilder {
}
return String.join("\n", context) + "\n\n" + prompt;
}
/**
* @deprecated Use {@link #buildUserPrompt(ChatRequest)} instead.
*/
@Deprecated
public String build(ChatRequest request) {
return buildUserPrompt(request);
}
}

View File

@@ -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:
model: qwen3.5:4b
temperature: 0.3
num_ctx: 32768
mcp:
client:
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"));
}
}