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;
|
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 {
|
public final class AihrAgentDto {
|
||||||
|
|
||||||
private AihrAgentDto() {
|
private AihrAgentDto() {
|
||||||
@@ -41,6 +46,16 @@ public final class AihrAgentDto {
|
|||||||
SOCIAL
|
SOCIAL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum AgentStatus {
|
||||||
|
COMPLETED,
|
||||||
|
NEEDS_INPUT,
|
||||||
|
NEEDS_CONFIRMATION,
|
||||||
|
NO_EVIDENCE,
|
||||||
|
FORBIDDEN,
|
||||||
|
UNAVAILABLE,
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
public record AgentPlan(
|
public record AgentPlan(
|
||||||
Intent intent,
|
Intent intent,
|
||||||
String rewrittenRequest,
|
String rewrittenRequest,
|
||||||
@@ -50,4 +65,40 @@ public final class AihrAgentDto {
|
|||||||
ResponseStyle responseStyle
|
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