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