diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentDto.java new file mode 100644 index 00000000..8a696e28 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentDto.java @@ -0,0 +1,53 @@ +package org.dromara.aihr.agent; + +public final class AihrAgentDto { + + private AihrAgentDto() { + } + + public enum Intent { + KNOWLEDGE_QA, + RESOURCE_DELIVERY, + LIVE_MY_WORK, + LIVE_TEAM_WORK, + PRACTICE_COACHING, + CAPTURE_FACT, + DRAFT_ACTION, + MEDIA_UNDERSTANDING, + WEB_RESEARCH, + CLARIFY, + SOCIAL + } + + public enum Tool { + NONE, + KNOWLEDGE_SEARCH, + KNOWLEDGE_RESOURCE, + MY_CURRENT_TASKS, + MY_PRACTICE_SUMMARY, + TEAM_PRACTICE_SUMMARY, + CAPTURE_MEMORY_DRAFT, + MEDIA_ANALYZE, + WEB_RESEARCH + } + + public enum ResponseStyle { + FACT, + GROUNDED, + DRAFT, + MEDIA, + WEB, + CLARIFICATION, + SOCIAL + } + + public record AgentPlan( + Intent intent, + String rewrittenRequest, + Tool tool, + boolean requiresConfirmation, + boolean requiresExternalConsent, + ResponseStyle responseStyle + ) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentPlanner.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentPlanner.java new file mode 100644 index 00000000..8adde2f1 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentPlanner.java @@ -0,0 +1,163 @@ +package org.dromara.aihr.agent; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.dromara.aihr.agent.AihrAgentDto.AgentPlan; +import org.dromara.aihr.agent.AihrAgentDto.Intent; +import org.dromara.aihr.agent.AihrAgentDto.ResponseStyle; +import org.dromara.aihr.agent.AihrAgentDto.Tool; +import org.dromara.aihr.service.AihrModelSeedService; +import org.springframework.stereotype.Service; + +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +@Service +public class AihrAgentPlanner { + + private static final Set PLAN_FIELDS = Set.of( + "intent", "rewrittenRequest", "tool", "requiresConfirmation", "requiresExternalConsent", "responseStyle" + ); + private static final String PLANNER_PROMPT = """ + 你是企业员工助手的意图规划器。只输出一个 JSON 对象,字段必须且只能是: + intent, rewrittenRequest, tool, requiresConfirmation, requiresExternalConsent, responseStyle。 + intent 可选 KNOWLEDGE_QA, RESOURCE_DELIVERY, LIVE_MY_WORK, LIVE_TEAM_WORK, PRACTICE_COACHING, + CAPTURE_FACT, DRAFT_ACTION, MEDIA_UNDERSTANDING, WEB_RESEARCH, CLARIFY, SOCIAL。 + tool 可选 NONE, KNOWLEDGE_SEARCH, KNOWLEDGE_RESOURCE, MY_CURRENT_TASKS, MY_PRACTICE_SUMMARY, + TEAM_PRACTICE_SUMMARY, CAPTURE_MEMORY_DRAFT, MEDIA_ANALYZE, WEB_RESEARCH。 + 不得输出用户、员工、项目、SQL、URL 等标识或参数;不确定时返回 CLARIFY 与 NONE。 + """; + + private final AihrModelSeedService modelService; + private final ObjectMapper objectMapper; + + public AihrAgentPlanner(AihrModelSeedService modelService, ObjectMapper objectMapper) { + this.modelService = modelService; + this.objectMapper = objectMapper; + } + + public AgentPlan plan(String rawRequest, boolean hasMedia) { + String request = clean(rawRequest); + if (hasMedia) { + return plan(Intent.MEDIA_UNDERSTANDING, request, Tool.MEDIA_ANALYZE, false, false, ResponseStyle.MEDIA); + } + if (containsAny(request, "记一下", "帮我记", "记录一下", "保存一下")) { + return plan(Intent.CAPTURE_FACT, request, Tool.CAPTURE_MEMORY_DRAFT, true, false, ResponseStyle.DRAFT); + } + if (containsAny(request, "查全网", "上网查", "联网查", "网上查")) { + return plan(Intent.WEB_RESEARCH, request, Tool.WEB_RESEARCH, false, true, ResponseStyle.WEB); + } + if (containsAny(request, "原文件", "源文件", "原文", "附件", "文件发我", "发我文件", "下载文件")) { + return plan(Intent.RESOURCE_DELIVERY, request, Tool.KNOWLEDGE_RESOURCE, false, false, + ResponseStyle.GROUNDED); + } + if (containsAny(request, "我今天有什么待办", "我有什么待办", "我的待办", "我的任务", + "我今天该做什么", "我现在该做什么", "今天要做什么", "当前待办")) { + return plan(Intent.LIVE_MY_WORK, request, Tool.MY_CURRENT_TASKS, false, false, ResponseStyle.FACT); + } + if (isSocial(request)) { + return plan(Intent.SOCIAL, request, Tool.NONE, false, false, ResponseStyle.SOCIAL); + } + if (request.isBlank() || containsAny(request, "帮我处理一下", "帮我弄一下", "你看着办")) { + return plan(Intent.CLARIFY, request, Tool.NONE, false, false, ResponseStyle.CLARIFICATION); + } + if (containsAny(request.toLowerCase(Locale.ROOT), "sop", "怎么", "如何", "流程", "规定", "制度", + "标准", "操作", "处理", "进场", "办理", "应该")) { + return plan(Intent.KNOWLEDGE_QA, request, Tool.KNOWLEDGE_SEARCH, false, false, + ResponseStyle.GROUNDED); + } + return modelPlan(request).orElseGet(() -> + plan(Intent.CLARIFY, request, Tool.NONE, false, false, ResponseStyle.CLARIFICATION)); + } + + private Optional modelPlan(String request) { + if (modelService == null) { + return Optional.empty(); + } + try { + Optional result = modelService.tryChat(PLANNER_PROMPT, request, 0.0); + if (result.isEmpty()) { + return Optional.empty(); + } + JsonNode node = objectMapper.readTree(result.get()); + if (!node.isObject() || !node.has("intent") || !node.has("rewrittenRequest") || !node.has("tool") + || !node.has("requiresConfirmation") || !node.has("requiresExternalConsent") + || !node.has("responseStyle")) { + return Optional.empty(); + } + var fields = node.fieldNames(); + while (fields.hasNext()) { + if (!PLAN_FIELDS.contains(fields.next())) { + return Optional.empty(); + } + } + String rewritten = clean(node.path("rewrittenRequest").asText()); + if (rewritten.isBlank() || rewritten.length() > 600 + || !node.path("requiresConfirmation").isBoolean() + || !node.path("requiresExternalConsent").isBoolean()) { + return Optional.empty(); + } + AgentPlan candidate = plan( + Intent.valueOf(node.path("intent").asText()), + rewritten, + Tool.valueOf(node.path("tool").asText()), + node.path("requiresConfirmation").asBoolean(), + node.path("requiresExternalConsent").asBoolean(), + ResponseStyle.valueOf(node.path("responseStyle").asText()) + ); + return compatible(candidate) ? Optional.of(candidate) : Optional.empty(); + } catch (Exception ignored) { + return Optional.empty(); + } + } + + private static boolean compatible(AgentPlan candidate) { + return switch (candidate.intent()) { + case KNOWLEDGE_QA -> candidate.tool() == Tool.KNOWLEDGE_SEARCH + && candidate.responseStyle() == ResponseStyle.GROUNDED; + case RESOURCE_DELIVERY -> candidate.tool() == Tool.KNOWLEDGE_RESOURCE + && candidate.responseStyle() == ResponseStyle.GROUNDED; + case LIVE_MY_WORK -> candidate.tool() == Tool.MY_CURRENT_TASKS + && candidate.responseStyle() == ResponseStyle.FACT; + case LIVE_TEAM_WORK -> candidate.tool() == Tool.TEAM_PRACTICE_SUMMARY + && candidate.responseStyle() == ResponseStyle.FACT; + case PRACTICE_COACHING -> candidate.tool() == Tool.MY_PRACTICE_SUMMARY + && candidate.responseStyle() == ResponseStyle.FACT; + case CAPTURE_FACT -> candidate.tool() == Tool.CAPTURE_MEMORY_DRAFT + && candidate.requiresConfirmation() && candidate.responseStyle() == ResponseStyle.DRAFT; + case DRAFT_ACTION -> candidate.tool() == Tool.NONE + && candidate.requiresConfirmation() && candidate.responseStyle() == ResponseStyle.DRAFT; + case MEDIA_UNDERSTANDING -> candidate.tool() == Tool.MEDIA_ANALYZE + && candidate.responseStyle() == ResponseStyle.MEDIA; + case WEB_RESEARCH -> candidate.tool() == Tool.WEB_RESEARCH + && candidate.requiresExternalConsent() && candidate.responseStyle() == ResponseStyle.WEB; + case CLARIFY -> candidate.tool() == Tool.NONE + && candidate.responseStyle() == ResponseStyle.CLARIFICATION; + case SOCIAL -> candidate.tool() == Tool.NONE && candidate.responseStyle() == ResponseStyle.SOCIAL; + }; + } + + private static AgentPlan plan(Intent intent, String request, Tool tool, boolean confirmation, + boolean externalConsent, ResponseStyle style) { + return new AgentPlan(intent, request, tool, confirmation, externalConsent, style); + } + + private static boolean isSocial(String request) { + String value = clean(request).toLowerCase(Locale.ROOT); + return value.matches("^(你好|您好|嗨|hi|hello|在吗|谢谢|感谢)[!!。.??]*$"); + } + + private static boolean containsAny(String value, String... needles) { + for (String needle : needles) { + if (value.contains(needle)) { + return true; + } + } + return false; + } + + private static String clean(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentPlannerTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentPlannerTest.java new file mode 100644 index 00000000..6c898511 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentPlannerTest.java @@ -0,0 +1,149 @@ +package org.dromara.aihr.agent; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.dromara.aihr.service.AihrModelSeedService; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.dromara.aihr.agent.AihrAgentDto.Intent.CAPTURE_FACT; +import static org.dromara.aihr.agent.AihrAgentDto.Intent.CLARIFY; +import static org.dromara.aihr.agent.AihrAgentDto.Intent.KNOWLEDGE_QA; +import static org.dromara.aihr.agent.AihrAgentDto.Intent.LIVE_MY_WORK; +import static org.dromara.aihr.agent.AihrAgentDto.Intent.MEDIA_UNDERSTANDING; +import static org.dromara.aihr.agent.AihrAgentDto.Intent.RESOURCE_DELIVERY; +import static org.dromara.aihr.agent.AihrAgentDto.Intent.SOCIAL; +import static org.dromara.aihr.agent.AihrAgentDto.Tool.CAPTURE_MEMORY_DRAFT; +import static org.dromara.aihr.agent.AihrAgentDto.Tool.KNOWLEDGE_RESOURCE; +import static org.dromara.aihr.agent.AihrAgentDto.Tool.KNOWLEDGE_SEARCH; +import static org.dromara.aihr.agent.AihrAgentDto.Tool.MEDIA_ANALYZE; +import static org.dromara.aihr.agent.AihrAgentDto.Tool.MY_CURRENT_TASKS; +import static org.dromara.aihr.agent.AihrAgentDto.Tool.NONE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("dev") +class AihrAgentPlannerTest { + + private final AihrAgentPlanner planner = new AihrAgentPlanner(null, new ObjectMapper()); + + @Test + void routesCurrentTasksToLiveDataInsteadOfKnowledgeSearch() { + var plan = planner.plan("我今天有什么待办", false); + + assertEquals(LIVE_MY_WORK, plan.intent()); + assertEquals(MY_CURRENT_TASKS, plan.tool()); + assertFalse(plan.requiresConfirmation()); + } + + @Test + void routesExplicitCaptureToConfirmationDraft() { + var plan = planner.plan("记一下,3栋1201要回访", false); + + assertEquals(CAPTURE_FACT, plan.intent()); + assertEquals(CAPTURE_MEMORY_DRAFT, plan.tool()); + assertTrue(plan.requiresConfirmation()); + } + + @Test + void routesGreetingWithoutCallingKnowledge() { + var plan = planner.plan("你好", false); + + assertEquals(SOCIAL, plan.intent()); + assertEquals(NONE, plan.tool()); + } + + @Test + void ambiguousActionAsksForClarification() { + var plan = planner.plan("帮我处理一下", false); + + assertEquals(CLARIFY, plan.intent()); + assertEquals(NONE, plan.tool()); + } + + @Test + void genericImageQuestionUsesMediaWithoutSopSearch() { + var plan = planner.plan("图中是什么宠物", true); + + assertEquals(MEDIA_UNDERSTANDING, plan.intent()); + assertEquals(MEDIA_ANALYZE, plan.tool()); + } + + @Test + void explicitSopQuestionUsesEnterpriseKnowledge() { + var plan = planner.plan("装修人员怎么进场?", false); + + assertEquals(KNOWLEDGE_QA, plan.intent()); + assertEquals(KNOWLEDGE_SEARCH, plan.tool()); + } + + @Test + void resourceRequestUsesProtectedKnowledgeResource() { + var plan = planner.plan("把刚才的原文件发我", false); + + assertEquals(RESOURCE_DELIVERY, plan.intent()); + assertEquals(KNOWLEDGE_RESOURCE, plan.tool()); + } + + @Test + void explicitWebRequestRequiresConsent() { + var plan = planner.plan("帮我查全网,国家对高空抛物怎么规定", false); + + assertEquals(AihrAgentDto.Intent.WEB_RESEARCH, plan.intent()); + assertEquals(AihrAgentDto.Tool.WEB_RESEARCH, plan.tool()); + assertTrue(plan.requiresExternalConsent()); + } + + @Test + void validatedModelPlanCanRouteAnUnmatchedWorkRequest() { + AihrModelSeedService model = modelReturning(""" + { + "intent": "LIVE_MY_WORK", + "rewrittenRequest": "查询当前认证员工今天的安排", + "tool": "MY_CURRENT_TASKS", + "requiresConfirmation": false, + "requiresExternalConsent": false, + "responseStyle": "FACT" + } + """); + var modelPlanner = new AihrAgentPlanner(model, new ObjectMapper()); + + var plan = modelPlanner.plan("看看我今天的安排", false); + + assertEquals(LIVE_MY_WORK, plan.intent()); + assertEquals(MY_CURRENT_TASKS, plan.tool()); + assertEquals("查询当前认证员工今天的安排", plan.rewrittenRequest()); + } + + @Test + void modelPlanWithIdentityOrUnknownToolFailsClosed() { + AihrModelSeedService model = modelReturning(""" + { + "intent": "LIVE_MY_WORK", + "rewrittenRequest": "查询其他员工任务", + "tool": "RAW_SQL", + "employeeId": "someone-else", + "requiresConfirmation": false, + "requiresExternalConsent": false, + "responseStyle": "FACT" + } + """); + var modelPlanner = new AihrAgentPlanner(model, new ObjectMapper()); + + var plan = modelPlanner.plan("看看那个人今天的安排", false); + + assertEquals(CLARIFY, plan.intent()); + assertEquals(NONE, plan.tool()); + } + + private static AihrModelSeedService modelReturning(String response) { + return new AihrModelSeedService(new ObjectMapper(), null) { + @Override + public Optional tryChat(String systemPrompt, String userPrompt, double temperature) { + return Optional.of(response); + } + }; + } +}