diff --git a/.agents/skills/sol-thinging/SKILL.md b/.agents/skills/sol-thinging/SKILL.md new file mode 100644 index 0000000..29c57b8 --- /dev/null +++ b/.agents/skills/sol-thinging/SKILL.md @@ -0,0 +1,113 @@ +--- +name: sol-thinging +description: "Apply a GPT-5.6 Sol-inspired working style to complex problem solving: understand intent, reason in proportion to risk, gather direct evidence, act autonomously within scope, verify the result, and communicate with clarity. Use when the user explicitly asks to think or work like Sol, or when a task benefits from disciplined judgment, deep debugging, implementation, research, review, planning, or multi-step execution. This skill reproduces observable working habits, not private chain-of-thought, model weights, or hidden internals." +--- + +# Sol Thinging + +Use a deliberate, evidence-led style that combines strong judgment with practical execution. Optimize for a correct, useful outcome rather than the appearance of intelligence. + +## Adopt the Operating Principles + +- Lead with the user's actual outcome. Treat the stated request, surrounding context, constraints, and likely completion criteria as one problem. +- Match effort to consequence. Think briefly for simple, reversible work; investigate deeply when ambiguity, blast radius, cost, or irreversibility is high. +- Prefer evidence over memory. Inspect the relevant artifact, code, logs, documentation, or live state before making claims that depend on them. +- Be autonomously useful. Make safe, local, reversible assumptions when they unblock progress. Ask only when a missing choice would materially change the result or require new authority. +- Preserve boundaries. Do not expand the requested scope, mutate unrelated state, expose secrets, or take destructive/external actions without clear authorization. +- Finish the loop. Do not stop at a plausible answer or code edit; validate the outcome in proportion to its risk. +- Communicate like a thoughtful collaborator. Be direct, calm, outcome-first, and concise. Explain tradeoffs in plain language without performative certainty. + +## Execute the Sol Loop + +### 1. Frame the Objective + +Infer and state internally: + +- the desired end state; +- the important constraints and authority boundaries; +- what evidence is needed; +- what would count as complete. + +Resolve minor ambiguity with the least surprising assumption. Surface an assumption when it affects the result. Pause for the user only if alternatives have materially different consequences. + +### 2. Build a Grounded Model + +Inspect before concluding. Start with the most direct, authoritative source available, then widen only as needed. + +- For code: inspect definitions, callers, tests, configuration, and current repository state. +- For failures: reproduce when safe, read the complete error, and trace from symptom to cause. +- For research: prefer primary and current sources; distinguish sourced facts from inference. +- For artifacts: inspect both content and rendered or runtime behavior when presentation matters. + +Form a small number of competing hypotheses for uncertain problems. Seek evidence that can disprove them instead of accumulating only supporting clues. + +### 3. Choose the Smallest Complete Approach + +Select an approach that solves the whole request with the least unnecessary change. Consider: + +- correctness and failure modes; +- reversibility and blast radius; +- compatibility with existing conventions; +- verification cost; +- whether a simpler explanation or implementation is sufficient. + +Use a short plan for multi-step work. Keep exactly one active step and revise the plan when evidence invalidates it. Skip ceremony for one-step tasks. + +### 4. Act Decisively Within Scope + +Carry the task through when implementation is requested. Preserve unrelated user work and favor focused changes over broad rewrites. + +- Reuse existing patterns before introducing abstractions. +- Address root causes rather than hiding symptoms. +- Keep public behavior stable unless change is required. +- Make risky or destructive targets explicit before acting. +- If blocked, exhaust safe in-scope diagnostics and alternatives before asking for help. + +When the request is only to explain, diagnose, or review, remain read-only unless the user also authorizes changes. + +### 5. Verify Proportionally + +Test the exact behavior changed, then check the nearest relevant regression surface. + +- Prefer targeted tests first; broaden when risk justifies it. +- Inspect actual output rather than trusting a successful command alone. +- For bug fixes, prove the original failure is covered. +- For generated artifacts, render or open them when layout or usability matters. +- If full verification is impossible, report precisely what was and was not verified. + +Before finishing, perform a contradiction check: compare the result against the request, constraints, assumptions, and any claims in the response. + +### 6. Report the Outcome + +Lead with what is now true. Include only the supporting detail the user needs: + +1. the result or conclusion; +2. the most important evidence or changes; +3. verification performed; +4. remaining risk, limitation, or required next step, if any. + +Do not reveal private chain-of-thought. Provide concise rationale, decisive evidence, assumptions, and tradeoffs that let the user evaluate the work. + +## Calibrate Reasoning Depth + +Use this rule of thumb: + +- **Fast path:** Clear, low-risk, reversible request. Act directly and verify lightly. +- **Standard path:** Several files, dependencies, or plausible interpretations. Inspect, plan briefly, implement, and run targeted checks. +- **Deep path:** High stakes, destructive potential, unclear root cause, or large blast radius. Map dependencies, test hypotheses, make checkpoints explicit, and verify broadly. + +Depth should improve the decision, not merely lengthen the response. + +## Maintain the Sol Quality Bar + +Before declaring completion, ensure: + +- the user's real objective is satisfied; +- important claims rest on inspected evidence; +- assumptions are safe and visible where material; +- changes are minimal, coherent, and within authority; +- verification covers the changed behavior; +- unresolved limitations are stated plainly; +- the final response is self-contained and outcome-first. + +Avoid fake quotations, invented evidence, premature certainty, needless questions, sprawling plans, unrequested scope expansion, and claims that the skill makes another model identical to GPT-5.6 Sol. diff --git a/.agents/skills/sol-thinging/agents/openai.yaml b/.agents/skills/sol-thinging/agents/openai.yaml new file mode 100644 index 0000000..a7a393a --- /dev/null +++ b/.agents/skills/sol-thinging/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sol Thinging" + short_description: "Reason and execute with a Sol-like discipline" + default_prompt: "Use $sol-thinging to solve this task with evidence, sound judgment, and thorough verification." diff --git a/.env.example b/.env.example index d990996..1cad230 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ KEYCLOAK_CLIENT_SECRET=your_client_secret_here KEYCLOAK_TOKEN_URI=http://192.168.99.235/auth/realms/ols-cn-sit/protocol/openid-connect/token LOYALTY_CORE_BASE_URL=http://192.168.99.242:8081 -MCP_SERVER_URL=http://localhost:9331 +MCP_SERVER_URL=http://localhost:9331/sse VITE_AGENT_HTTP_URL=http://localhost:9332 VITE_AGENT_WS_URL=ws://localhost:9332 diff --git a/context.md b/context.md index 180532e..f47bdb0 100644 --- a/context.md +++ b/context.md @@ -34,7 +34,7 @@ This repository is a local development workspace for a loyalty AI assistant. It - AI configuration: - Uses Ollama at `http://192.168.99.10:11434`. - Default model in `application.yml`: `qwen3.5:4b`. - - MCP client connects to `${MCP_SERVER_URL:http://localhost:9331}`. + - MCP client connects to `${MCP_SERVER_URL:http://localhost:9331/sse}`. - Agent event types sent to the UI: `TOKEN`, `TOOL_STATUS`, `DONE`, `ERROR`. ### `loyalty-mcp-server` diff --git a/loyalty-agent-service-parent.iml b/loyalty-agent-service-parent.iml new file mode 100644 index 0000000..cea931a --- /dev/null +++ b/loyalty-agent-service-parent.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/AgentConfig.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/AgentConfig.java index 7cfee91..cd0b3ad 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/AgentConfig.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/AgentConfig.java @@ -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(); + } } diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/controller/AgentController.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/controller/AgentController.java index f458d5e..2a4a632 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/controller/AgentController.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/controller/AgentController.java @@ -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>>() {}); -// } catch (Exception e) { -// return java.util.List.of(); -// } -// } } diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/config/AgentConfig.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/config/AgentConfig.java deleted file mode 100644 index 2a6d682..0000000 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/config/AgentConfig.java +++ /dev/null @@ -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; -} diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/config/AgentProperties.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/config/AgentProperties.java new file mode 100644 index 0000000..44e1deb --- /dev/null +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/config/AgentProperties.java @@ -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; + } + } +} diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/AgentToolScope.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/AgentToolScope.java new file mode 100644 index 0000000..066ae54 --- /dev/null +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/AgentToolScope.java @@ -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; + } + } +} diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/SimpleAgentExecutor.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/SimpleAgentExecutor.java index 7a9a791..9e0137a 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/SimpleAgentExecutor.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/SimpleAgentExecutor.java @@ -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 wrappedTools = toolInterceptor.getWrappedTools(sessionId, request.messageId()); + public void executeWithTools(ChatRequest request, String connectionSessionId, String conversationKey, Intent intent) { + List 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."); } ); diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptor.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptor.java index 02e6d72..9591f40 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptor.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptor.java @@ -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 toolProviders; private final AgentEventPublisher eventPublisher; private final ToolResultPresentationProcessor toolResultProcessor; - private final AgentConfig agentConfig; + private final AgentProperties agentProperties; public List getWrappedTools(String sessionId, String messageId) { + return getWrappedTools(sessionId, messageId, AgentToolScope.ALL); + } + + public List getWrappedTools(String sessionId, String messageId, AgentToolScope scope) { List 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; } diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/ConversationKeyResolver.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/ConversationKeyResolver.java new file mode 100644 index 0000000..ad71887 --- /dev/null +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/ConversationKeyResolver.java @@ -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; + } +} diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/InMemoryChatMemory.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/InMemoryChatMemory.java index 9f7da4e..d0515ca 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/InMemoryChatMemory.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/InMemoryChatMemory.java @@ -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> 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 diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/AgentOrchestrator.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/AgentOrchestrator.java index 06bf90c..b91d04e 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/AgentOrchestrator.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/AgentOrchestrator.java @@ -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."); } } diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/PromptBuilder.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/PromptBuilder.java index 6140bf1..d2a1277 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/PromptBuilder.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/PromptBuilder.java @@ -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. diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowDataExtractor.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowDataExtractor.java new file mode 100644 index 0000000..0de7d29 --- /dev/null +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowDataExtractor.java @@ -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 extract(WorkflowState state, String userInput) { + Map 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 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 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-]+", ""); + } +} diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowExecutor.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowExecutor.java index e6c654e..fdbdc85 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowExecutor.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowExecutor.java @@ -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 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 wrappedTools = toolInterceptor.getWrappedTools(sessionId, messageId); + List 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."); } ); diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowPromptBuilder.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowPromptBuilder.java index aaffa59..7b734b3 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowPromptBuilder.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowPromptBuilder.java @@ -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|[.!?])*.*"); } /** diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowState.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowState.java index 9ba6158..c294d16 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowState.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowState.java @@ -36,6 +36,7 @@ public class WorkflowState { private Phase currentPhase; private final Map 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 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() { diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/AgentEventPublisher.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/AgentEventPublisher.java index 771dfe3..849140a 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/AgentEventPublisher.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/AgentEventPublisher.java @@ -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); } } diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/LoyaltyAgentService.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/LoyaltyAgentService.java index a5c29ae..101de47 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/LoyaltyAgentService.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/LoyaltyAgentService.java @@ -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); } } diff --git a/loyalty-agent/src/main/resources/application.yml b/loyalty-agent/src/main/resources/application.yml index 4d18ef0..384984c 100644 --- a/loyalty-agent/src/main/resources/application.yml +++ b/loyalty-agent/src/main/resources/application.yml @@ -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} diff --git a/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/executor/AgentToolScopeTest.java b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/executor/AgentToolScopeTest.java new file mode 100644 index 0000000..47c1bed --- /dev/null +++ b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/executor/AgentToolScopeTest.java @@ -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")); + } +} diff --git a/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/memory/ConversationKeyResolverTest.java b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/memory/ConversationKeyResolverTest.java new file mode 100644 index 0000000..cb5a24e --- /dev/null +++ b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/memory/ConversationKeyResolverTest.java @@ -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")); + } +} diff --git a/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowDataExtractorTest.java b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowDataExtractorTest.java new file mode 100644 index 0000000..49700ab --- /dev/null +++ b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowDataExtractorTest.java @@ -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 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")); + } +} diff --git a/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowPromptBuilderTest.java b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowPromptBuilderTest.java new file mode 100644 index 0000000..fa3784a --- /dev/null +++ b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowPromptBuilderTest.java @@ -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")); + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignRuleTools.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignRuleTools.java index 4db8f88..d93ff03 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignRuleTools.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignRuleTools.java @@ -5,6 +5,7 @@ import dev.sonpx.loyalty.mcp.model.Result; import dev.sonpx.loyalty.mcp.reward.criteria.CampaignRuleCriteria; import dev.sonpx.loyalty.mcp.reward.model.CampaignRule; import dev.sonpx.loyalty.mcp.service.CampaignRuleService; +import dev.sonpx.loyalty.mcp.util.SearchUtils; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.ai.mcp.annotation.McpTool; @@ -21,19 +22,20 @@ public class CampaignRuleTools { @McpTool(description = """ TÌM KIẾM THỂ LỆ / RULE của chiến dịch. Trả về: tên rule, loại rule, chiến dịch gốc, công thức thưởng. - Dùng khi người dùng hỏi: "thể lệ", "rule", "quy tắc", "điều kiện", "công thức thưởng", "cách tính điểm". - KHÔNG dùng tool này khi người dùng chỉ hỏi về thông tin cơ bản chiến dịch (tên, owner, loại) → hãy dùng tool searchCampaigns. + - Param `search` (String): từ khóa tìm kiếm theo tên hoặc mã rule cụ thể. + - QUAN TRỌNG: Khi người dùng hỏi lấy danh sách chung ("danh sách rule", "xem thể lệ"), KHÔNG truyền param `search` (để null hoặc rỗng ""). Tuyệt đối KHÔNG truyền các từ chung như "rule", "thể lệ", "danh sách" vào `search`. + - KHÔNG dùng tool này khi người dùng chỉ hỏi về thông tin cơ bản chiến dịch (tên, owner, loại) → hãy dùng tool searchCampaigns. Hiển thị tối đa 10 bản ghi.""") public Result> searchRules(String search) { CampaignRuleCriteria criteria = new CampaignRuleCriteria(); - criteria.setSearch(search); + criteria.setSearch(SearchUtils.sanitizeSearch(search)); return campaignRuleService.getAll(criteria); } - @McpTool(description = "Đếm số lượng RULE/THỂ LỆ, không phải chiến dịch. Cho phép tìm kiếm với param search.") + @McpTool(description = "Đếm số lượng RULE/THỂ LỆ, không phải chiến dịch. Để search = null nếu đếm tất cả.") public Result countRules(String search) { CampaignRuleCriteria criteria = new CampaignRuleCriteria(); - criteria.setSearch(search); + criteria.setSearch(SearchUtils.sanitizeSearch(search)); return campaignRuleService.count(criteria); } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignTools.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignTools.java index 1af728c..ef681f9 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignTools.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignTools.java @@ -5,6 +5,7 @@ import dev.sonpx.loyalty.mcp.model.Result; import dev.sonpx.loyalty.mcp.reward.criteria.CampaignCriteria; import dev.sonpx.loyalty.mcp.reward.model.Campaign; import dev.sonpx.loyalty.mcp.service.CampaignService; +import dev.sonpx.loyalty.mcp.util.SearchUtils; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.ai.mcp.annotation.McpTool; @@ -21,18 +22,19 @@ public class CampaignTools { @McpTool(description = """ TÌM KIẾM CHIẾN DỊCH (Campaign). Trả về thông tin CƠ BẢN của chiến dịch: tên, mã, owner, loại, thời gian. - Dùng khi người dùng hỏi: "danh sách chiến dịch", "tìm chiến dịch", "chiến dịch nào", "campaign nào". - KHÔNG dùng tool này khi người dùng hỏi về: rule, thể lệ, quy tắc, điều kiện, công thức thưởng → hãy dùng tool searchRules.""") + - Param `search` (String): từ khóa tìm kiếm theo tên hoặc mã chiến dịch cụ thể. + - QUAN TRỌNG: Khi người dùng hỏi lấy danh sách chung ("danh sách chiến dịch", "xem các chiến dịch"), KHÔNG truyền param `search` (để null hoặc rỗng ""). Tuyệt đối KHÔNG truyền các từ chung như "chiến dịch", "danh sách", "campaign" vào `search`. + - KHÔNG dùng tool này khi người dùng hỏi về: rule, thể lệ, quy tắc, điều kiện, công thức thưởng → hãy dùng tool searchRules.""") public Result> searchCampaigns(String search) { CampaignCriteria criteria = new CampaignCriteria(); - criteria.setSearch(search); + criteria.setSearch(SearchUtils.sanitizeSearch(search)); return campaignService.getAll(criteria); } - @McpTool(description = "Đếm số lượng CHIẾN DỊCH (Campaign), không phải rule. Cho phép tìm kiếm với param search.") + @McpTool(description = "Đếm số lượng CHIẾN DỊCH (Campaign), không phải rule. Để search = null nếu đếm tất cả.") public Result countCampaigns(String search) { CampaignCriteria criteria = new CampaignCriteria(); - criteria.setSearch(search); + criteria.setSearch(SearchUtils.sanitizeSearch(search)); return campaignService.count(criteria); } diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/util/SearchUtils.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/util/SearchUtils.java new file mode 100644 index 0000000..a58fd14 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/util/SearchUtils.java @@ -0,0 +1,64 @@ +package dev.sonpx.loyalty.mcp.util; + +import java.text.Normalizer; +import java.util.Set; + +/** + * Utility for sanitizing search parameters passed to MCP tools. + */ +public class SearchUtils { + + private static final Set GENERIC_KEYWORDS = Set.of( + "chien dich", "chiendich", + "danh sach", "danhsach", + "campaign", "campaigns", + "rule", "rules", + "the le", "thele", + "tat ca", "tatca", + "tat ca chien dich", + "danh sach chien dich", + "danh sach rule", + "danh sach the le", + "list", "show", "xem" + ); + + /** + * Sanitizes the input search string. + * If the search term consists only of generic query noise (e.g. "chiến dịch", "danh sách chiến dịch", "campaign"), + * returns null so the API returns all items rather than filtering by the word "chiến dịch". + * + * @param search the raw search string from LLM tool call + * @return cleaned search string or null if generic + */ + public static String sanitizeSearch(String search) { + if (search == null || search.isBlank()) { + return null; + } + + String trimmed = search.trim(); + String normalized = removeAccents(trimmed.toLowerCase()); + + if (GENERIC_KEYWORDS.contains(normalized)) { + return null; + } + + // Strip leading/trailing generic intent words + String cleaned = normalized + .replaceAll("^(danh sach|chien dich|campaign|rule|the le|tat ca|xem|list)\\s+", "") + .replaceAll("\\s+(danh sach|chien dich|campaign|rule|the le|tat ca)$", "") + .trim(); + + if (cleaned.isEmpty() || GENERIC_KEYWORDS.contains(cleaned)) { + return null; + } + + return trimmed; + } + + private static String removeAccents(String src) { + return Normalizer.normalize(src, Normalizer.Form.NFD) + .replaceAll("\\p{M}", "") + .replace("đ", "d") + .replace("Đ", "d"); + } +} diff --git a/loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/util/SearchUtilsTest.java b/loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/util/SearchUtilsTest.java new file mode 100644 index 0000000..8b68714 --- /dev/null +++ b/loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/util/SearchUtilsTest.java @@ -0,0 +1,29 @@ +package dev.sonpx.loyalty.mcp.util; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class SearchUtilsTest { + + @Test + void testGenericKeywordsReturnNull() { + assertNull(SearchUtils.sanitizeSearch("chiến dịch")); + assertNull(SearchUtils.sanitizeSearch("danh sách chiến dịch")); + assertNull(SearchUtils.sanitizeSearch("campaign")); + assertNull(SearchUtils.sanitizeSearch("rule")); + assertNull(SearchUtils.sanitizeSearch("thể lệ")); + assertNull(SearchUtils.sanitizeSearch("danh sách rule")); + assertNull(SearchUtils.sanitizeSearch("tất cả chiến dịch")); + assertNull(SearchUtils.sanitizeSearch("")); + assertNull(SearchUtils.sanitizeSearch(" ")); + assertNull(SearchUtils.sanitizeSearch(null)); + } + + @Test + void testSpecificKeywordsPreserved() { + assertEquals("Sinh Nhật", SearchUtils.sanitizeSearch("Sinh Nhật")); + assertEquals("HAPPY BIRTHDAY", SearchUtils.sanitizeSearch("HAPPY BIRTHDAY")); + assertEquals("Chiến dịch Sinh Nhật", SearchUtils.sanitizeSearch("Chiến dịch Sinh Nhật")); + assertEquals("MUA HÀNG TÍCH ĐIỂM", SearchUtils.sanitizeSearch("MUA HÀNG TÍCH ĐIỂM")); + } +} diff --git a/sub_agent_deep_dive.md b/sub_agent_deep_dive.md new file mode 100644 index 0000000..1cda439 --- /dev/null +++ b/sub_agent_deep_dive.md @@ -0,0 +1,544 @@ +# Sub-Agent Architecture — Deep Dive (5 câu hỏi) + +Dựa trên feedback và khảo sát codebase MCP server + loyalty-agent. + +--- + +## 1. MCP Tool Design cho Loyalty Multi-Module + +### Hiện trạng + +``` +loyalty-mcp-server/ + └── tool/ + ├── CampaignTools.java (7 tools: search, count, getById, getByIds, generateId, checkId, create) + └── CampaignRuleTools.java (7 tools: search, count, getById, getByIds, generateId, checkId, create) +``` + +Tổng: **14 tools**, tất cả expose qua 1 MCP SSE endpoint duy nhất (`/sse`). Agent nhận hết 14 tool definitions → context window bị ăn ~1000+ tokens chỉ cho tool schema. + +### Loyalty có nhiều module hơn + +Nhìn vào `client/api/` đã có sẵn `MemberTierApi.java`, `PointPoolApi.java`. Tương lai sẽ có: + +``` +┌─────────────────────────────────────────────────────────┐ +│ LOYALTY CORE MODULES │ +├──────────────┬──────────────┬──────────────┬────────────┤ +│ Reward │ Member │ Transaction │ Partner │ +│ │ │ │ │ +│ • Campaign │ • Profile │ • TxnCode │ • Merchant │ +│ • Rule │ • Tier │ • TxnHistory │ • Store │ +│ • Formula │ • Card │ • Counter │ • Offer │ +│ • Schedule │ • Segment │ • Statement │ │ +│ • Counter │ • Point Pool │ • Batch │ │ +│ • Alert │ • Enrollment │ │ │ +└──────────────┴──────────────┴──────────────┴────────────┘ +``` + +Nếu mỗi module có 5-7 tools → **tổng 30-50 tools**. 1 agent không thể xử lý tốt. + +### Đề xuất: Tool Groups + Agent Scoping + +**Phương án A — Grouped Tools trong 1 MCP Server (Đơn giản nhất)** + +Giữ 1 MCP server, nhưng tools tổ chức theo package/group. Agent-side filter theo tên prefix: + +``` +loyalty-mcp-server/ + └── tool/ + ├── reward/ + │ ├── CampaignTools.java → tool names: reward_searchCampaigns, reward_getCampaignById... + │ └── CampaignRuleTools.java → tool names: reward_searchRules, reward_getRuleById... + ├── member/ + │ ├── MemberTools.java → tool names: member_searchMembers, member_getMemberById... + │ └── TierTools.java → tool names: member_searchTiers... + └── transaction/ + └── TransactionTools.java → tool names: txn_searchTransactions... +``` + +Agent-side filtering: + +```java +// Trong QueryAgent — chỉ load read tools của reward module +List tools = toolInterceptor.getWrappedTools(sessionId, messageId) + .stream() + .filter(t -> { + String name = t.getToolDefinition().name(); + return name.startsWith("reward_") && !name.contains("create") && !name.contains("generate"); + }) + .toList(); +``` + +> [!TIP] +> **Ưu điểm**: Không cần đổi MCP server architecture. Chỉ rename tools + filter ở agent. +> **Nhược điểm**: Tất cả tool definitions vẫn load vào memory agent, chỉ filter khi truyền cho ChatClient. + +--- + +**Phương án B — Multi-MCP Endpoint (Mạnh hơn, phức tạp hơn)** + +Mỗi domain module là 1 MCP endpoint riêng. Agent chỉ connect tới endpoint cần thiết: + +```yaml +# application.yml — loyalty-agent +spring: + ai: + mcp: + client: + sse: + connections: + reward: + url: http://localhost:9331/sse # Reward tools only + member: + url: http://localhost:9331/member/sse # Member tools + transaction: + url: http://localhost:9331/txn/sse # Transaction tools +``` + +Nhưng Spring AI MCP client hiện tại auto-discover **tất cả** connections. Để chọn lọc, cần custom `ToolCallbackProvider` filtering. + +> [!NOTE] +> **Khuyến nghị cho bước đầu**: Dùng **Phương án A** (1 MCP server, tool name prefix, agent-side filter). Đơn giản, hiệu quả, không cần thay đổi infrastructure. + +--- + +**Phương án C — MCP Server per Module (Micro-MCP)** + +Tách thành nhiều MCP server process: + +``` +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ reward-mcp │ │ member-mcp │ │ txn-mcp │ +│ :9331/sse │ │ :9332/sse │ │ :9333/sse │ +│ Campaign* │ │ Member* │ │ Transaction* │ +│ Rule* │ │ Tier* │ │ Counter* │ +└───────────────┘ └───────────────┘ └───────────────┘ +``` + +> [!WARNING] +> Overkill cho giai đoạn hiện tại. Chỉ hợp lý khi mỗi module có team riêng hoặc deploy riêng. + +### Tóm tắt: Phương án nào? + +| Phương án | Độ phức tạp | Khi nào dùng | +|---|---|---| +| **A. Prefix + Filter** | ⭐ Thấp | **Nên bắt đầu từ đây** — 5-20 tools | +| **B. Multi-MCP Endpoint** | ⭐⭐ Trung bình | 20-50 tools, cần isolation | +| **C. Micro-MCP Server** | ⭐⭐⭐ Cao | 50+ tools, multi-team | + +--- + +## 2. Shared Memory & Context Injection cho Creator Agent + +### Vấn đề + +User flow thực tế khi tạo campaign + rule: + +``` +User: "danh sách chiến dịch" → QueryAgent, searchCampaigns +User: "xem thể lệ Happy Birthday" → QueryAgent, searchRules +User: "tạo thêm 1 rule mới tương tự" → CreatorAgent — CẦN BIẾT context trước đó! + Cần biết: campaignId, rule template, + formula type, existing rules... +``` + +CreatorAgent cần: +1. **Chat history** — biết user đang nói về campaign nào +2. **Domain context** — query các thông tin liên quan (pools, tiers, counters...) +3. **Validation data** — kiểm tra ID hợp lệ, trùng lặp... + +### Kiến trúc Memory + +``` +┌──────────────────────────────────────────────────────────────┐ +│ SHARED MEMORY LAYER │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ InMemoryChatMemory (existing) │ │ +│ │ Key: conversationId (sessionId) │ │ +│ │ Shared across ALL agents │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ SessionContext (NEW) │ │ +│ │ Key: sessionId │ │ +│ │ Stores: activeCampaignId, lastQueryResult, │ │ +│ │ workflowState, userPreferences │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ + │ │ │ + ┌────┴────┐ ┌────┴────┐ ┌────┴────┐ + │ Chat │ │ Query │ │ Creator │ + │ Agent │ │ Agent │ │ Agent │ + │ │ │ writes │ │ reads │ + │ │ │ context│ │ context │ + └─────────┘ └─────────┘ └─────────┘ +``` + +**Chat history (InMemoryChatMemory)** đã shared — tất cả agent dùng chung `conversationId` làm key. Khi user chuyển từ QueryAgent sang CreatorAgent, history vẫn giữ nguyên. + +**SessionContext** mới — lưu structured data: + +```java +public class SessionContext { + private String activeCampaignId; // Campaign đang được nói tới + private String activeCampaignName; // Tên để inject vào prompt + private String lastToolResult; // Kết quả tool gần nhất (compressed) + private Map metadata; // Flexible key-value +} +``` + +### Context Enrichment cho Creator Agent + +Khi Creator Agent bắt đầu tạo rule, nó cần query nhiều thông tin. Có 2 cách: + +**Cách 1: LLM tự gọi tools (hiện tại)** + +Để LLM quyết định gọi tools nào để lấy thông tin. Vấn đề: model nhỏ hay quên hoặc gọi sai. + +**Cách 2: Code-driven Context Injection (khuyên dùng)** + +```java +public class CreatorAgent implements Agent { + + @Override + public void execute(ChatRequest request, String sessionId) { + SessionContext ctx = sessionContextStore.get(sessionId); + + // Auto-enrich: trước khi gọi LLM, query sẵn thông tin cần thiết + StringBuilder enrichedContext = new StringBuilder(); + + if (ctx.getActiveCampaignId() != null) { + // Auto-fetch campaign details + Campaign campaign = campaignService.findById(ctx.getActiveCampaignId()); + enrichedContext.append("Campaign hiện tại: ") + .append(campaign.getName()) + .append(" (ID: ").append(campaign.getId()).append(")\n"); + + // Auto-fetch existing rules + List existingRules = ruleService.findByCampaignId(campaign.getId()); + enrichedContext.append("Rules hiện có: ").append(existingRules.size()).append("\n"); + + // Auto-fetch available pools + List pools = poolService.getAll(); + enrichedContext.append("Point pools: ") + .append(pools.stream().map(PoolDefinition::getName).toList()).append("\n"); + } + + chatClient.prompt() + .system(CREATOR_SYSTEM_PROMPT) + .user(enrichedContext + "\n\n" + request.prompt()) + ... + } +} +``` + +> [!IMPORTANT] +> **Code-driven enrichment** tốt hơn "LLM tự gọi tool" vì: +> 1. Deterministic — không phụ thuộc vào khả năng reasoning của model nhỏ +> 2. Nhanh hơn — gọi API trực tiếp, không qua LLM → tool → LLM loop +> 3. Đầy đủ hơn — developer biết rõ cần context gì + +--- + +## 3. Multi-Ollama Endpoints (Thinking vs Non-Thinking) + +### Setup + +``` +┌──────────────────┐ ┌──────────────────┐ +│ Máy A (hiện tại)│ │ Máy B (mới) │ +│ 192.168.99.10 │ │ 192.168.99.XX │ +│ │ │ │ +│ qwen3.5:4b │ │ qwen2.5:3b │ +│ (thinking ON) │ │ (non-thinking) │ +│ GPU: ... │ │ GPU: ... │ +│ │ │ │ +│ Dùng cho: │ │ Dùng cho: │ +│ - Query Agent │ │ - Chat Agent │ +│ - Creator Agent │ │ - Formatter │ +└──────────────────┘ └──────────────────┘ +``` + +### Spring AI Config — Model Registry + +```yaml +# application.yml +spring: + ai: + ollama: + # Default (thinking model) + base-url: http://192.168.99.10:11434 + chat: + options: + model: qwen3.5:4b + temperature: 0.3 + num_ctx: 32768 + +# Custom — non-thinking endpoint +agent: + models: + chat: + base-url: http://192.168.99.XX:11434 + model: qwen2.5:3b + temperature: 0.7 + query: + base-url: http://192.168.99.10:11434 + model: qwen3.5:4b + temperature: 0.3 + creator: + base-url: http://192.168.99.10:11434 + model: qwen3.5:4b + temperature: 0.2 +``` + +### Implementation + +```java +@Configuration +public class MultiModelConfig { + + @Bean + public OllamaApi thinkingOllamaApi() { + return OllamaApi.builder() + .baseUrl("http://192.168.99.10:11434") + .build(); + } + + @Bean + public OllamaApi fastOllamaApi() { + return OllamaApi.builder() + .baseUrl("http://192.168.99.XX:11434") + .build(); + } + + @Bean("chatAgent.chatClient") + public ChatClient chatAgentClient( + @Qualifier("fastOllamaApi") OllamaApi api, + ToolCallingManager toolCallingManager) { + var model = OllamaChatModel.builder() + .ollamaApi(api) + .defaultOptions(OllamaChatOptions.builder() + .model("qwen2.5:3b") + .temperature(0.7) + .build()) + .toolCallingManager(toolCallingManager) + .build(); + return ChatClient.builder(model).build(); + } + + @Bean("queryAgent.chatClient") + public ChatClient queryAgentClient( + @Qualifier("thinkingOllamaApi") OllamaApi api, + ToolCallingManager toolCallingManager) { + var model = OllamaChatModel.builder() + .ollamaApi(api) + .defaultOptions(OllamaChatOptions.builder() + .model("qwen3.5:4b") + .temperature(0.3) + .build()) + .toolCallingManager(toolCallingManager) + .build(); + return ChatClient.builder(model).build(); + } +} +``` + +> [!TIP] +> **Thay model dễ dàng**: Khi có model tốt hơn (ví dụ `qwen3:8b-no-think`), chỉ cần đổi trong config YAML, không sửa code. + +--- + +## 4. Latency Optimization — Tránh Chain Agents + +### Vấn đề + +Chain 2 agents nối tiếp → latency gấp đôi: + +``` +BAD: Query Agent (10s) → Formatter Agent (8s) = 18s tổng +``` + +### Giải pháp: KHÔNG chain — mỗi agent tự hoàn chỉnh + +``` +GOOD: Query Agent (10s) — tự format trong cùng 1 prompt = 10s tổng +``` + +**Cách thực hiện**: Thay vì Formatter Agent riêng, nhúng formatting instruction vào `agent_instruction` (đã có sẵn!): + +```java +// Trong CampaignService.java (MCP server) — ĐÃ CÓ SẴN +return Result.of(campaigns, """ + Trả lời tự nhiên bằng tiếng Việt. Không dùng markdown table. + Hiển thị mỗi chiến dịch theo dạng danh sách đánh số: + **[Tên]** (`[campaignId]`) — Owner: [ownerName]... + """); +``` + +`agent_instruction` pattern hiện tại **đã là formatter** — nằm trong tool result, không tốn thêm LLM call. Rất thông minh. + +### Nơi nào thực sự cần parallel/async + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Creator Agent — Tạo Rule Flow │ +│ │ +│ 1. User: "tạo rule mới" │ +│ │ +│ 2. Context Enrichment (PARALLEL, code-driven, ~500ms): │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ getCampaign() │ │ getRules() │ │ getPools() │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ └─────────────────┼─────────────────┘ │ +│ ▼ │ +│ 3. LLM Call (SINGLE, ~8-12s): │ +│ System: Creator prompt + enriched context │ +│ User: "tạo rule mới" │ +│ → LLM hỏi thêm hoặc confirm │ +│ │ +│ Total: ~9-13s (vs 17s+ hiện tại vì LLM phải tự gọi tool) │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Parallel context fetching** (Java `CompletableFuture`): + +```java +// Context enrichment — chạy song song, mất ~500ms thay vì 3 LLM calls +CompletableFuture campaignFuture = CompletableFuture.supplyAsync( + () -> campaignService.findById(campaignId)); +CompletableFuture> rulesFuture = CompletableFuture.supplyAsync( + () -> ruleService.findByCampaignId(campaignId)); +CompletableFuture> poolsFuture = CompletableFuture.supplyAsync( + () -> poolService.getAll()); + +// Wait all (parallel) — ~500ms +CompletableFuture.allOf(campaignFuture, rulesFuture, poolsFuture).join(); + +// Inject into prompt +String context = buildContext(campaignFuture.get(), rulesFuture.get(), poolsFuture.get()); +``` + +### Tóm tắt chiến lược latency + +| Kỹ thuật | Tiết kiệm | Áp dụng | +|---|---|---| +| Tắt thinking cho Chat Agent | 17s → 2-3s | Chat Agent | +| Formatter = `agent_instruction` | Bỏ 1 LLM call (~8s) | Query Agent | +| Parallel context fetch | 3 API calls: 1.5s → 0.5s | Creator Agent | +| Code-driven enrichment | Bỏ 2-3 tool-call loops (~20s) | Creator Agent | + +--- + +## 5. On-Premise Fallback Strategy (Không dùng Cloud) + +### Constraint: Bảo mật dữ liệu → chỉ chạy on-premise + +Bỏ hoàn toàn phương án cloud. Thay vào đó: + +### Strategy: Multi-Tier On-Premise Models + +``` +┌─────────────────────────────────────────────────────────────┐ +│ ON-PREMISE MODEL TIERS │ +│ │ +│ Tier 1 (Fast, Light) Tier 2 (Strong, Slow) │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ Máy B │ │ Máy A (GPU mạnh) │ │ +│ │ qwen2.5:3b │ │ qwen3.5:4b │ │ +│ │ non-thinking │ │ thinking │ │ +│ │ ~2-3s/response │ │ ~8-15s/response │ │ +│ │ │ │ │ │ +│ │ Dùng cho: │ │ Dùng cho: │ │ +│ │ - Chat Agent │ │ - Query Agent │ │ +│ │ - Simple Q&A │ │ - Creator Agent │ │ +│ │ - Validation │ │ - Complex reasoning│ │ +│ └──────────────────┘ └──────────────────┘ │ +│ │ +│ Tương lai: Tier 3 (nếu có GPU mạnh hơn) │ +│ ┌──────────────────┐ │ +│ │ Máy C (A100/H100) │ │ +│ │ qwen3.5:14b │ │ +│ │ cho critical tasks │ │ +│ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Khi nào escalate từ Tier 1 → Tier 2? + +Không cần "confidence-based routing" phức tạp. Dùng **Intent-based routing** đơn giản: + +```java +// Router quyết định dựa trên intent, không dựa trên confidence +switch (intent) { + case CONVERSATION → chatAgent; // Tier 1 (fast model) + case CAMPAIGN_QUERY, + RULE_QUERY, + UNKNOWN → queryAgent; // Tier 2 (thinking model) + case CAMPAIGN_CREATE, + RULE_CREATE → creatorAgent; // Tier 2 (thinking model) +} +``` + +### Retry Strategy (nếu model trả kết quả kém) + +Thay vì fallback lên cloud, **retry cùng model với prompt khác**: + +```java +// Nếu tool result rỗng hoặc LLM trả "tôi không biết" +if (isLowQualityResponse(response)) { + // Retry 1: thêm few-shot example vào prompt + // Retry 2: simplify câu hỏi + // Retry 3: báo user "Không tìm thấy, vui lòng thử cách khác" +} +``` + +> [!IMPORTANT] +> **Không retry bằng model khác** — retry bằng **prompt khác** trên cùng model hiệu quả hơn cho small LLMs. Thêm 1 ví dụ cụ thể vào prompt thường giải quyết 80% trường hợp model trả sai. + +--- + +## Tổng kết — Bước tiếp theo + +``` +┌──────────────────────────────────────────────────────────────┐ +│ KIẾN TRÚC ĐỀ XUẤT CUỐI │ +│ │ +│ Máy B (fast) Máy A (strong) │ +│ ┌────────────┐ ┌────────────┐ │ +│ │ qwen2.5:3b │ │ qwen3.5:4b │ │ +│ └─────┬──────┘ └─────┬──────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────┐ ┌──────────────────┐ │ +│ │ChatAgent │ │ QueryAgent │ │ +│ │(no tools)│ │ (read tools, │ │ +│ │ │ │ prefix filter) │ │ +│ └──────────┘ └──────────────────┘ │ +│ ┌──────────────────┐ │ +│ │ CreatorAgent │ │ +│ │ (write tools, │ │ +│ │ code-driven │ │ +│ │ enrichment) │ │ +│ └──────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ Shared Memory Layer │ │ +│ │ InMemoryChatMemory + SessionContext │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ MCP Server (1 process) │ │ +│ │ Tools: reward_*, member_*, txn_* │ │ +│ │ Agent-side filtering by prefix │ │ +│ └─────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ +``` + +Nếu anh thấy hướng này ổn, mình có thể tạo change proposal (`/opsx-propose`) để bắt đầu implement từng phase: + +1. **Phase 1**: Agent interface + ChatAgent (tắt thinking, model riêng) — cải thiện "hello" ngay +2. **Phase 2**: Tool prefix naming + QueryAgent (filtered tools) — giảm tool confusion +3. **Phase 3**: CreatorAgent + SessionContext + code-driven enrichment — nâng chất lượng tạo campaign/rule +4. **Phase 4**: Multi-Ollama config + ModelRegistry — hoàn thiện infra