refactor: overhaul agent workflow engine with scoped tool execution, configurable LLM model profiles, and enhanced state management.

This commit is contained in:
2026-07-22 18:41:45 +07:00
parent 369985ae9c
commit 66385746f6
32 changed files with 1454 additions and 171 deletions

View File

@@ -1,7 +1,11 @@
package dev.sonpx.loyalty.agent.config;
import dev.sonpx.loyalty.agent.core.config.AgentProperties;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.ollama.api.OllamaApi;
import org.springframework.ai.ollama.api.OllamaChatOptions;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -40,4 +44,39 @@ public class AgentConfig {
.defaultSystem(AGENT_SYSTEM_PROMPT)
.build();
}
@Bean("chatAgentChatClient")
public ChatClient chatAgentChatClient(AgentProperties agentProperties) {
return buildOllamaChatClient(agentProperties.getModels().getChat());
}
@Bean("queryAgentChatClient")
public ChatClient queryAgentChatClient(AgentProperties agentProperties) {
return buildOllamaChatClient(agentProperties.getModels().getQuery());
}
@Bean("creatorAgentChatClient")
public ChatClient creatorAgentChatClient(AgentProperties agentProperties) {
return buildOllamaChatClient(agentProperties.getModels().getCreator());
}
private ChatClient buildOllamaChatClient(AgentProperties.Model modelConfig) {
OllamaChatOptions.Builder options = OllamaChatOptions.builder()
.model(modelConfig.getModel())
.temperature(modelConfig.getTemperature())
.numCtx(modelConfig.getNumCtx());
if (modelConfig.isDisableThinking()) {
options.disableThinking();
}
OllamaChatModel model = OllamaChatModel.builder()
.ollamaApi(OllamaApi.builder()
.baseUrl(modelConfig.getBaseUrl())
.build())
.options(options.build())
.build();
return ChatClient.builder(model).build();
}
}

View File

@@ -21,44 +21,4 @@ public class AgentController {
agentService.chat(request, principal.getName());
}
// @GetMapping("/api/v1/agent/campaigns")
// public Object getCampaigns(
// @org.springframework.web.bind.annotation.RequestParam(defaultValue = "0") int page,
// @org.springframework.web.bind.annotation.RequestParam(defaultValue = "50") int size,
// @org.springframework.web.bind.annotation.RequestParam(required = false) String status,
// @org.springframework.web.bind.annotation.RequestParam(required = false) String name) {
//
// String url = String.format("/svc/reward/api/campaign/csr/list?page=%d&size=%d", page, size);
// if (status != null && !status.isEmpty()) url += "&status=" + status;
// if (name != null && !name.isEmpty()) url += "&name=" + name;
//
// String response = coreRestClient.get()
// .uri(url)
// .retrieve()
// .body(String.class);
// return standardizeResponse(response);
// }
//
// @GetMapping("/api/v1/agent/campaigns/{campaignId}/rules")
// public Object getCampaignRules(@org.springframework.web.bind.annotation.PathVariable("campaignId") String campaignId) {
// String response = coreRestClient.get()
// .uri("/svc/reward/api/campaign-rule/csr/list?page=0&size=100&campaignId.equals=" + campaignId)
// .retrieve()
// .body(String.class);
// return standardizeResponse(response);
// }
//
// Object standardizeResponse(String jsonStr) {
// try {
// com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
// com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(jsonStr);
// com.fasterxml.jackson.databind.JsonNode arrayNode = root;
// if (root.has("content") && root.get("content").isArray()) arrayNode = root.get("content");
// else if (root.has("data") && root.get("data").isArray()) arrayNode = root.get("data");
//
// return mapper.convertValue(arrayNode, new com.fasterxml.jackson.core.type.TypeReference<java.util.List<java.util.Map<String, Object>>>() {});
// } catch (Exception e) {
// return java.util.List.of();
// }
// }
}

View File

@@ -1,31 +0,0 @@
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

@@ -0,0 +1,70 @@
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 AgentProperties {
/** 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;
/** Agent-specific model routing. Defaults preserve the existing single-model setup. */
private Models models = new Models();
@Getter
@Setter
public static class Models {
private Model chat = Model.fast();
private Model query = Model.strong();
private Model creator = Model.creator();
}
@Getter
@Setter
public static class Model {
private String baseUrl = "http://192.168.99.10:11434";
private String model = "qwen3.5:4b";
private Double temperature = 0.3;
private Integer numCtx = 32768;
private boolean disableThinking;
static Model fast() {
Model config = new Model();
config.model = "qwen3.5:4b";
config.temperature = 0.7;
config.disableThinking = true;
return config;
}
static Model strong() {
return new Model();
}
static Model creator() {
Model config = new Model();
config.temperature = 0.2;
return config;
}
}
}

View File

@@ -0,0 +1,66 @@
package dev.sonpx.loyalty.agent.core.executor;
/**
* Capability scopes used to keep each agent's tool surface small and safe.
*/
public enum AgentToolScope {
ALL,
READ_ONLY,
CREATE_CAMPAIGN,
CREATE_RULE;
public boolean allows(String toolName) {
ToolCapability capability = ToolCapability.fromToolName(toolName);
return switch (this) {
case ALL -> true;
case READ_ONLY -> capability == ToolCapability.READ || capability == ToolCapability.VALIDATE;
case CREATE_CAMPAIGN -> capability == ToolCapability.CAMPAIGN_WRITE
|| capability == ToolCapability.CAMPAIGN_GENERATE_ID
|| capability == ToolCapability.VALIDATE
|| capability == ToolCapability.READ;
case CREATE_RULE -> capability == ToolCapability.RULE_WRITE
|| capability == ToolCapability.RULE_GENERATE_ID
|| capability == ToolCapability.VALIDATE
|| capability == ToolCapability.READ;
};
}
private enum ToolCapability {
READ,
VALIDATE,
CAMPAIGN_GENERATE_ID,
RULE_GENERATE_ID,
CAMPAIGN_WRITE,
RULE_WRITE,
OTHER;
static ToolCapability fromToolName(String toolName) {
if (toolName == null) {
return OTHER;
}
String normalized = toolName.toLowerCase();
if (normalized.contains("createcampaign")) {
return CAMPAIGN_WRITE;
}
if (normalized.contains("createrule")) {
return RULE_WRITE;
}
if (normalized.contains("generatecampaignid")) {
return CAMPAIGN_GENERATE_ID;
}
if (normalized.contains("generateruleid")) {
return RULE_GENERATE_ID;
}
if (normalized.startsWith("check") || normalized.contains("_check")) {
return VALIDATE;
}
if (normalized.startsWith("search") || normalized.contains("_search")
|| normalized.startsWith("count") || normalized.contains("_count")
|| normalized.startsWith("get") || normalized.contains("_get")) {
return READ;
}
return OTHER;
}
}
}

View File

@@ -1,15 +1,14 @@
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.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import java.util.List;
@@ -27,49 +26,66 @@ import java.util.List;
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SimpleAgentExecutor {
private final ChatClient chatClient;
private final ChatClient queryChatClient;
private final ChatClient chatChatClient;
private final ToolInterceptor toolInterceptor;
private final AgentEventPublisher eventPublisher;
private final AgentMemoryManager memoryManager;
private final PromptBuilder promptBuilder;
private final AgentConfig agentConfig;
public SimpleAgentExecutor(@Qualifier("queryAgentChatClient") ChatClient queryChatClient,
@Qualifier("chatAgentChatClient") ChatClient chatChatClient,
ToolInterceptor toolInterceptor,
AgentEventPublisher eventPublisher,
AgentMemoryManager memoryManager,
PromptBuilder promptBuilder) {
this.queryChatClient = queryChatClient;
this.chatChatClient = chatChatClient;
this.toolInterceptor = toolInterceptor;
this.eventPublisher = eventPublisher;
this.memoryManager = memoryManager;
this.promptBuilder = promptBuilder;
}
/**
* 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());
public void executeWithTools(ChatRequest request, String connectionSessionId, String conversationKey, Intent intent) {
List<ToolCallback> wrappedTools = toolInterceptor.getWrappedTools(
connectionSessionId, request.messageId(), AgentToolScope.READ_ONLY);
String systemPrompt = promptBuilder.buildSystemPrompt(intent);
String userPrompt = promptBuilder.buildUserPrompt(request);
log.info("SimpleAgent executing with tools for session: {}, intent: {}", sessionId, intent);
log.info("SimpleAgent executing with tools for conversation: {}, connection: {}, intent: {}", conversationKey, connectionSessionId, intent);
log.debug("System prompt: {}", systemPrompt);
chatClient.prompt()
queryChatClient.prompt()
.advisors(memoryManager.getAdvisor())
.advisors(a -> a
.param("chat_memory_conversation_id", sessionId)
.param("chat_memory_conversation_id", conversationKey)
.param("chat_memory_response_size", 50))
.system(systemPrompt)
.user(userPrompt)
.tools(wrappedTools)
.stream()
.content()
.doOnComplete(() -> eventPublisher.done(sessionId, request.messageId()))
.doOnComplete(() -> {
log.info("SimpleAgent execution completed for conversation: {}, connection: {}", conversationKey, connectionSessionId);
eventPublisher.done(connectionSessionId, request.messageId());
})
.subscribe(
content -> {
if (!content.isEmpty()) {
eventPublisher.token(sessionId, request.messageId(), content);
eventPublisher.token(connectionSessionId, request.messageId(), content);
}
},
error -> {
log.error("Error during SimpleAgent execution: {}", error.getMessage(), error);
eventPublisher.error(sessionId, request.messageId(),
log.error("Error during SimpleAgent execution for connection {}: {}", connectionSessionId, error.getMessage(), error);
eventPublisher.error(connectionSessionId, request.messageId(),
"Xin lỗi, đã xảy ra lỗi khi xử lý yêu cầu. Vui lòng thử lại.");
}
);
@@ -79,31 +95,34 @@ public class SimpleAgentExecutor {
* 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) {
public void executeDirectChat(ChatRequest request, String connectionSessionId, String conversationKey) {
String systemPrompt = promptBuilder.buildSystemPrompt(Intent.CONVERSATION);
String userPrompt = promptBuilder.buildUserPrompt(request);
log.info("DirectChat executing for session: {}", sessionId);
log.info("DirectChat executing for conversation: {}, connection: {}", conversationKey, connectionSessionId);
chatClient.prompt()
chatChatClient.prompt()
.advisors(memoryManager.getAdvisor())
.advisors(a -> a
.param("chat_memory_conversation_id", sessionId)
.param("chat_memory_conversation_id", conversationKey)
.param("chat_memory_response_size", 50))
.system(systemPrompt)
.user(userPrompt)
.stream()
.content()
.doOnComplete(() -> eventPublisher.done(sessionId, request.messageId()))
.doOnComplete(() -> {
log.info("DirectChat completed for conversation: {}, connection: {}", conversationKey, connectionSessionId);
eventPublisher.done(connectionSessionId, request.messageId());
})
.subscribe(
content -> {
if (!content.isEmpty()) {
eventPublisher.token(sessionId, request.messageId(), content);
eventPublisher.token(connectionSessionId, request.messageId(), content);
}
},
error -> {
log.error("Error during DirectChat execution: {}", error.getMessage(), error);
eventPublisher.error(sessionId, request.messageId(),
log.error("Error during DirectChat execution for connection {}: {}", connectionSessionId, error.getMessage(), error);
eventPublisher.error(connectionSessionId, request.messageId(),
"Xin lỗi, đã xảy ra lỗi. Vui lòng thử lại.");
}
);

View File

@@ -1,6 +1,6 @@
package dev.sonpx.loyalty.agent.core.executor;
import dev.sonpx.loyalty.agent.core.config.AgentConfig;
import dev.sonpx.loyalty.agent.core.config.AgentProperties;
import dev.sonpx.loyalty.agent.service.AgentEventPublisher;
import dev.sonpx.loyalty.agent.service.ToolResultPresentationProcessor;
import lombok.RequiredArgsConstructor;
@@ -28,16 +28,24 @@ public class ToolInterceptor {
private final List<ToolCallbackProvider> toolProviders;
private final AgentEventPublisher eventPublisher;
private final ToolResultPresentationProcessor toolResultProcessor;
private final AgentConfig agentConfig;
private final AgentProperties agentProperties;
public List<ToolCallback> getWrappedTools(String sessionId, String messageId) {
return getWrappedTools(sessionId, messageId, AgentToolScope.ALL);
}
public List<ToolCallback> getWrappedTools(String sessionId, String messageId, AgentToolScope scope) {
List<ToolCallback> wrappedTools = new ArrayList<>();
for (ToolCallbackProvider provider : toolProviders) {
for (ToolCallback tool : provider.getToolCallbacks()) {
wrappedTools.add(wrap(tool, sessionId, messageId));
String toolName = tool.getToolDefinition().name();
if (scope.allows(toolName)) {
wrappedTools.add(wrap(tool, sessionId, messageId));
}
}
}
log.debug("Loaded {} tools for scope {}", wrappedTools.size(), scope);
return wrappedTools;
}
@@ -89,7 +97,7 @@ public class ToolInterceptor {
if (result == null) {
return result;
}
int maxChars = agentConfig.getMaxToolResultChars();
int maxChars = agentProperties.getMaxToolResultChars();
if (result.length() <= maxChars) {
return result;
}

View File

@@ -0,0 +1,19 @@
package dev.sonpx.loyalty.agent.core.memory;
import dev.sonpx.loyalty.agent.domain.ChatRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* Chooses the stable key used by memory and workflow state.
*/
@Component
public class ConversationKeyResolver {
public String resolve(ChatRequest request, String connectionSessionId) {
if (request != null && StringUtils.hasText(request.conversationId())) {
return request.conversationId();
}
return connectionSessionId;
}
}

View File

@@ -1,6 +1,6 @@
package dev.sonpx.loyalty.agent.core.memory;
import dev.sonpx.loyalty.agent.core.config.AgentConfig;
import dev.sonpx.loyalty.agent.core.config.AgentProperties;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;
import org.springframework.stereotype.Component;
@@ -14,7 +14,7 @@ import java.util.concurrent.ConcurrentHashMap;
* In-memory chat memory with token-aware truncation.
*
* Improvements over original:
* - Configurable max tokens via AgentConfig
* - Configurable max tokens via AgentProperties
* - Vietnamese-aware token estimation (chars/2 instead of chars/4)
* - Preserves system messages during truncation
*/
@@ -22,10 +22,10 @@ import java.util.concurrent.ConcurrentHashMap;
public class InMemoryChatMemory implements ChatMemory {
private final Map<String, List<Message>> conversationHistory = new ConcurrentHashMap<>();
private final AgentConfig agentConfig;
private final AgentProperties agentProperties;
public InMemoryChatMemory(AgentConfig agentConfig) {
this.agentConfig = agentConfig;
public InMemoryChatMemory(AgentProperties agentProperties) {
this.agentProperties = agentProperties;
}
@Override
@@ -37,7 +37,7 @@ public class InMemoryChatMemory implements ChatMemory {
history.addAll(messages);
// Truncation: remove oldest non-system messages when over token limit
while (history.size() > 1 && estimateTokens(history) > agentConfig.getMaxMemoryTokens()) {
while (history.size() > 1 && estimateTokens(history) > agentProperties.getMaxMemoryTokens()) {
boolean removed = false;
for (int i = 0; i < history.size(); i++) {
if (!(history.get(i) instanceof org.springframework.ai.chat.messages.SystemMessage)) {
@@ -63,7 +63,7 @@ public class InMemoryChatMemory implements ChatMemory {
int chars = messages.stream()
.mapToInt(m -> m.getText() != null ? m.getText().length() : 0)
.sum();
return chars / agentConfig.getCharsPerToken();
return chars / agentProperties.getCharsPerToken();
}
@Override

View File

@@ -33,37 +33,43 @@ public class AgentOrchestrator {
private final WorkflowExecutor workflowExecutor;
private final AgentEventPublisher eventPublisher;
public void process(ChatRequest request, String sessionId) {
public void process(ChatRequest request, String connectionSessionId, String conversationKey) {
try {
// 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);
if (workflowExecutor.hasActiveWorkflow(conversationKey)) {
if (workflowExecutor.isSideQuery(request.prompt())) {
log.info("Routing side-query while preserving active workflow for conversation: {}", conversationKey);
Intent intent = intentClassifier.classify(request.prompt());
simpleAgent.executeWithTools(request, connectionSessionId, conversationKey, intent);
} else {
log.info("Continuing active workflow for conversation: {}", conversationKey);
workflowExecutor.continueWorkflow(request, connectionSessionId, conversationKey);
}
return;
}
// Priority 2: Classify intent and route
Intent intent = intentClassifier.classify(request.prompt());
log.info("Classified intent: {} for session: {}", intent, sessionId);
log.info("Classified intent: {} for conversation: {}, connection: {}", intent, conversationKey, connectionSessionId);
switch (intent) {
case CONVERSATION -> {
log.info("Routing to DirectChat (no tools)");
simpleAgent.executeDirectChat(request, sessionId);
simpleAgent.executeDirectChat(request, connectionSessionId, conversationKey);
}
case CAMPAIGN_CREATE, RULE_CREATE -> {
log.info("Starting ConversationWorkflow for: {}", intent);
workflowExecutor.startWorkflow(intent, request, sessionId);
workflowExecutor.startWorkflow(intent, request, connectionSessionId, conversationKey);
}
case CAMPAIGN_QUERY, RULE_QUERY, UNKNOWN -> {
log.info("Routing to SimpleAgent with tools");
simpleAgent.executeWithTools(request, sessionId, intent);
simpleAgent.executeWithTools(request, connectionSessionId, conversationKey, intent);
}
}
} catch (Exception e) {
log.error("Error processing request for session: {}", sessionId, e);
log.error("Error processing request for conversation: {}, connection: {}", conversationKey, connectionSessionId, e);
// Always notify user of errors — never swallow exceptions silently
eventPublisher.error(sessionId, request.messageId(),
eventPublisher.error(connectionSessionId, 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

@@ -31,14 +31,16 @@ public class PromptBuilder {
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
HƯỚNG DẪN CHỌN TOOL VÀ TRUYỀN THAM SỐ:
- Hỏi xem/lấy danh sách chiến dịch chung → dùng searchCampaigns. KHÔNG truyền từ khóa chung ("chiến dịch", "danh sách", "campaign") vào param `search`, hãy để `search` = null hoặc rỗng. Chỉ truyền `search` khi tìm kiếm tên/mã chiến dịch cụ thể.
- Hỏi xem/lấy danh sách thể lệ/rule chung → dùng searchRules. KHÔNG truyền từ khóa chung ("rule", "thể lệ", "danh sách") vào param `search`, hãy để `search` = null hoặc rỗng.
- Đế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""";
- Kiểm tra mã → dùng checkCampaignId hoặc checkRuleId
- Lấy chi tiết → dùng getCampaignById hoặc getRuleById
Bạn đang ở Query Agent: KHÔNG tạo mới dữ liệu. Nếu người dùng muốn tạo chiến dịch/rule,
hãy nói họ xác nhận yêu cầu tạo để chuyển sang workflow tạo.""";
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.

View File

@@ -0,0 +1,105 @@
package dev.sonpx.loyalty.agent.core.workflow;
import dev.sonpx.loyalty.agent.core.classifier.Intent;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* Deterministic, low-cost extraction for workflow state.
*
* It intentionally stores raw user-provided text as a fallback, because small local
* models are not reliable enough to be the only source of workflow state.
*/
@Component
public class WorkflowDataExtractor {
private static final Pattern KEY_VALUE = Pattern.compile("^\\s*(?:[-*]\\s*)?(?:\\d+[.)]\\s*)?([^:-]{2,40})\\s*[:-]\\s*(.+)$");
private static final Pattern CAMPAIGN_ID = Pattern.compile("(?i)\\b(?:campaign|chi[eế]n d[iị]ch|m[aã])(?:\\s*id)?\\s*[:#-]?\\s*([A-Z0-9_-]{3,})\\b");
private static final Pattern RULE_ID = Pattern.compile("(?i)\\b(?:rule|th[eể] l[eệ]|m[aã])(?:\\s*id)?\\s*[:#-]?\\s*([A-Z0-9_-]{3,})\\b");
private static final Pattern DATE_RANGE = Pattern.compile("(\\d{1,2}[/-]\\d{1,2}[/-]\\d{2,4}|\\d{4}-\\d{1,2}-\\d{1,2})\\s*(?:đ[eế]n|->|t[oớ]i|-)\\s*(\\d{1,2}[/-]\\d{1,2}[/-]\\d{2,4}|\\d{4}-\\d{1,2}-\\d{1,2})", Pattern.CASE_INSENSITIVE);
public Map<String, Object> extract(WorkflowState state, String userInput) {
Map<String, Object> extracted = new LinkedHashMap<>();
if (!StringUtils.hasText(userInput)) {
return extracted;
}
String phaseKey = switch (state.getCurrentPhase()) {
case BASIC_INFO -> "Thông tin cơ bản";
case REWARD_CONFIG -> "Cấu hình thưởng";
case CONFIRMING -> "Điều chỉnh khi xác nhận";
default -> "Thông tin bổ sung";
};
extracted.put(phaseKey, userInput.trim());
extractKeyValues(userInput, extracted);
extractKnownFields(state.getWorkflowType(), userInput, extracted);
return extracted;
}
public boolean hasEnoughForNextPhase(WorkflowState state, String userInput) {
if (!StringUtils.hasText(userInput)) {
return false;
}
if (state.getWorkflowType() == Intent.RULE_CREATE && state.getCurrentPhase() == WorkflowState.Phase.REWARD_CONFIG) {
return true;
}
return userInput.trim().length() >= 20 || state.getCollectedData().size() >= 3;
}
private void extractKeyValues(String userInput, Map<String, Object> extracted) {
for (String line : userInput.split("\\R")) {
Matcher matcher = KEY_VALUE.matcher(line);
if (matcher.matches()) {
String key = normalizeKey(matcher.group(1));
String value = matcher.group(2).trim();
if (StringUtils.hasText(key) && StringUtils.hasText(value)) {
extracted.put(key, value);
}
}
}
}
private void extractKnownFields(Intent workflowType, String userInput, Map<String, Object> extracted) {
Matcher dateRange = DATE_RANGE.matcher(userInput);
if (dateRange.find()) {
extracted.put("Thời gian hiệu lực", dateRange.group(1) + " -> " + dateRange.group(2));
}
String lower = userInput.toLowerCase();
if (workflowType == Intent.CAMPAIGN_CREATE) {
if (lower.contains("base")) {
extracted.put("Loại chiến dịch", "B");
} else if (lower.contains("tactical")) {
extracted.put("Loại chiến dịch", "T");
}
return;
}
Matcher campaignId = CAMPAIGN_ID.matcher(userInput);
if (campaignId.find()) {
extracted.put("Campaign ID", campaignId.group(1));
}
Matcher ruleId = RULE_ID.matcher(userInput);
if (ruleId.find()) {
extracted.put("Rule ID", ruleId.group(1));
}
for (String type : new String[] {"AWD", "RED", "IRED", "ADJ", "CEP", "REP", "TEP", "MAWD"}) {
if (lower.contains(type.toLowerCase())) {
extracted.put("Loại rule", type);
break;
}
}
}
private String normalizeKey(String key) {
return key.trim()
.replaceAll("\\s+", " ")
.replaceAll("^[0-9.)\\s-]+", "");
}
}

View File

@@ -1,19 +1,21 @@
package dev.sonpx.loyalty.agent.core.workflow;
import dev.sonpx.loyalty.agent.core.classifier.Intent;
import dev.sonpx.loyalty.agent.core.executor.AgentToolScope;
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.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
/**
* Manages multi-turn conversation workflows for creating campaigns and rules.
@@ -31,17 +33,32 @@ import java.util.concurrent.ConcurrentHashMap;
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class WorkflowExecutor {
private static final Pattern SIDE_QUERY_PATTERN = Pattern.compile(
"(?i)^\\s*(xem|tìm|danh sách|liệt kê|chi tiết|có bao nhiêu|đếm|show|list)\\b.*");
private final ChatClient chatClient;
private final ToolInterceptor toolInterceptor;
private final AgentEventPublisher eventPublisher;
private final AgentMemoryManager memoryManager;
private final WorkflowDataExtractor dataExtractor;
/** Active workflows per session */
private final Map<String, WorkflowState> activeWorkflows = new ConcurrentHashMap<>();
public WorkflowExecutor(@Qualifier("creatorAgentChatClient") ChatClient chatClient,
ToolInterceptor toolInterceptor,
AgentEventPublisher eventPublisher,
AgentMemoryManager memoryManager,
WorkflowDataExtractor dataExtractor) {
this.chatClient = chatClient;
this.toolInterceptor = toolInterceptor;
this.eventPublisher = eventPublisher;
this.memoryManager = memoryManager;
this.dataExtractor = dataExtractor;
}
/**
* Check if there's an active workflow for this session.
*/
@@ -57,15 +74,26 @@ public class WorkflowExecutor {
return activeWorkflows.get(sessionId);
}
/**
* Allow explicit read/query turns while preserving the active create workflow.
*/
public boolean isSideQuery(String userInput) {
return userInput != null && SIDE_QUERY_PATTERN.matcher(userInput).find();
}
/**
* 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) {
/**
* 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 connectionSessionId, String conversationKey) {
WorkflowState state = new WorkflowState(intent);
activeWorkflows.put(sessionId, state);
activeWorkflows.put(conversationKey, state);
log.info("Starting {} workflow for session: {}", intent, sessionId);
log.info("Starting {} workflow for conversation: {}, connection: {}", intent, conversationKey, connectionSessionId);
// Inject activeCampaignId if available (useful for rule creation)
if (request.activeCampaignId() != null && !request.activeCampaignId().isBlank()) {
@@ -76,17 +104,24 @@ public class WorkflowExecutor {
String systemPrompt = getPhasePrompt(state);
String userPrompt = request.prompt() != null ? request.prompt() : "Bắt đầu tạo";
streamResponse(systemPrompt, userPrompt, sessionId, request.messageId(), false);
streamResponse(systemPrompt, userPrompt, connectionSessionId, conversationKey, 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);
public void continueWorkflow(ChatRequest request, String connectionSessionId, String conversationKey) {
WorkflowState state = activeWorkflows.get(conversationKey);
if (state == null || !state.isActive()) {
log.warn("No active workflow for session: {}", sessionId);
log.warn("No active workflow for conversation: {}", conversationKey);
return;
}
if (state.isExecuting()) {
streamResponse(
"Việc tạo đang được thực hiện. Hãy nói ngắn gọn rằng hệ thống đang xử lý và người dùng vui lòng chờ kết quả.",
request.prompt(), connectionSessionId, conversationKey, request.messageId(), false);
return;
}
@@ -95,19 +130,19 @@ public class WorkflowExecutor {
// Check for cancellation at any phase
if (WorkflowPromptBuilder.isCancellation(userInput)) {
state.cancel();
activeWorkflows.remove(sessionId);
activeWorkflows.remove(conversationKey);
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);
userInput, connectionSessionId, conversationKey, request.messageId(), false);
return;
}
log.info("Continuing workflow for session: {}, phase: {}", sessionId, state.getCurrentPhase());
log.info("Continuing workflow for conversation: {}, phase: {}", conversationKey, 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);
case BASIC_INFO -> handleBasicInfoResponse(state, request, connectionSessionId, conversationKey);
case REWARD_CONFIG -> handleRewardConfigResponse(state, request, connectionSessionId, conversationKey);
case CONFIRMING -> handleConfirmation(state, request, connectionSessionId, conversationKey);
default -> log.warn("Unexpected workflow phase: {}", state.getCurrentPhase());
}
}
@@ -116,11 +151,19 @@ public class WorkflowExecutor {
* 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) {
private void handleBasicInfoResponse(WorkflowState state, ChatRequest request, String connectionSessionId, String conversationKey) {
state.putAllData(dataExtractor.extract(state, request.prompt()));
if (!dataExtractor.hasEnoughForNextPhase(state, request.prompt())) {
streamResponse(WorkflowPromptBuilder.missingInfoPrompt(state),
request.prompt(), connectionSessionId, conversationKey, request.messageId(), false);
return;
}
String systemPrompt = WorkflowPromptBuilder.extractDataPrompt(state);
// LLM extracts data and confirms — no tools needed yet
streamResponse(systemPrompt, request.prompt(), sessionId, request.messageId(), false);
streamResponse(systemPrompt, request.prompt(), connectionSessionId, conversationKey, request.messageId(), false);
// Advance phase after LLM responds
// The LLM will indicate if data is complete; user's next message drives phase transition
@@ -130,17 +173,25 @@ public class WorkflowExecutor {
/**
* Handle user's response during REWARD_CONFIG phase (rule only).
*/
private void handleRewardConfigResponse(WorkflowState state, ChatRequest request, String sessionId) {
private void handleRewardConfigResponse(WorkflowState state, ChatRequest request, String connectionSessionId, String conversationKey) {
// 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);
streamResponse(confirmPrompt, request.prompt(), connectionSessionId, conversationKey, request.messageId(), false);
return;
}
state.putAllData(dataExtractor.extract(state, request.prompt()));
if (!dataExtractor.hasEnoughForNextPhase(state, request.prompt())) {
streamResponse(WorkflowPromptBuilder.missingInfoPrompt(state),
request.prompt(), connectionSessionId, conversationKey, request.messageId(), false);
return;
}
String systemPrompt = WorkflowPromptBuilder.extractDataPrompt(state);
streamResponse(systemPrompt, request.prompt(), sessionId, request.messageId(), false);
streamResponse(systemPrompt, request.prompt(), connectionSessionId, conversationKey, request.messageId(), false);
// Advance to CONFIRMING
state.advancePhase();
@@ -149,25 +200,31 @@ public class WorkflowExecutor {
/**
* Handle user's confirmation or rejection.
*/
private void handleConfirmation(WorkflowState state, ChatRequest request, String sessionId) {
private void handleConfirmation(WorkflowState state, ChatRequest request, String connectionSessionId, String conversationKey) {
if (WorkflowPromptBuilder.isConfirmation(request.prompt())) {
// User confirmed → execute creation with tools
log.info("User confirmed creation for session: {}", sessionId);
state.advancePhase(); // → COMPLETED
log.info("User confirmed creation for conversation: {}", conversationKey);
state.startExecuting();
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);
AgentToolScope toolScope = (state.getWorkflowType() == Intent.CAMPAIGN_CREATE)
? AgentToolScope.CREATE_CAMPAIGN
: AgentToolScope.CREATE_RULE;
streamResponse(executePrompt, request.prompt(), connectionSessionId, conversationKey, request.messageId(), true, toolScope,
() -> {
state.complete();
activeWorkflows.remove(conversationKey);
},
state::failExecution);
} else {
// User wants to modify — go back to collect more info
state.putAllData(dataExtractor.extract(state, request.prompt()));
String systemPrompt = WorkflowPromptBuilder.extractDataPrompt(state);
streamResponse(systemPrompt, request.prompt(), sessionId, request.messageId(), false);
streamResponse(systemPrompt, request.prompt(), connectionSessionId, conversationKey, request.messageId(), false);
}
}
@@ -195,32 +252,48 @@ public class WorkflowExecutor {
* @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) {
String connectionSessionId, String conversationKey,
String messageId, boolean withTools) {
streamResponse(systemPrompt, userPrompt, connectionSessionId, conversationKey, messageId, withTools, AgentToolScope.ALL, null, null);
}
private void streamResponse(String systemPrompt, String userPrompt,
String connectionSessionId, String conversationKey,
String messageId, boolean withTools,
AgentToolScope toolScope, Runnable onComplete, Runnable onError) {
var builder = chatClient.prompt()
.advisors(memoryManager.getAdvisor())
.advisors(a -> a
.param("chat_memory_conversation_id", sessionId)
.param("chat_memory_conversation_id", conversationKey)
.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);
List<ToolCallback> wrappedTools = toolInterceptor.getWrappedTools(connectionSessionId, messageId, toolScope);
builder.tools(wrappedTools);
}
builder.stream()
.content()
.doOnComplete(() -> eventPublisher.done(sessionId, messageId))
.doOnComplete(() -> {
if (onComplete != null) {
onComplete.run();
}
eventPublisher.done(connectionSessionId, messageId);
})
.subscribe(
content -> {
if (!content.isEmpty()) {
eventPublisher.token(sessionId, messageId, content);
eventPublisher.token(connectionSessionId, messageId, content);
}
},
error -> {
log.error("Error during workflow execution: {}", error.getMessage(), error);
eventPublisher.error(sessionId, messageId,
if (onError != null) {
onError.run();
}
eventPublisher.error(connectionSessionId, messageId,
"Xin lỗi, đã xảy ra lỗi. Vui lòng thử lại.");
}
);

View File

@@ -145,6 +145,18 @@ public class WorkflowPromptBuilder {
""".formatted(target, formatData(state.getCollectedData()));
}
static String missingInfoPrompt(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, nhưng thông tin hiện tại chưa đủ để sang bước tiếp theo.
Thông tin đã có:
%s
Hãy hỏi ngắn gọn các mục còn thiếu. KHÔNG gọi tool nào.
""".formatted(target, formatData(state.getCollectedData()));
}
/**
* Detect if user's response indicates confirmation.
*/
@@ -160,7 +172,7 @@ public class WorkflowPromptBuilder {
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).*");
return lower.matches("(?i)^\\s*(hủy|cancel|thôi|bỏ qua workflow|dừng|stop|không tạo|không tạo nữa|hủy tạo)(\\s|[.!?])*.*");
}
/**

View File

@@ -36,6 +36,7 @@ public class WorkflowState {
private Phase currentPhase;
private final Map<String, Object> collectedData;
private String lastQuestion;
private boolean executing;
public WorkflowState(Intent workflowType) {
this.workflowType = workflowType;
@@ -48,6 +49,13 @@ public class WorkflowState {
log.debug("Workflow data collected: {} = {}", key, value);
}
public void putAllData(Map<String, Object> data) {
if (data == null || data.isEmpty()) {
return;
}
data.forEach(this::putData);
}
public Object getData(String key) {
return collectedData.get(key);
}
@@ -57,7 +65,23 @@ public class WorkflowState {
}
public boolean isActive() {
return currentPhase != Phase.COMPLETED && currentPhase != Phase.CANCELLED;
return executing || (currentPhase != Phase.COMPLETED && currentPhase != Phase.CANCELLED);
}
public void startExecuting() {
this.executing = true;
log.info("Workflow execution started");
}
public void complete() {
this.executing = false;
this.currentPhase = Phase.COMPLETED;
log.info("Workflow completed");
}
public void failExecution() {
this.executing = false;
log.info("Workflow execution failed; keeping workflow active for retry");
}
public void advancePhase() {

View File

@@ -2,9 +2,11 @@ package dev.sonpx.loyalty.agent.service;
import dev.sonpx.loyalty.agent.domain.AgentEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class AgentEventPublisher {
@@ -34,6 +36,7 @@ public class AgentEventPublisher {
}
private void send(String sessionId, AgentEvent event) {
log.debug("Publishing STOMP event type={} for session={}", event.type(), sessionId);
messagingTemplate.convertAndSendToUser(sessionId, CHAT_EVENTS_DESTINATION, event);
}
}

View File

@@ -1,6 +1,7 @@
package dev.sonpx.loyalty.agent.service;
import dev.sonpx.loyalty.agent.core.orchestrator.AgentOrchestrator;
import dev.sonpx.loyalty.agent.core.memory.ConversationKeyResolver;
import dev.sonpx.loyalty.agent.domain.ChatRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -10,8 +11,10 @@ import org.springframework.stereotype.Service;
public class LoyaltyAgentService {
private final AgentOrchestrator agentOrchestrator;
private final ConversationKeyResolver conversationKeyResolver;
public void chat(ChatRequest request, String sessionId) {
agentOrchestrator.process(request, sessionId);
public void chat(ChatRequest request, String connectionSessionId) {
String conversationKey = conversationKeyResolver.resolve(request, connectionSessionId);
agentOrchestrator.process(request, connectionSessionId, conversationKey);
}
}

View File

@@ -1,7 +1,13 @@
server:
port: 9332
shutdown: graceful
spring:
devtools:
restart:
quiet-period: 1000ms
poll-interval: 2000ms
mvc:
async:
request-timeout: 3600000
@@ -23,7 +29,7 @@ spring:
sse:
connections:
loyalty:
url: ${MCP_SERVER_URL:http://localhost:9331}
url: ${MCP_SERVER_URL:http://localhost:9331/sse}
logging:
pattern:
@@ -32,3 +38,24 @@ logging:
org.springframework.ai: INFO
dev.sonpx.loyalty: DEBUG
root: warn
agent:
models:
chat:
base-url: ${CHAT_OLLAMA_BASE_URL:http://localhost:11434}
model: ${CHAT_MODEL:qwen2.5:1.5b}
temperature: ${CHAT_MODEL_TEMPERATURE:0.7}
num-ctx: ${CHAT_MODEL_NUM_CTX:8192}
disable-thinking: ${CHAT_MODEL_DISABLE_THINKING:true}
query:
base-url: ${QUERY_OLLAMA_BASE_URL:http://192.168.99.10:11434}
model: ${QUERY_MODEL:qwen3.5:4b}
temperature: ${QUERY_MODEL_TEMPERATURE:0.3}
num-ctx: ${QUERY_MODEL_NUM_CTX:32768}
disable-thinking: ${QUERY_MODEL_DISABLE_THINKING:false}
creator:
base-url: ${CREATOR_OLLAMA_BASE_URL:http://192.168.99.10:11434}
model: ${CREATOR_MODEL:qwen3.5:4b}
temperature: ${CREATOR_MODEL_TEMPERATURE:0.2}
num-ctx: ${CREATOR_MODEL_NUM_CTX:32768}
disable-thinking: ${CREATOR_MODEL_DISABLE_THINKING:false}

View File

@@ -0,0 +1,36 @@
package dev.sonpx.loyalty.agent.core.executor;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class AgentToolScopeTest {
@Test
void readOnlyAllowsReadAndValidateTools() {
assertTrue(AgentToolScope.READ_ONLY.allows("searchCampaigns"));
assertTrue(AgentToolScope.READ_ONLY.allows("reward_getRuleById"));
assertTrue(AgentToolScope.READ_ONLY.allows("countRules"));
assertTrue(AgentToolScope.READ_ONLY.allows("checkCampaignId"));
}
@Test
void readOnlyRejectsWriteAndGenerateTools() {
assertFalse(AgentToolScope.READ_ONLY.allows("createCampaign"));
assertFalse(AgentToolScope.READ_ONLY.allows("createRule"));
assertFalse(AgentToolScope.READ_ONLY.allows("generateCampaignId"));
assertFalse(AgentToolScope.READ_ONLY.allows("generateRuleId"));
}
@Test
void createScopesOnlyAllowMatchingWriteTools() {
assertTrue(AgentToolScope.CREATE_CAMPAIGN.allows("generateCampaignId"));
assertTrue(AgentToolScope.CREATE_CAMPAIGN.allows("createCampaign"));
assertFalse(AgentToolScope.CREATE_CAMPAIGN.allows("createRule"));
assertTrue(AgentToolScope.CREATE_RULE.allows("generateRuleId"));
assertTrue(AgentToolScope.CREATE_RULE.allows("createRule"));
assertFalse(AgentToolScope.CREATE_RULE.allows("createCampaign"));
}
}

View File

@@ -0,0 +1,25 @@
package dev.sonpx.loyalty.agent.core.memory;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.sonpx.loyalty.agent.domain.ChatRequest;
import org.junit.jupiter.api.Test;
class ConversationKeyResolverTest {
private final ConversationKeyResolver resolver = new ConversationKeyResolver();
@Test
void usesConversationIdWhenPresent() {
ChatRequest request = new ChatRequest("conversation-1", "message-1", "hello", null);
assertEquals("conversation-1", resolver.resolve(request, "connection-1"));
}
@Test
void fallsBackToConnectionSessionId() {
ChatRequest request = new ChatRequest(" ", "message-1", "hello", null);
assertEquals("connection-1", resolver.resolve(request, "connection-1"));
}
}

View File

@@ -0,0 +1,33 @@
package dev.sonpx.loyalty.agent.core.workflow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.sonpx.loyalty.agent.core.classifier.Intent;
import java.util.Map;
import org.junit.jupiter.api.Test;
class WorkflowDataExtractorTest {
private final WorkflowDataExtractor extractor = new WorkflowDataExtractor();
@Test
void extractsRuleFieldsFromNaturalLanguage() {
WorkflowState state = new WorkflowState(Intent.RULE_CREATE);
Map<String, Object> data = extractor.extract(state,
"Tên rule: Birthday Bonus\nCampaign ID: CMP_BIRTHDAY\nLoại AWD\nHiệu lực 01/08/2026 -> 31/08/2026");
assertEquals("Birthday Bonus", data.get("Tên rule"));
assertEquals("CMP_BIRTHDAY", data.get("Campaign ID"));
assertEquals("AWD", data.get("Loại rule"));
assertEquals("01/08/2026 -> 31/08/2026", data.get("Thời gian hiệu lực"));
}
@Test
void treatsShortAnswersAsIncompleteForBasicInfo() {
WorkflowState state = new WorkflowState(Intent.CAMPAIGN_CREATE);
assertTrue(!extractor.hasEnoughForNextPhase(state, "ABC"));
}
}

View File

@@ -0,0 +1,18 @@
package dev.sonpx.loyalty.agent.core.workflow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class WorkflowPromptBuilderTest {
@Test
void cancellationRequiresExplicitStopIntent() {
assertTrue(WorkflowPromptBuilder.isCancellation("hủy tạo rule này"));
assertTrue(WorkflowPromptBuilder.isCancellation("cancel"));
assertFalse(WorkflowPromptBuilder.isCancellation("không giới hạn cap"));
assertFalse(WorkflowPromptBuilder.isCancellation("không cần schedule"));
}
}