fix(agent): route media by intent
This commit is contained in:
+21
@@ -5,10 +5,14 @@ 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.http.MediaType;
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@@ -22,4 +26,21 @@ public class AihrAgentController {
|
||||
public R<AgentResponse> messages(@RequestBody AgentRequest request) {
|
||||
return R.ok(orchestrator.handle(request));
|
||||
}
|
||||
|
||||
@SaCheckLogin
|
||||
@PostMapping(value = "/messages/media", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public R<AgentResponse> media(@RequestPart("file") MultipartFile file,
|
||||
@RequestParam(value = "question", required = false) String question,
|
||||
@RequestParam(value = "conversationId", required = false) String conversationId,
|
||||
@RequestParam(value = "contextVersion", required = false) Long contextVersion,
|
||||
@RequestParam(value = "projectCode", required = false) String projectCode,
|
||||
@RequestParam(value = "broadcastMessageId", required = false) Long broadcastMessageId,
|
||||
@RequestParam(value = "externalConsent", defaultValue = "false")
|
||||
boolean externalConsent) {
|
||||
String prompt = question == null || question.isBlank() ? "请分析附件内容" : question.trim();
|
||||
return R.ok(orchestrator.handleMedia(
|
||||
new AgentRequest(prompt, conversationId, contextVersion, projectCode, broadcastMessageId, externalConsent),
|
||||
file
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+36
-4
@@ -13,8 +13,10 @@ 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.aihr.knowledge.service.AihrKnowledgeQueryService.MediaMode;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
@@ -30,9 +32,7 @@ public class AihrAgentOrchestrator {
|
||||
private final AihrKnowledgeQueryService queryService;
|
||||
|
||||
public AgentResponse handle(AgentRequest request) {
|
||||
if (request == null || request.question() == null || request.question().isBlank()) {
|
||||
throw new ServiceException("问题不能为空", 400);
|
||||
}
|
||||
requireQuestion(request);
|
||||
AgentPlan plan = planner.plan(request.question(), false);
|
||||
policy.authorize(principalResolver.current(), plan, request.externalConsent());
|
||||
return switch (plan.intent()) {
|
||||
@@ -47,6 +47,23 @@ public class AihrAgentOrchestrator {
|
||||
};
|
||||
}
|
||||
|
||||
public AgentResponse handleMedia(AgentRequest request, MultipartFile file) {
|
||||
requireQuestion(request);
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new ServiceException("附件不能为空", 400);
|
||||
}
|
||||
AgentPlan plan = planner.plan(request.question(), true);
|
||||
policy.authorize(principalResolver.current(), plan, request.externalConsent());
|
||||
MediaMode mode = needsKnowledge(request.question())
|
||||
? MediaMode.MEDIA_WITH_KNOWLEDGE
|
||||
: MediaMode.MEDIA_ONLY;
|
||||
QueryResponse response = queryService.queryInternalMedia(new QueryRequest(
|
||||
request.question(), List.of(), "sop", null, "mobile_uni_agent", 5, null,
|
||||
request.conversationId(), request.contextVersion(), request.projectCode(), request.broadcastMessageId()
|
||||
), file, mode);
|
||||
return fromResponse(plan, response, "MEDIA");
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -56,8 +73,11 @@ public class AihrAgentOrchestrator {
|
||||
plan.rewrittenRequest(), List.of(), "sop", null, "mobile_uni_agent", 5, toolCode,
|
||||
request.conversationId(), request.contextVersion(), request.projectCode(), request.broadcastMessageId()
|
||||
));
|
||||
return fromResponse(plan, response, toolCode == null ? "ENTERPRISE_KNOWLEDGE" : "LIVE_DATA");
|
||||
}
|
||||
|
||||
private AgentResponse fromResponse(AgentPlan plan, QueryResponse response, String sourceType) {
|
||||
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(
|
||||
@@ -67,6 +87,18 @@ public class AihrAgentOrchestrator {
|
||||
);
|
||||
}
|
||||
|
||||
private static void requireQuestion(AgentRequest request) {
|
||||
if (request == null || request.question() == null || request.question().isBlank()) {
|
||||
throw new ServiceException("问题不能为空", 400);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean needsKnowledge(String question) {
|
||||
String value = question == null ? "" : question;
|
||||
return List.of("怎么办", "怎么处理", "如何处理", "sop", "SOP", "制度", "流程", "规定",
|
||||
"故障", "漏水", "电梯", "现场").stream().anyMatch(value::contains);
|
||||
}
|
||||
|
||||
private static AgentStatus status(QueryResponse response) {
|
||||
if (response.data() instanceof CurrentTaskSummary summary && "UNAVAILABLE".equals(summary.state())) {
|
||||
return AgentStatus.UNAVAILABLE;
|
||||
|
||||
+42
-5
@@ -40,6 +40,11 @@ import java.util.UUID;
|
||||
@Service
|
||||
public class AihrKnowledgeQueryService {
|
||||
|
||||
public enum MediaMode {
|
||||
MEDIA_ONLY,
|
||||
MEDIA_WITH_KNOWLEDGE
|
||||
}
|
||||
|
||||
private final AihrKnowledgePrincipalResolver principalResolver;
|
||||
private final AihrKnowledgeAppService appService;
|
||||
private final AihrKnowledgeAccessService accessService;
|
||||
@@ -177,6 +182,10 @@ public class AihrKnowledgeQueryService {
|
||||
}
|
||||
|
||||
public QueryResponse queryInternalMedia(QueryRequest rawRequest, MultipartFile file) {
|
||||
return queryInternalMedia(rawRequest, file, MediaMode.MEDIA_WITH_KNOWLEDGE);
|
||||
}
|
||||
|
||||
public QueryResponse queryInternalMedia(QueryRequest rawRequest, MultipartFile file, MediaMode mode) {
|
||||
QueryRequest request = validate(rawRequest);
|
||||
if (request.broadcastMessageId() != null) {
|
||||
throw new ServiceException("公司消息追问暂不支持现场附件", HttpStatus.BAD_REQUEST);
|
||||
@@ -196,11 +205,26 @@ public class AihrKnowledgeQueryService {
|
||||
}
|
||||
String combinedQuery = truncate(context.rewrittenQuery(), 400)
|
||||
+ "\n附件现场信息:" + truncate(extractedContext, 520);
|
||||
QueryResponse response = queryDocuments(principal, app, spaceIds, new QueryRequest(
|
||||
combinedQuery, request.spaceCodes(), request.category(), request.position(),
|
||||
request.source(), request.limit(), null, request.conversationId(), request.contextVersion(),
|
||||
request.projectCode()
|
||||
), request.queryText());
|
||||
QueryResponse response;
|
||||
if (mode == MediaMode.MEDIA_ONLY) {
|
||||
String requestId = UUID.randomUUID().toString().replace("-", "");
|
||||
Citation citation = new Citation(null, "MEDIA", "media:" + requestId,
|
||||
"本次附件识别", truncate(extractedContext, 520), null);
|
||||
auditService.record(requestId, principal, app, request.queryText(), List.of(), List.of("MEDIA"),
|
||||
"SUCCESS", 0L, "media-only-v1");
|
||||
response = new QueryResponse(
|
||||
requestId, request.queryText(), "附件识别结果:" + truncate(extractedContext, 520),
|
||||
List.of(citation), List.of(), false, "media-only-v1", null,
|
||||
Map.of("observation", truncate(extractedContext, 2000))
|
||||
);
|
||||
} else {
|
||||
response = queryDocuments(principal, app, spaceIds, new QueryRequest(
|
||||
combinedQuery, request.spaceCodes(), request.category(), request.position(),
|
||||
request.source(), request.limit(), null, request.conversationId(), request.contextVersion(),
|
||||
request.projectCode()
|
||||
), request.queryText());
|
||||
response = withMediaContextWhenNoEvidence(response, extractedContext);
|
||||
}
|
||||
QueryResponse completed = complete(principal, app, spaceIds, request.queryText(), context, response);
|
||||
MemoryCandidateResponse candidate = memoryService.detectForQuery(
|
||||
principal, combinedQuery, context.conversationId(), completed.requestId(),
|
||||
@@ -209,6 +233,19 @@ public class AihrKnowledgeQueryService {
|
||||
return completed.withMemoryCandidate(candidate);
|
||||
}
|
||||
|
||||
private static QueryResponse withMediaContextWhenNoEvidence(QueryResponse response, String extractedContext) {
|
||||
if (!response.noEvidence()) {
|
||||
return response;
|
||||
}
|
||||
String answer = "附件识别结果:" + truncate(extractedContext, 240) + "\n\n" + response.answer();
|
||||
return new QueryResponse(
|
||||
response.requestId(), response.queryText(), answer, response.citations(), response.usedSpaceCodes(),
|
||||
true, response.promptVersion(), response.legacy(), response.data(), response.conversationId(),
|
||||
response.contextVersion(), response.intent(), response.rewrittenQuery(), response.resources(),
|
||||
response.memoryCandidate(), response.broadcastContext()
|
||||
);
|
||||
}
|
||||
|
||||
public QueryResponse queryExternal(AuthenticatedApp app, QueryRequest rawRequest) {
|
||||
QueryRequest request = validate(rawRequest);
|
||||
if (hasText(request.toolCode()) || hasText(request.conversationId()) || request.contextVersion() != null
|
||||
|
||||
+10
@@ -38,4 +38,14 @@ class AihrAgentControllerContractTest {
|
||||
assertTrue(fields.stream().noneMatch(name ->
|
||||
name.contains("userid") || name.contains("employeeid") || name.contains("role") || name.contains("tool")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mediaEndpointAlsoRequiresLogin() throws Exception {
|
||||
var method = AihrAgentController.class.getMethod("media",
|
||||
org.springframework.web.multipart.MultipartFile.class,
|
||||
String.class, String.class, Long.class, String.class, Long.class, boolean.class);
|
||||
|
||||
assertNotNull(method.getAnnotation(SaCheckLogin.class));
|
||||
assertEquals("/messages/media", method.getAnnotation(PostMapping.class).value()[0]);
|
||||
}
|
||||
}
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
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.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService.MediaMode.MEDIA_ONLY;
|
||||
import static org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService.MediaMode.MEDIA_WITH_KNOWLEDGE;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
@Tag("dev")
|
||||
class AihrAgentMediaTest {
|
||||
|
||||
@Test
|
||||
void genericImageQuestionUsesMediaOnly() {
|
||||
var query = new CapturingMediaService();
|
||||
var response = orchestrator(query).handleMedia(request("图中是什么宠物"),
|
||||
new MockMultipartFile("file", "pet.jpg", "image/jpeg", new byte[]{1}));
|
||||
|
||||
assertEquals(MEDIA_ONLY, query.mode);
|
||||
assertEquals(AgentStatus.COMPLETED, response.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void workplaceImageQuestionCombinesMediaWithKnowledge() {
|
||||
var query = new CapturingMediaService();
|
||||
orchestrator(query).handleMedia(request("图中电梯门打不开怎么办"),
|
||||
new MockMultipartFile("file", "lift.jpg", "image/jpeg", new byte[]{1}));
|
||||
|
||||
assertEquals(MEDIA_WITH_KNOWLEDGE, query.mode);
|
||||
}
|
||||
|
||||
private static AihrAgentOrchestrator orchestrator(CapturingMediaService 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 final class CapturingMediaService extends AihrKnowledgeQueryService {
|
||||
private MediaMode mode;
|
||||
|
||||
private CapturingMediaService() {
|
||||
super(null, null, null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryResponse queryInternalMedia(QueryRequest request, MultipartFile file, MediaMode mode) {
|
||||
this.mode = mode;
|
||||
return new QueryResponse("request-1", request.queryText(), "附件识别结果:图中是一只金毛犬",
|
||||
List.of(), List.of(), false, "media-only-v1", null, null,
|
||||
"conversation_1", 1L, "QA", request.queryText(), List.of());
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -201,6 +201,82 @@ class AihrKnowledgeQueryServiceTest {
|
||||
assertEquals(true, requestCaptor.getValue().queryText().contains("电梯门无法打开"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void genericMediaQuestionSkipsRagAndAppendsTheObservedAnswer() {
|
||||
var resolver = mock(AihrKnowledgePrincipalResolver.class);
|
||||
var appService = mock(AihrKnowledgeAppService.class);
|
||||
var access = mock(AihrKnowledgeAccessService.class);
|
||||
var sop = mock(AihrSopSeedService.class);
|
||||
var conversations = mock(AihrKnowledgeConversationService.class);
|
||||
var principal = new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1",
|
||||
Set.of("employee"), Set.of("P1"), "app");
|
||||
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
|
||||
var request = new QueryRequest("图中是什么宠物", List.of(), "sop", null, "mobile_uni_agent", 5, null,
|
||||
"conversation_123", 0L, "P1");
|
||||
var context = new ConversationContext("conversation_123", true, true, 0L, List.of(),
|
||||
"图中是什么宠物", "QA", null, "P1", null);
|
||||
var media = new MockMultipartFile("file", "pet.jpg", "image/jpeg", new byte[]{1});
|
||||
when(resolver.current()).thenReturn(principal);
|
||||
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
|
||||
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(9L));
|
||||
when(conversations.prepare(principal, app, request)).thenReturn(context);
|
||||
when(conversations.append(any(), any(), any(), anyString(), anyString(), any())).thenReturn(1L);
|
||||
when(sop.extractQuestionMedia(media, "图中是什么宠物")).thenReturn("图中是一只金毛犬");
|
||||
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
|
||||
mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class),
|
||||
mock(AihrKnowledgeDataToolService.class), conversations, mock(AihrMemoryService.class));
|
||||
|
||||
var result = service.queryInternalMedia(request, media, AihrKnowledgeQueryService.MediaMode.MEDIA_ONLY);
|
||||
|
||||
assertFalse(result.noEvidence());
|
||||
assertTrue(result.answer().contains("图中是一只金毛犬"));
|
||||
verify(sop, never()).searchAuthorized(any(), any(), any());
|
||||
var appendedAnswer = ArgumentCaptor.forClass(String.class);
|
||||
verify(conversations).append(any(), any(), any(), anyString(), appendedAnswer.capture(), any());
|
||||
assertTrue(appendedAnswer.getValue().contains("图中是一只金毛犬"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void workMediaWithoutSopEvidenceKeepsObservationBeforeConversationAppend() {
|
||||
var resolver = mock(AihrKnowledgePrincipalResolver.class);
|
||||
var appService = mock(AihrKnowledgeAppService.class);
|
||||
var access = mock(AihrKnowledgeAccessService.class);
|
||||
var sop = mock(AihrSopSeedService.class);
|
||||
var jdbc = mock(JdbcTemplate.class);
|
||||
var conversations = mock(AihrKnowledgeConversationService.class);
|
||||
var principal = new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1",
|
||||
Set.of("employee"), Set.of("P1"), "app");
|
||||
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
|
||||
var request = new QueryRequest("电梯门打不开怎么办", List.of(), "sop", null, "mobile_uni_agent", 5, null,
|
||||
"conversation_123", 0L, "P1");
|
||||
var context = new ConversationContext("conversation_123", true, true, 0L, List.of(),
|
||||
"电梯门打不开怎么办", "QA", null, "P1", null);
|
||||
var media = new MockMultipartFile("file", "lift.jpg", "image/jpeg", new byte[]{1});
|
||||
when(resolver.current()).thenReturn(principal);
|
||||
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
|
||||
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(9L));
|
||||
when(conversations.prepare(principal, app, request)).thenReturn(context);
|
||||
when(conversations.append(any(), any(), any(), anyString(), anyString(), any())).thenReturn(1L);
|
||||
when(jdbc.queryForList(any(String.class), any(Class.class), any(Object[].class))).thenReturn(List.of("sop"));
|
||||
when(sop.extractQuestionMedia(media, "电梯门打不开怎么办")).thenReturn("画面显示电梯门无法打开");
|
||||
when(sop.searchAuthorized(any(), any(), any())).thenReturn(
|
||||
new SearchResponse("现场问题", "sop", "未找到明确制度依据。", "", List.of(), List.of(),
|
||||
List.of(), List.of(), List.of(), List.of(), "test-v1", null));
|
||||
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
|
||||
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class),
|
||||
conversations, mock(AihrMemoryService.class));
|
||||
|
||||
var result = service.queryInternalMedia(
|
||||
request, media, AihrKnowledgeQueryService.MediaMode.MEDIA_WITH_KNOWLEDGE);
|
||||
|
||||
assertTrue(result.noEvidence());
|
||||
assertTrue(result.answer().contains("附件识别结果:画面显示电梯门无法打开"));
|
||||
assertTrue(result.answer().contains("未找到明确制度依据"));
|
||||
var appendedAnswer = ArgumentCaptor.forClass(String.class);
|
||||
verify(conversations).append(any(), any(), any(), anyString(), appendedAnswer.capture(), any());
|
||||
assertTrue(appendedAnswer.getValue().contains("附件识别结果:画面显示电梯门无法打开"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fileFollowupUsesConversationRewriteAndReturnsOnlyAuthorizedAttachment() throws Exception {
|
||||
var resolver = mock(AihrKnowledgePrincipalResolver.class);
|
||||
|
||||
Reference in New Issue
Block a user