diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentController.java new file mode 100644 index 00000000..bb6725b5 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentController.java @@ -0,0 +1,25 @@ +package org.dromara.aihr.agent; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.agent.AihrAgentDto.AgentRequest; +import org.dromara.aihr.agent.AihrAgentDto.AgentResponse; +import org.dromara.common.core.domain.R; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/aihr/agent") +public class AihrAgentController { + + private final AihrAgentOrchestrator orchestrator; + + @SaCheckLogin + @PostMapping("/messages") + public R messages(@RequestBody AgentRequest request) { + return R.ok(orchestrator.handle(request)); + } +} 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 index 8a696e28..fcf267bb 100644 --- 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 @@ -1,5 +1,10 @@ package org.dromara.aihr.agent; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Citation; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Resource; + +import java.util.List; + public final class AihrAgentDto { private AihrAgentDto() { @@ -41,6 +46,16 @@ public final class AihrAgentDto { SOCIAL } + public enum AgentStatus { + COMPLETED, + NEEDS_INPUT, + NEEDS_CONFIRMATION, + NO_EVIDENCE, + FORBIDDEN, + UNAVAILABLE, + FAILED + } + public record AgentPlan( Intent intent, String rewrittenRequest, @@ -50,4 +65,40 @@ public final class AihrAgentDto { ResponseStyle responseStyle ) { } + + public record AgentRequest( + String question, + String conversationId, + Long contextVersion, + String projectCode, + Long broadcastMessageId, + boolean externalConsent + ) { + } + + public record SourceSummary(String type, String label, String verifiedAt) { + } + + public record ActionDraft(String draftId, String type, Long version, Object payload) { + } + + public record Clarification(String prompt, List fields) { + } + + public record AgentResponse( + String runId, + String conversationId, + Long contextVersion, + Intent intent, + AgentStatus status, + String answer, + List sourceSummary, + List citations, + List resources, + Object data, + ActionDraft actionDraft, + Clarification clarification, + List nextActions + ) { + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java new file mode 100644 index 00000000..067b0218 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java @@ -0,0 +1,88 @@ +package org.dromara.aihr.agent; + +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.agent.AihrAgentDto.AgentPlan; +import org.dromara.aihr.agent.AihrAgentDto.AgentRequest; +import org.dromara.aihr.agent.AihrAgentDto.AgentResponse; +import org.dromara.aihr.agent.AihrAgentDto.AgentStatus; +import org.dromara.aihr.agent.AihrAgentDto.Clarification; +import org.dromara.aihr.agent.AihrAgentDto.Intent; +import org.dromara.aihr.agent.AihrAgentDto.SourceSummary; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse; +import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.CurrentTaskSummary; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.stereotype.Service; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class AihrAgentOrchestrator { + + private final AihrAgentPlanner planner; + private final AihrAgentPolicy policy; + private final AihrKnowledgePrincipalResolver principalResolver; + private final AihrKnowledgeQueryService queryService; + + public AgentResponse handle(AgentRequest request) { + if (request == null || request.question() == null || request.question().isBlank()) { + throw new ServiceException("问题不能为空", 400); + } + AgentPlan plan = planner.plan(request.question(), false); + policy.authorize(principalResolver.current(), plan, request.externalConsent()); + return switch (plan.intent()) { + case SOCIAL -> simple(plan, AgentStatus.COMPLETED, "你好,我是数字师傅。你可以问制度、待办、训练或现场问题。", + null); + case CLARIFY, DRAFT_ACTION, MEDIA_UNDERSTANDING -> simple(plan, AgentStatus.NEEDS_INPUT, + "请再说明你想查询、记录或处理什么。", + new Clarification("请补充具体目标", List.of("具体问题"))); + case WEB_RESEARCH -> simple(plan, AgentStatus.NEEDS_INPUT, + "这项问题需要使用独立的全网查询能力。", new Clarification("请确认是否查询全网", List.of())); + default -> fromQuery(plan, request); + }; + } + + private AgentResponse fromQuery(AgentPlan plan, AgentRequest request) { + String toolCode = switch (plan.tool()) { + case MY_CURRENT_TASKS, MY_PRACTICE_SUMMARY, TEAM_PRACTICE_SUMMARY -> plan.tool().name(); + default -> null; + }; + QueryResponse response = queryService.queryInternal(new QueryRequest( + plan.rewrittenRequest(), List.of(), "sop", null, "mobile_uni_agent", 5, toolCode, + request.conversationId(), request.contextVersion(), request.projectCode(), request.broadcastMessageId() + )); + AgentStatus status = status(response); + String sourceType = toolCode == null ? "ENTERPRISE_KNOWLEDGE" : "LIVE_DATA"; + String sourceLabel = response.citations().isEmpty() ? sourceType + : response.citations().get(0).title(); + return new AgentResponse( + runId(), response.conversationId(), response.contextVersion(), plan.intent(), status, response.answer(), + List.of(new SourceSummary(sourceType, sourceLabel, OffsetDateTime.now().toString())), + response.citations(), response.resources(), response.data(), null, null, List.of() + ); + } + + private static AgentStatus status(QueryResponse response) { + if (response.data() instanceof CurrentTaskSummary summary && "UNAVAILABLE".equals(summary.state())) { + return AgentStatus.UNAVAILABLE; + } + return response.noEvidence() ? AgentStatus.NO_EVIDENCE : AgentStatus.COMPLETED; + } + + private static AgentResponse simple(AgentPlan plan, AgentStatus status, String answer, + Clarification clarification) { + return new AgentResponse( + runId(), null, null, plan.intent(), status, answer, List.of(), List.of(), List.of(), + null, null, clarification, List.of() + ); + } + + private static String runId() { + return "agent_run_" + UUID.randomUUID().toString().replace("-", ""); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentControllerContractTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentControllerContractTest.java new file mode 100644 index 00000000..0f6b3c2e --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentControllerContractTest.java @@ -0,0 +1,41 @@ +package org.dromara.aihr.agent; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import org.dromara.aihr.agent.AihrAgentDto.AgentRequest; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("dev") +class AihrAgentControllerContractTest { + + @Test + void textEndpointRequiresLoginAndUsesCanonicalPath() throws Exception { + var method = AihrAgentController.class.getMethod("messages", AgentRequest.class); + + assertNotNull(method.getAnnotation(SaCheckLogin.class)); + assertEquals("/api/aihr/agent", + AihrAgentController.class.getAnnotation(RequestMapping.class).value()[0]); + assertEquals("/messages", method.getAnnotation(PostMapping.class).value()[0]); + } + + @Test + void requestDoesNotAcceptIdentityRoleOrTool() { + Set fields = Arrays.stream(AgentRequest.class.getRecordComponents()) + .map(component -> component.getName().toLowerCase()) + .collect(Collectors.toSet()); + + assertTrue(fields.containsAll(Set.of("question", "conversationid", "contextversion", "externalconsent"))); + assertTrue(fields.stream().noneMatch(name -> + name.contains("userid") || name.contains("employeeid") || name.contains("role") || name.contains("tool"))); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java new file mode 100644 index 00000000..b145fbad --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java @@ -0,0 +1,115 @@ +package org.dromara.aihr.agent; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.dromara.aihr.agent.AihrAgentDto.AgentRequest; +import org.dromara.aihr.agent.AihrAgentDto.AgentStatus; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService; +import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.CurrentTaskSummary; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.dromara.aihr.agent.AihrAgentDto.Intent.KNOWLEDGE_QA; +import static org.dromara.aihr.agent.AihrAgentDto.Intent.LIVE_MY_WORK; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +@Tag("dev") +class AihrAgentOrchestratorTest { + + @Test + void currentTasksUsesRegisteredDataToolWithoutFallingIntoRag() { + var query = new CapturingQueryService(response( + "当前没有查到分配给你的待办或训练任务。", + new CurrentTaskSummary("NO_TASKS", 0, List.of()), false)); + var service = orchestrator(query); + + var result = service.handle(request("我今天有什么待办")); + + assertEquals(LIVE_MY_WORK, result.intent()); + assertEquals("MY_CURRENT_TASKS", query.lastRequest.toolCode()); + assertEquals(AgentStatus.COMPLETED, result.status()); + assertEquals("NO_TASKS", ((CurrentTaskSummary) result.data()).state()); + } + + @Test + void knowledgeQuestionUsesKnowledgeSearchWithoutClientToolInput() { + var query = new CapturingQueryService(response("按访客进场制度登记。", null, false)); + var service = orchestrator(query); + + var result = service.handle(request("装修人员怎么进场?")); + + assertEquals(KNOWLEDGE_QA, result.intent()); + assertNull(query.lastRequest.toolCode()); + assertEquals(AgentStatus.COMPLETED, result.status()); + } + + @Test + void socialAndClarificationDoNotInvokeKnowledge() { + var query = new CapturingQueryService(response("unused", null, false)); + var service = orchestrator(query); + + assertEquals(AgentStatus.COMPLETED, service.handle(request("你好")).status()); + assertEquals(AgentStatus.NEEDS_INPUT, service.handle(request("帮我处理一下")).status()); + assertEquals(0, query.callCount); + } + + @Test + void unavailableDataIsNotReportedAsNoTasks() { + var query = new CapturingQueryService(response( + "当前任务数据读取失败,暂时无法确认待办。", + new CurrentTaskSummary("UNAVAILABLE", 0, List.of()), false)); + + var result = orchestrator(query).handle(request("我今天有什么待办")); + + assertEquals(AgentStatus.UNAVAILABLE, result.status()); + } + + private static AihrAgentOrchestrator orchestrator(CapturingQueryService query) { + return new AihrAgentOrchestrator( + new AihrAgentPlanner(null, new ObjectMapper()), + new AihrAgentPolicy(), + new AihrKnowledgePrincipalResolver(null) { + @Override + public AihrKnowledgePrincipal current() { + return new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1", + Set.of("employee"), Set.of("P1"), "app"); + } + }, + query + ); + } + + private static AgentRequest request(String question) { + return new AgentRequest(question, "conversation_1", 0L, "P1", null, false); + } + + private static QueryResponse response(String answer, Object data, boolean noEvidence) { + return new QueryResponse("request-1", "question", answer, List.of(), List.of(), noEvidence, + "test", null, data, "conversation_1", 1L, "QA", "question", List.of()); + } + + private static final class CapturingQueryService extends AihrKnowledgeQueryService { + private final QueryResponse response; + private QueryRequest lastRequest; + private int callCount; + + private CapturingQueryService(QueryResponse response) { + super(null, null, null, null, null, null, null, null, null); + this.response = response; + } + + @Override + public QueryResponse queryInternal(QueryRequest request) { + callCount++; + lastRequest = request; + return response; + } + } +}