refactor: overhaul agent workflow engine with scoped tool execution, configurable LLM model profiles, and enhanced state management.
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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-]+", "");
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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|[.!?])*.*");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user