消息顺序
This commit is contained in:
parent
a62cacadc4
commit
a6c3692261
@ -9,6 +9,7 @@
|
||||
3. 后端根据意图选择预注册的白名单处理器。
|
||||
4. 处理器根据企业微信 UserID 调用对应业务接口。
|
||||
5. 后端将结构化结果格式化成企业微信文本并发送。
|
||||
6. 主消息发送成功后,再按登记顺序发送 Excel 等附件;附件不会先于业务回复出现。
|
||||
|
||||
模型不能输出或调用任意 URL,也不能传入校区、顾问或权限范围。业务接口仍会根据企业微信 UserID 做最终权限校验。
|
||||
|
||||
|
||||
@ -5,7 +5,9 @@ import com.company.agentfoundation.integration.wecom.client.WecomClient;
|
||||
import com.company.agentfoundation.integration.wecom.model.WecomEncryptedEnvelope;
|
||||
import com.company.agentfoundation.integration.wecom.model.WecomIncomingMessage;
|
||||
import com.company.agentfoundation.integration.wecom.service.WecomCryptoService;
|
||||
import com.company.agentfoundation.semantic.SemanticDispatchResult;
|
||||
import com.company.agentfoundation.semantic.SemanticDispatchService;
|
||||
import com.company.agentfoundation.semantic.SemanticReplyAttachment;
|
||||
|
||||
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
|
||||
import org.slf4j.Logger;
|
||||
@ -20,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executor;
|
||||
@ -193,16 +196,19 @@ public class WecomCallbackController {
|
||||
String reportTaskKey
|
||||
) {
|
||||
String reply;
|
||||
List<SemanticReplyAttachment> attachments = List.of();
|
||||
try {
|
||||
reply = callbackMode == CallbackMode.GENERAL_CHAT
|
||||
? cozeWorkflowService.chatDefault(userText)
|
||||
: semanticDispatchService.dispatch(fromUser, userText);
|
||||
if (callbackMode == CallbackMode.GENERAL_CHAT) {
|
||||
reply = cozeWorkflowService.chatDefault(userText);
|
||||
} else {
|
||||
SemanticDispatchResult result = semanticDispatchService.dispatchWithAttachments(fromUser, userText);
|
||||
reply = result.reply();
|
||||
attachments = result.attachments();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("wecom callback processing failed mode={} fromUser={} err={}",
|
||||
callbackMode, fromUser, e.getMessage(), e);
|
||||
reply = errorReply(callbackMode);
|
||||
} finally {
|
||||
if (reportTaskKey != null) runningReports.remove(reportTaskKey);
|
||||
}
|
||||
if (reply == null || reply.isBlank()) {
|
||||
log.warn("wecom callback reply blank mode={}, fallback to error reply", callbackMode);
|
||||
@ -211,7 +217,7 @@ public class WecomCallbackController {
|
||||
log.info("wecom callback sending reply mode={} toUser={} replyPreview={}", callbackMode, fromUser, preview(reply));
|
||||
try {
|
||||
if (callbackMode == CallbackMode.SEMANTIC_ROUTING) {
|
||||
wecomClient.sendMarkdown(appKey, fromUser, reply);
|
||||
sendSemanticReply(appKey, fromUser, reply, attachments);
|
||||
} else {
|
||||
wecomClient.sendText(appKey, fromUser, reply);
|
||||
}
|
||||
@ -219,6 +225,38 @@ public class WecomCallbackController {
|
||||
} catch (Exception e) {
|
||||
log.error("wecom callback reply send failed mode={} toUser={} err={}",
|
||||
callbackMode, fromUser, e.getMessage(), e);
|
||||
} finally {
|
||||
if (reportTaskKey != null) runningReports.remove(reportTaskKey);
|
||||
}
|
||||
}
|
||||
|
||||
void sendSemanticReply(
|
||||
String appKey,
|
||||
String userId,
|
||||
String reply,
|
||||
List<SemanticReplyAttachment> attachments
|
||||
) {
|
||||
wecomClient.sendMarkdown(appKey, userId, reply);
|
||||
sendReplyAttachments(appKey, userId, attachments);
|
||||
}
|
||||
|
||||
private void sendReplyAttachments(
|
||||
String appKey,
|
||||
String userId,
|
||||
List<SemanticReplyAttachment> attachments
|
||||
) {
|
||||
for (SemanticReplyAttachment attachment : attachments) {
|
||||
try {
|
||||
switch (attachment.type()) {
|
||||
case EXCEL -> wecomClient.sendExcelFile(
|
||||
appKey, userId, attachment.fileName(), attachment.content());
|
||||
}
|
||||
log.info("wecom callback attachment sent appKey={} toUser={} type={} fileName={}",
|
||||
appKey, userId, attachment.type(), attachment.fileName());
|
||||
} catch (Exception exception) {
|
||||
log.error("wecom callback attachment send failed appKey={} toUser={} type={} fileName={} err={}",
|
||||
appKey, userId, attachment.type(), attachment.fileName(), exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
package com.company.agentfoundation.semantic;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record SemanticDispatchResult(
|
||||
String reply,
|
||||
List<SemanticReplyAttachment> attachments
|
||||
) {
|
||||
public SemanticDispatchResult {
|
||||
attachments = attachments == null ? List.of() : List.copyOf(attachments);
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@ -38,6 +39,10 @@ public class SemanticDispatchService {
|
||||
}
|
||||
|
||||
public String dispatch(String wecomUserId, String userText) {
|
||||
return dispatchWithAttachments(wecomUserId, userText).reply();
|
||||
}
|
||||
|
||||
public SemanticDispatchResult dispatchWithAttachments(String wecomUserId, String userText) {
|
||||
ZonedDateTime now = ZonedDateTime.now(BUSINESS_ZONE);
|
||||
SemanticRecognition recognition = recognitionService.recognize(userText, now.toLocalDate());
|
||||
log.info("semantic dispatch recognized wecomUserId={} intent={} confidence={} source={}",
|
||||
@ -45,9 +50,13 @@ public class SemanticDispatchService {
|
||||
|
||||
SemanticIntentHandler handler = handlers.get(recognition.intent());
|
||||
if (handler == null || recognition.confidence() < properties.getSemantic().getMinimumConfidence()) {
|
||||
return unsupportedReply();
|
||||
return new SemanticDispatchResult(unsupportedReply(), List.of());
|
||||
}
|
||||
return handler.handle(new SemanticRequestContext(wecomUserId, userText, now), recognition);
|
||||
List<SemanticReplyAttachment> attachments = new ArrayList<>();
|
||||
String reply = handler.handle(
|
||||
new SemanticRequestContext(wecomUserId, userText, now, attachments::add),
|
||||
recognition);
|
||||
return new SemanticDispatchResult(reply, attachments);
|
||||
}
|
||||
|
||||
private String unsupportedReply() {
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
package com.company.agentfoundation.semantic;
|
||||
|
||||
public record SemanticReplyAttachment(
|
||||
Type type,
|
||||
String fileName,
|
||||
byte[] content
|
||||
) {
|
||||
public static SemanticReplyAttachment excel(String fileName, byte[] content) {
|
||||
return new SemanticReplyAttachment(Type.EXCEL, fileName, content);
|
||||
}
|
||||
|
||||
public enum Type {
|
||||
EXCEL
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,19 @@
|
||||
package com.company.agentfoundation.semantic;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public record SemanticRequestContext(
|
||||
String wecomUserId,
|
||||
String userText,
|
||||
ZonedDateTime requestTime
|
||||
) {}
|
||||
ZonedDateTime requestTime,
|
||||
Consumer<SemanticReplyAttachment> attachmentSink
|
||||
) {
|
||||
public SemanticRequestContext(String wecomUserId, String userText, ZonedDateTime requestTime) {
|
||||
this(wecomUserId, userText, requestTime, ignored -> {});
|
||||
}
|
||||
|
||||
public void deferExcel(String fileName, byte[] content) {
|
||||
attachmentSink.accept(SemanticReplyAttachment.excel(fileName, content));
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@ package com.company.agentfoundation.semantic.handler;
|
||||
|
||||
import com.company.agentfoundation.ec.report.EcCallComplianceReportRequest;
|
||||
import com.company.agentfoundation.ec.report.EcCallComplianceReportService;
|
||||
import com.company.agentfoundation.integration.wecom.client.WecomClient;
|
||||
import com.company.agentfoundation.report.ExcelReportService;
|
||||
import com.company.agentfoundation.semantic.SemanticDateRangeResolver;
|
||||
import com.company.agentfoundation.semantic.SemanticIntentHandler;
|
||||
@ -20,26 +19,21 @@ import java.util.Map;
|
||||
@Component
|
||||
public class EcCallComplianceReportIntentHandler implements SemanticIntentHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(EcCallComplianceReportIntentHandler.class);
|
||||
private static final String SPORTS_VENUE_APP = "sports-venue";
|
||||
|
||||
private final SemanticDateRangeResolver dateRangeResolver;
|
||||
private final EcCallComplianceReportService reportService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ExcelReportService excelReportService;
|
||||
private final WecomClient wecomClient;
|
||||
|
||||
public EcCallComplianceReportIntentHandler(
|
||||
SemanticDateRangeResolver dateRangeResolver,
|
||||
EcCallComplianceReportService reportService,
|
||||
ObjectMapper objectMapper,
|
||||
ExcelReportService excelReportService,
|
||||
WecomClient wecomClient
|
||||
ExcelReportService excelReportService
|
||||
) {
|
||||
this.dateRangeResolver = dateRangeResolver;
|
||||
this.reportService = reportService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.excelReportService = excelReportService;
|
||||
this.wecomClient = wecomClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -67,12 +61,11 @@ public class EcCallComplianceReportIntentHandler implements SemanticIntentHandle
|
||||
Map<String, Object> reportData = reportService.generate(
|
||||
new EcCallComplianceReportRequest(range.startDate(), range.endDate(), context.wecomUserId()));
|
||||
JsonNode report = objectMapper.valueToTree(reportData);
|
||||
wecomClient.sendExcelFile(
|
||||
SPORTS_VENUE_APP,
|
||||
context.wecomUserId(),
|
||||
String reply = format(report, range);
|
||||
context.deferExcel(
|
||||
fileName(range),
|
||||
excelReportService.ecCallCompliance(reportData));
|
||||
return format(report, range);
|
||||
return reply;
|
||||
} catch (ResponseStatusException exception) {
|
||||
if (exception.getStatusCode().value() == 403) {
|
||||
return "无权限查看:你的企业微信账号不在可查询的组织范围内。UserID:" + context.wecomUserId();
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
package com.company.agentfoundation.integration.wecom.controller;
|
||||
|
||||
import com.company.agentfoundation.integration.coze.service.CozeWorkflowService;
|
||||
import com.company.agentfoundation.integration.wecom.client.WecomClient;
|
||||
import com.company.agentfoundation.integration.wecom.service.WecomCryptoService;
|
||||
import com.company.agentfoundation.semantic.SemanticDispatchService;
|
||||
import com.company.agentfoundation.semantic.SemanticReplyAttachment;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InOrder;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
class WecomCallbackControllerTest {
|
||||
@Test
|
||||
void sendsMainReplyBeforeExcelAttachment() {
|
||||
WecomClient wecom = mock(WecomClient.class);
|
||||
WecomCallbackController controller = controller(wecom);
|
||||
byte[] content = {1, 2, 3};
|
||||
|
||||
controller.sendSemanticReply(
|
||||
"sports-venue",
|
||||
"YiNianYiQingJing",
|
||||
"EC日报消息",
|
||||
List.of(SemanticReplyAttachment.excel("EC日报.xlsx", content)));
|
||||
|
||||
InOrder order = inOrder(wecom);
|
||||
order.verify(wecom).sendMarkdown("sports-venue", "YiNianYiQingJing", "EC日报消息");
|
||||
order.verify(wecom).sendExcelFile("sports-venue", "YiNianYiQingJing", "EC日报.xlsx", content);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotSendAttachmentWhenMainReplyFails() {
|
||||
WecomClient wecom = mock(WecomClient.class);
|
||||
WecomCallbackController controller = controller(wecom);
|
||||
doThrow(new IllegalStateException("send failed"))
|
||||
.when(wecom).sendMarkdown("sports-venue", "YiNianYiQingJing", "EC日报消息");
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> controller.sendSemanticReply(
|
||||
"sports-venue",
|
||||
"YiNianYiQingJing",
|
||||
"EC日报消息",
|
||||
List.of(SemanticReplyAttachment.excel("EC日报.xlsx", new byte[]{1}))));
|
||||
|
||||
verify(wecom, never()).sendExcelFile(
|
||||
org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.any(byte[].class));
|
||||
}
|
||||
|
||||
private WecomCallbackController controller(WecomClient wecom) {
|
||||
return new WecomCallbackController(
|
||||
mock(SemanticDispatchService.class),
|
||||
mock(CozeWorkflowService.class),
|
||||
wecom,
|
||||
mock(WecomCryptoService.class),
|
||||
Runnable::run);
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
@ -30,6 +31,30 @@ class SemanticDispatchServiceTest {
|
||||
assertEquals("日报结果", service.dispatch("DongPoLi", "查看今日客资管理日报"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectsAttachmentsForDeliveryAfterTheMainReply() {
|
||||
SemanticRecognitionService recognitionService = mock(SemanticRecognitionService.class);
|
||||
SemanticIntentHandler handler = mock(SemanticIntentHandler.class);
|
||||
when(handler.intent()).thenReturn("EC_CALL_COMPLIANCE_DAILY_REPORT");
|
||||
when(handler.handle(any(), any())).thenAnswer(invocation -> {
|
||||
SemanticRequestContext context = invocation.getArgument(0);
|
||||
context.deferExcel("EC日报.xlsx", new byte[]{1, 2, 3});
|
||||
return "EC日报消息";
|
||||
});
|
||||
when(recognitionService.recognize(eq("查看EC日报"), any(LocalDate.class)))
|
||||
.thenReturn(new SemanticRecognition(
|
||||
"EC_CALL_COMPLIANCE_DAILY_REPORT", 0.95D, Map.of(), "TEST"));
|
||||
SemanticDispatchService service = new SemanticDispatchService(
|
||||
recognitionService, new FoundationProperties(), List.of(handler));
|
||||
|
||||
SemanticDispatchResult result = service.dispatchWithAttachments("DongPoLi", "查看EC日报");
|
||||
|
||||
assertEquals("EC日报消息", result.reply());
|
||||
assertEquals(1, result.attachments().size());
|
||||
assertEquals("EC日报.xlsx", result.attachments().get(0).fileName());
|
||||
assertArrayEquals(new byte[]{1, 2, 3}, result.attachments().get(0).content());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateIntentHandlersAtStartup() {
|
||||
SemanticIntentHandler first = mock(SemanticIntentHandler.class);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user