refactor: replace old agent logic with orchestrator-based routing, intent classification, and workflow execution for campaign and rule management.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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ự)";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ spring:
|
||||
options:
|
||||
model: qwen3.5:4b
|
||||
temperature: 0.3
|
||||
num_ctx: 32768
|
||||
mcp:
|
||||
client:
|
||||
request-timeout: 60000
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user