feat(agent): orchestrate read-only work tools

This commit is contained in:
2026-07-24 20:17:21 +08:00
parent b4cc83168d
commit ffe830f64a
5 changed files with 320 additions and 0 deletions
@@ -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<AgentResponse> messages(@RequestBody AgentRequest request) {
return R.ok(orchestrator.handle(request));
}
}
@@ -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<String> fields) {
}
public record AgentResponse(
String runId,
String conversationId,
Long contextVersion,
Intent intent,
AgentStatus status,
String answer,
List<SourceSummary> sourceSummary,
List<Citation> citations,
List<Resource> resources,
Object data,
ActionDraft actionDraft,
Clarification clarification,
List<String> nextActions
) {
}
}
@@ -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("-", "");
}
}
@@ -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<String> 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")));
}
}
@@ -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;
}
}
}