feat(agent): orchestrate read-only work tools
This commit is contained in:
+25
@@ -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));
|
||||
}
|
||||
}
|
||||
+51
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
+88
@@ -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("-", "");
|
||||
}
|
||||
}
|
||||
+41
@@ -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")));
|
||||
}
|
||||
}
|
||||
+115
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user