实现MCP任务结果提交稳定性映射
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
package cn.nianxx.thhotel.integrations.mcp.superagent.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* SuperAgent MCP 写入工具 payload 适配服务。负责把 Agent 业务结果转换为本系统任务结果入站 payload。
|
||||
*/
|
||||
public interface SuperAgentMcpSubmitPayloadAdapter {
|
||||
|
||||
/**
|
||||
* 校验并转换 th_hotel_submit_task_results 的 arguments,返回可提交给业务入站服务的稳定 payload。
|
||||
*/
|
||||
JsonNode adapt(JsonNode arguments);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcp
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpToolDefinition;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpToolsListResult;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.service.SuperAgentMcpService;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.service.SuperAgentMcpSubmitPayloadAdapter;
|
||||
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
|
||||
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiCaseContextQueryRequest;
|
||||
@@ -51,6 +52,7 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
|
||||
private final SuperAgentMcpProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final HotelContextService hotelContextService;
|
||||
private final SuperAgentMcpSubmitPayloadAdapter submitPayloadAdapter;
|
||||
|
||||
/**
|
||||
* 注入已有业务服务和 JSON 工具,MCP 层不直接访问 Mapper 或数据库。
|
||||
@@ -60,12 +62,14 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
|
||||
ReservationAiTaskIntakeService intakeService,
|
||||
SuperAgentMcpProperties properties,
|
||||
ObjectMapper objectMapper,
|
||||
HotelContextService hotelContextService) {
|
||||
HotelContextService hotelContextService,
|
||||
SuperAgentMcpSubmitPayloadAdapter submitPayloadAdapter) {
|
||||
this.aiQueryService = aiQueryService;
|
||||
this.intakeService = intakeService;
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
this.hotelContextService = hotelContextService;
|
||||
this.submitPayloadAdapter = submitPayloadAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,6 +170,13 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
|
||||
exception.getErrorCode(),
|
||||
exception.getMessage(),
|
||||
Map.of("http_status", exception.getStatus().value())));
|
||||
} catch (SuperAgentMcpSubmitPayloadException exception) {
|
||||
return SuperAgentMcpToolCallResult.error(
|
||||
"MCP submit payload 校验失败:" + exception.getMessage(),
|
||||
errorStructuredContent(
|
||||
exception.getErrorCode(),
|
||||
exception.getMessage(),
|
||||
Map.of("field", exception.getField())));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return SuperAgentMcpToolCallResult.error(
|
||||
"MCP 工具参数不合法。",
|
||||
@@ -249,7 +260,8 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
|
||||
"MCP 写入工具未启用。",
|
||||
Map.of("tool", TOOL_SUBMIT_TASK_RESULTS)));
|
||||
}
|
||||
String rawBody = objectMapper.writeValueAsString(arguments);
|
||||
JsonNode mappedPayload = submitPayloadAdapter.adapt(arguments);
|
||||
String rawBody = objectMapper.writeValueAsString(mappedPayload);
|
||||
SuperAgentTaskResultResponse response = intakeService.accept(
|
||||
rawBody,
|
||||
MCP_CLIENT_ID,
|
||||
@@ -360,16 +372,104 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
|
||||
propertiesMap.put("hotel_id", stringField("可选酒店上下文 ID;缺省由 TH Hotel 后端解析系统酒店"));
|
||||
propertiesMap.put("source_provider", nullableStringField("兼容字段;写入工具通常不需要传,后端写入定位不使用该字段"));
|
||||
propertiesMap.put("source_channel", nullableStringField("兼容字段;写入工具通常不需要传,后端写入定位不使用该字段,AgentBus 实际入库渠道可能是 OUTLOOK"));
|
||||
propertiesMap.put("source_message_id", stringField("外部来源消息 ID,对应 AgentBus source.external_message_id"));
|
||||
propertiesMap.put("source_message", sourceMessageSchema());
|
||||
propertiesMap.put("route_code", nullableStringField("V3 S10/S99 入口通知路由码"));
|
||||
propertiesMap.put("handler_type", nullableStringField("V3 Main Agent 输出处理器类型"));
|
||||
propertiesMap.put("result_type", nullableStringField("V3 入口通知或 V2 任务结果类型"));
|
||||
propertiesMap.put("current_or_history", nullableStringField("V3 current/history 标记"));
|
||||
propertiesMap.put("agent_assessment", Map.of("type", "object", "description", "V3 S10/S99 入口判断摘要"));
|
||||
propertiesMap.put("notification", Map.of("type", "object", "description", "V3 S10/S99 通知展示信息"));
|
||||
propertiesMap.put("manual_review", Map.of(
|
||||
"type", List.of("object", "null"),
|
||||
"description", "V3 人工复核对象,S10 可为空,S99 必须完整"));
|
||||
propertiesMap.put("message_events", Map.of(
|
||||
"type", "array",
|
||||
"description", "V3 业务事件数组;MCP Adapter 会按数组顺序生成一基 source_event_index",
|
||||
"items", messageEventSchema()));
|
||||
propertiesMap.put("case_candidates", Map.of(
|
||||
"type", "array",
|
||||
"description", "V3 订单候选数组,无候选传空数组",
|
||||
"items", Map.of("type", "object")));
|
||||
propertiesMap.put("unhandled_current_intents", Map.of(
|
||||
"type", "array",
|
||||
"description", "V3 未覆盖当前意图数组,无意图传空数组",
|
||||
"items", Map.of("type", "object")));
|
||||
propertiesMap.put("source_message_id", stringField("V2 兼容字段:外部来源消息 ID,对应 AgentBus source.external_message_id"));
|
||||
propertiesMap.put("ai_task_results", Map.of(
|
||||
"type", "array",
|
||||
"description", "AI 拆分出的任务结果,必须保留数组顺序",
|
||||
"description", "V2 兼容字段:AI 拆分出的任务结果,必须保留数组顺序",
|
||||
"items", Map.of("type", "object")));
|
||||
propertiesMap.put("extraction_warnings", Map.of(
|
||||
"type", "array",
|
||||
"description", "AI 抽取警告",
|
||||
"description", "AI 抽取警告;V3/V2 都允许,缺省按空数组处理",
|
||||
"items", Map.of("type", "object")));
|
||||
return objectSchema(propertiesMap, List.of("source_message_id", "ai_task_results"));
|
||||
return objectSchema(propertiesMap, List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 V3 source_message 的 MCP schema,保持和提交前 adapter 校验规则一致。
|
||||
*/
|
||||
private Map<String, Object> sourceMessageSchema() {
|
||||
Map<String, Object> propertiesMap = new LinkedHashMap<>();
|
||||
propertiesMap.put("source_message_id", stringField("外部来源消息 ID,对应 AgentBus source.external_message_id"));
|
||||
propertiesMap.put("subject", nullableStringField("邮件主题"));
|
||||
propertiesMap.put("from", nullableStringField("发件人摘要"));
|
||||
propertiesMap.put("cc", Map.of("type", "array", "description", "抄送人列表", "items", Map.of("type", "string")));
|
||||
propertiesMap.put("received_at", nullableStringField("来源消息接收时间"));
|
||||
propertiesMap.put("source_channel", Map.of("type", "string", "enum", List.of("Email"), "description", "来源渠道语义,固定 Email"));
|
||||
return objectSchema(propertiesMap, List.of(
|
||||
"source_message_id",
|
||||
"subject",
|
||||
"from",
|
||||
"cc",
|
||||
"received_at",
|
||||
"source_channel"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 V3 message_events[] item 的 MCP schema,说明事件索引会由 adapter 统一映射。
|
||||
*/
|
||||
private Map<String, Object> messageEventSchema() {
|
||||
Map<String, Object> propertiesMap = new LinkedHashMap<>();
|
||||
propertiesMap.put("event_type", stringField("V3 业务事件类型"));
|
||||
propertiesMap.put("event_role", stringField("V3 事件来源角色"));
|
||||
propertiesMap.put("source_event_index", Map.of(
|
||||
"type", List.of("string", "integer"),
|
||||
"description", "Agent 业务事件 ID 或数字;MCP Adapter 会按 message_events[] 顺序映射为本系统一基数字索引"));
|
||||
propertiesMap.put("current_or_history", stringField("第一版只接受 current"));
|
||||
propertiesMap.put("case_keys", Map.of("type", "object", "description", "订单关联候选键"));
|
||||
propertiesMap.put("relevant_message_excerpt", stringField("当前事件证据摘录"));
|
||||
propertiesMap.put("attachments", Map.of("type", "array", "description", "当前事件附件引用", "items", Map.of("type", "object")));
|
||||
propertiesMap.put("file_references", Map.of("type", "array", "description", "当前事件文件引用", "items", Map.of("type", "object")));
|
||||
propertiesMap.put("context_used", Map.of("type", "object", "description", "当前事件使用的上下文"));
|
||||
propertiesMap.put("extracted_fields", Map.of("type", "object", "description", "业务字段主体"));
|
||||
propertiesMap.put("manual_review", Map.of("type", List.of("object", "null"), "description", "type-known manual review 对象"));
|
||||
propertiesMap.put("related_source_event_index", nullableStringField("单事件关系引用,MCP Adapter 会映射为真实索引"));
|
||||
propertiesMap.put("related_source_event_indices", Map.of(
|
||||
"type", "array",
|
||||
"description", "多事件关系引用,MCP Adapter 会保持顺序、去重校验并映射为真实索引",
|
||||
"items", Map.of("type", List.of("string", "integer"))));
|
||||
propertiesMap.put("parent_source_event_index", Map.of(
|
||||
"type", List.of("string", "integer", "null"),
|
||||
"description", "父事件引用,MCP Adapter 会映射为真实索引"));
|
||||
propertiesMap.put("linked_task_group_id", nullableStringField("联动任务组 ID"));
|
||||
propertiesMap.put("blocked_until_parent_completed", Map.of("type", List.of("boolean", "null"), "description", "是否被父任务阻塞"));
|
||||
propertiesMap.put("related_event_type", nullableStringField("关系目标事件类型"));
|
||||
propertiesMap.put("requires_downstream_hard_validation", Map.of("type", List.of("boolean", "null"), "description", "是否要求下游硬校验"));
|
||||
propertiesMap.put("contract_errors", Map.of("type", "array", "description", "Agent 暴露的契约错误", "items", Map.of("type", "object")));
|
||||
propertiesMap.put("missing_fields", Map.of("type", "array", "description", "根级缺失字段", "items", Map.of("type", "string")));
|
||||
return objectSchema(propertiesMap, List.of(
|
||||
"event_type",
|
||||
"event_role",
|
||||
"source_event_index",
|
||||
"current_or_history",
|
||||
"case_keys",
|
||||
"relevant_message_excerpt",
|
||||
"attachments",
|
||||
"file_references",
|
||||
"context_used",
|
||||
"extracted_fields",
|
||||
"manual_review"));
|
||||
}
|
||||
|
||||
private Map<String, Object> objectSchema(Map<String, Object> propertiesMap, List<String> required) {
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
package cn.nianxx.thhotel.integrations.mcp.superagent.service.impl;
|
||||
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.service.SuperAgentMcpSubmitPayloadAdapter;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* SuperAgent MCP 写入工具 payload 适配实现。该类只处理 transport 映射,不重新解释业务语义。
|
||||
*/
|
||||
@Service
|
||||
public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmitPayloadAdapter {
|
||||
|
||||
private static final String ERROR_CODE = "MCP_SUBMIT_PAYLOAD_INVALID";
|
||||
private static final Set<String> V3_BUSINESS_ROOT_FIELDS = Set.of(
|
||||
"source_message",
|
||||
"message_events",
|
||||
"case_candidates",
|
||||
"extraction_warnings",
|
||||
"unhandled_current_intents");
|
||||
private static final Set<String> V3_SOURCE_MESSAGE_FIELDS = Set.of(
|
||||
"source_message_id",
|
||||
"subject",
|
||||
"from",
|
||||
"cc",
|
||||
"received_at",
|
||||
"source_channel");
|
||||
private static final List<String> V3_SOURCE_MESSAGE_REQUIRED_FIELDS = List.of(
|
||||
"source_message_id",
|
||||
"subject",
|
||||
"from",
|
||||
"cc",
|
||||
"received_at",
|
||||
"source_channel");
|
||||
private static final Set<String> V3_EVENT_FIELDS = Set.of(
|
||||
"event_type",
|
||||
"event_role",
|
||||
"source_event_index",
|
||||
"current_or_history",
|
||||
"case_keys",
|
||||
"relevant_message_excerpt",
|
||||
"attachments",
|
||||
"file_references",
|
||||
"context_used",
|
||||
"extracted_fields",
|
||||
"manual_review",
|
||||
"related_event_type",
|
||||
"requires_downstream_hard_validation",
|
||||
"related_source_event_index",
|
||||
"related_source_event_indices",
|
||||
"parent_source_event_index",
|
||||
"linked_task_group_id",
|
||||
"blocked_until_parent_completed",
|
||||
"contract_errors",
|
||||
"missing_fields");
|
||||
private static final List<String> V3_EVENT_REQUIRED_FIELDS = List.of(
|
||||
"event_type",
|
||||
"event_role",
|
||||
"source_event_index",
|
||||
"current_or_history",
|
||||
"case_keys",
|
||||
"relevant_message_excerpt",
|
||||
"attachments",
|
||||
"file_references",
|
||||
"context_used",
|
||||
"extracted_fields",
|
||||
"manual_review");
|
||||
private static final Set<String> V3_ACTIVE_EVENT_TYPES = Set.of(
|
||||
"New Booking",
|
||||
"Update Booking / Amendment",
|
||||
"Cancel Booking",
|
||||
"Cancel Allotment",
|
||||
"Voucher Received",
|
||||
"Payment Evidence",
|
||||
"Rooming List",
|
||||
"AMEND GROUP CODE",
|
||||
"Trace",
|
||||
"TA RECORDER",
|
||||
"Invoice Generation",
|
||||
"Invoice Received",
|
||||
"Payment Notice",
|
||||
"Manual RateCode");
|
||||
private static final List<String> V3_CASE_KEY_FIELDS = List.of(
|
||||
"group_code",
|
||||
"confirmation_number",
|
||||
"reservation_number",
|
||||
"block_code");
|
||||
private static final Set<String> V3_NOTIFICATION_ROOT_FIELDS = Set.of(
|
||||
"source_message",
|
||||
"route_code",
|
||||
"handler_type",
|
||||
"result_type",
|
||||
"current_or_history",
|
||||
"agent_assessment",
|
||||
"notification",
|
||||
"manual_review");
|
||||
private static final List<String> V3_NOTIFICATION_REQUIRED_FIELDS = List.of(
|
||||
"source_message",
|
||||
"route_code",
|
||||
"handler_type",
|
||||
"result_type",
|
||||
"current_or_history",
|
||||
"agent_assessment",
|
||||
"notification",
|
||||
"manual_review");
|
||||
private static final Set<String> LEGACY_V2_ROOT_FIELDS = Set.of(
|
||||
"hotel_id",
|
||||
"source_provider",
|
||||
"source_channel",
|
||||
"source_message_id",
|
||||
"ai_task_results",
|
||||
"extraction_warnings");
|
||||
private static final List<String> REQUIRED_V3_ROOT_ARRAY_FIELDS = List.of(
|
||||
"message_events",
|
||||
"case_candidates",
|
||||
"extraction_warnings",
|
||||
"unhandled_current_intents");
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 注入 JSON 工具,用于深拷贝和构造归一化 payload。
|
||||
*/
|
||||
public SuperAgentMcpSubmitPayloadAdapterImpl(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 submit arguments 的根结构选择 V3 业务根、S10/S99 通知或 V2 兼容映射。
|
||||
*/
|
||||
@Override
|
||||
public JsonNode adapt(JsonNode arguments) {
|
||||
if (arguments == null || !arguments.isObject()) {
|
||||
throw invalid("MCP submit arguments 必须是 JSON object。", "arguments");
|
||||
}
|
||||
if (isV3BusinessRoot(arguments)) {
|
||||
if (isV3SourceMessageIdentityMissing(arguments)) {
|
||||
return arguments;
|
||||
}
|
||||
return adaptV3BusinessRoot(arguments);
|
||||
}
|
||||
if (isV3SourceMessageNotification(arguments)) {
|
||||
if (isV3SourceMessageIdentityMissing(arguments)) {
|
||||
return arguments;
|
||||
}
|
||||
validateRootFields(arguments, V3_NOTIFICATION_ROOT_FIELDS);
|
||||
validateRequiredFields(arguments, V3_NOTIFICATION_REQUIRED_FIELDS);
|
||||
validateSourceMessage(arguments.path("source_message"));
|
||||
return arguments;
|
||||
}
|
||||
if (arguments.has("ai_task_results")) {
|
||||
validateRootFields(arguments, LEGACY_V2_ROOT_FIELDS);
|
||||
if (!hasText(arguments.path("source_message_id"))) {
|
||||
throw invalid("V2 兼容 payload 缺少 source_message_id。", "source_message_id");
|
||||
}
|
||||
if (!arguments.path("ai_task_results").isArray()) {
|
||||
throw invalid("ai_task_results 必须是数组。", "ai_task_results");
|
||||
}
|
||||
return arguments;
|
||||
}
|
||||
throw invalid("MCP submit arguments 不是支持的 V3 或 V2 任务结果结构。", "arguments");
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并转换 V3 业务根,将业务事件 ID 映射为本系统一基 source_event_index。
|
||||
*/
|
||||
private JsonNode adaptV3BusinessRoot(JsonNode root) {
|
||||
validateRootFields(root, V3_BUSINESS_ROOT_FIELDS);
|
||||
validateSourceMessage(root.path("source_message"));
|
||||
for (String fieldName : REQUIRED_V3_ROOT_ARRAY_FIELDS) {
|
||||
if (!root.path(fieldName).isArray()) {
|
||||
throw invalid(fieldName + " 必须是数组。", fieldName);
|
||||
}
|
||||
}
|
||||
ArrayNode events = (ArrayNode) root.path("message_events");
|
||||
if (events.isEmpty()) {
|
||||
return root.deepCopy();
|
||||
}
|
||||
Map<String, Integer> eventIndexMap = eventIndexMap(events);
|
||||
ObjectNode mappedRoot = root.deepCopy();
|
||||
ArrayNode mappedEvents = (ArrayNode) mappedRoot.path("message_events");
|
||||
for (int index = 0; index < mappedEvents.size(); index++) {
|
||||
JsonNode eventNode = mappedEvents.get(index);
|
||||
if (!eventNode.isObject()) {
|
||||
throw invalid("message_events[] item 必须是 object。", "message_events");
|
||||
}
|
||||
ObjectNode event = (ObjectNode) eventNode;
|
||||
validateRootFields(event, V3_EVENT_FIELDS);
|
||||
validateV3EventShape(event);
|
||||
event.put("source_event_index", index + 1);
|
||||
mapSingleEventReference(event, eventIndexMap, "related_source_event_index", false);
|
||||
mapSingleEventReference(event, eventIndexMap, "parent_source_event_index", true);
|
||||
mapMultipleEventReferences(event, eventIndexMap);
|
||||
}
|
||||
return mappedRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建业务事件 ID 到 MCP 一基索引的映射,重复或缺失立即拒绝。
|
||||
*/
|
||||
private Map<String, Integer> eventIndexMap(ArrayNode events) {
|
||||
Map<String, Integer> result = new LinkedHashMap<>();
|
||||
for (int index = 0; index < events.size(); index++) {
|
||||
JsonNode event = events.get(index);
|
||||
if (!event.isObject()) {
|
||||
throw invalid("message_events[] item 必须是 object。", "message_events");
|
||||
}
|
||||
validateRootFields(event, V3_EVENT_FIELDS);
|
||||
validateV3EventShape((ObjectNode) event);
|
||||
String rawIndex = referenceText(event.path("source_event_index"));
|
||||
if (rawIndex == null) {
|
||||
throw invalid("message_events[].source_event_index 不能为空。", "source_event_index");
|
||||
}
|
||||
if (result.putIfAbsent(rawIndex, index + 1) != null) {
|
||||
throw invalid("message_events[].source_event_index 不能重复。", "source_event_index");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射单事件关系字段;parent_source_event_index 按业务入站服务约定输出数字。
|
||||
*/
|
||||
private void mapSingleEventReference(
|
||||
ObjectNode event,
|
||||
Map<String, Integer> eventIndexMap,
|
||||
String fieldName,
|
||||
boolean numericOutput) {
|
||||
JsonNode value = event.get(fieldName);
|
||||
if (value == null || value.isNull()) {
|
||||
return;
|
||||
}
|
||||
Integer mappedIndex = eventIndexMap.get(referenceText(value));
|
||||
if (mappedIndex == null) {
|
||||
throw invalid(fieldName + " 引用了不存在的 source_event_index。", fieldName);
|
||||
}
|
||||
if (numericOutput) {
|
||||
event.put(fieldName, mappedIndex);
|
||||
} else {
|
||||
event.put(fieldName, String.valueOf(mappedIndex));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射多事件关系字段,保持顺序并拒绝悬空或重复引用。
|
||||
*/
|
||||
private void mapMultipleEventReferences(ObjectNode event, Map<String, Integer> eventIndexMap) {
|
||||
JsonNode indices = event.get("related_source_event_indices");
|
||||
if (indices == null || indices.isNull()) {
|
||||
return;
|
||||
}
|
||||
if (!indices.isArray()) {
|
||||
throw invalid("related_source_event_indices 必须是数组。", "related_source_event_indices");
|
||||
}
|
||||
ArrayNode mapped = objectMapper.createArrayNode();
|
||||
Set<Integer> seen = new LinkedHashSet<>();
|
||||
for (JsonNode item : indices) {
|
||||
Integer mappedIndex = eventIndexMap.get(referenceText(item));
|
||||
if (mappedIndex == null) {
|
||||
throw invalid("related_source_event_indices 引用了不存在的 source_event_index。", "related_source_event_indices");
|
||||
}
|
||||
if (!seen.add(mappedIndex)) {
|
||||
throw invalid("related_source_event_indices 不能包含重复引用。", "related_source_event_indices");
|
||||
}
|
||||
mapped.add(String.valueOf(mappedIndex));
|
||||
}
|
||||
event.set("related_source_event_indices", mapped);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 source_message 的最小形态,source_message_id 必须存在。
|
||||
*/
|
||||
private void validateSourceMessage(JsonNode sourceMessage) {
|
||||
if (sourceMessage == null || !sourceMessage.isObject()) {
|
||||
throw invalid("source_message 必须是对象。", "source_message");
|
||||
}
|
||||
validateRootFields(sourceMessage, V3_SOURCE_MESSAGE_FIELDS);
|
||||
validateRequiredFields(sourceMessage, V3_SOURCE_MESSAGE_REQUIRED_FIELDS);
|
||||
if (!hasText(sourceMessage.path("source_message_id"))) {
|
||||
throw invalid("source_message.source_message_id 不能为空。", "source_message.source_message_id");
|
||||
}
|
||||
if (!isStringOrNull(sourceMessage.path("subject"))
|
||||
|| !isStringOrNull(sourceMessage.path("from"))
|
||||
|| !isStringArray(sourceMessage.path("cc"))
|
||||
|| !isStringOrNull(sourceMessage.path("received_at"))
|
||||
|| !"Email".equals(referenceText(sourceMessage.path("source_channel")))) {
|
||||
throw invalid("source_message 结构不符合 0711 P0 契约。", "source_message");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 V3 业务事件最小结构和字段类型,提交前 fail closed。
|
||||
*/
|
||||
private void validateV3EventShape(ObjectNode event) {
|
||||
validateRequiredFields(event, V3_EVENT_REQUIRED_FIELDS);
|
||||
if ((!V3_ACTIVE_EVENT_TYPES.contains(referenceText(event.path("event_type")))
|
||||
&& !"Need Manual Review".equals(referenceText(event.path("event_type"))))
|
||||
|| !hasText(event.path("event_role"))
|
||||
|| !"current".equals(referenceText(event.path("current_or_history")))
|
||||
|| !hasText(event.path("source_event_index"))
|
||||
|| !isValidCaseKeys(event.path("case_keys"))
|
||||
|| !event.path("relevant_message_excerpt").isTextual()
|
||||
|| !event.path("attachments").isArray()
|
||||
|| !event.path("file_references").isArray()
|
||||
|| !event.path("context_used").isObject()
|
||||
|| !event.path("extracted_fields").isObject()) {
|
||||
throw invalid("message_event 字段类型或基础值无效。", "message_events");
|
||||
}
|
||||
JsonNode manualReview = event.get("manual_review");
|
||||
if (manualReview == null || (!manualReview.isNull() && !manualReview.isObject())) {
|
||||
throw invalid("message_event.manual_review 必须为 null 或对象。", "manual_review");
|
||||
}
|
||||
if ("Need Manual Review".equals(referenceText(event.path("event_type"))) && manualReview.isNull()) {
|
||||
throw invalid("Need Manual Review 必须携带 manual_review。", "manual_review");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验对象不存在未知字段,避免 Agent 猜测 transport 字段。
|
||||
*/
|
||||
private void validateRootFields(JsonNode node, Set<String> allowedFields) {
|
||||
if (node == null || !node.isObject()) {
|
||||
throw invalid("payload 节点必须是对象。", "payload");
|
||||
}
|
||||
Iterator<String> fieldNames = node.fieldNames();
|
||||
while (fieldNames.hasNext()) {
|
||||
String fieldName = fieldNames.next();
|
||||
if (!allowedFields.contains(fieldName)) {
|
||||
throw invalid("MCP submit payload 包含未知字段。", fieldName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验必填字段存在,允许字段值为 null 的场景由后续类型校验处理。
|
||||
*/
|
||||
private void validateRequiredFields(JsonNode node, List<String> requiredFields) {
|
||||
for (String requiredField : requiredFields) {
|
||||
if (!node.has(requiredField)) {
|
||||
throw invalid("MCP submit payload 缺少必填字段。", requiredField);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 case_keys 四字段结构,避免 source_event_index 映射后才被业务层拒绝。
|
||||
*/
|
||||
private boolean isValidCaseKeys(JsonNode caseKeys) {
|
||||
if (caseKeys == null || !caseKeys.isObject()) {
|
||||
return false;
|
||||
}
|
||||
for (String key : V3_CASE_KEY_FIELDS) {
|
||||
if (!caseKeys.has(key) || !isStringOrNull(caseKeys.path(key))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Iterator<String> fieldNames = caseKeys.fieldNames();
|
||||
while (fieldNames.hasNext()) {
|
||||
if (!V3_CASE_KEY_FIELDS.contains(fieldNames.next())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 V3 业务根。
|
||||
*/
|
||||
private boolean isV3BusinessRoot(JsonNode arguments) {
|
||||
return arguments.has("source_message") && arguments.has("message_events");
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 V3 S10/S99 入口通知。
|
||||
*/
|
||||
private boolean isV3SourceMessageNotification(JsonNode arguments) {
|
||||
return arguments.has("source_message") && arguments.has("route_code");
|
||||
}
|
||||
|
||||
/**
|
||||
* source_message_id 缺失由业务入站服务返回既有 typed infrastructure error,MCP adapter 不改写错误通道。
|
||||
*/
|
||||
private boolean isV3SourceMessageIdentityMissing(JsonNode arguments) {
|
||||
JsonNode sourceMessage = arguments.get("source_message");
|
||||
return sourceMessage == null
|
||||
|| !sourceMessage.isObject()
|
||||
|| !hasText(sourceMessage.path("source_message_id"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取事件引用文本,数字和字符串都统一成字符串键。
|
||||
*/
|
||||
private String referenceText(JsonNode node) {
|
||||
if (node == null || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
String value = node.asText();
|
||||
return value == null || value.trim().isEmpty() ? null : value.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 JSON 文本节点是否存在有效内容。
|
||||
*/
|
||||
private boolean hasText(JsonNode node) {
|
||||
return referenceText(node) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字段是否为字符串或 null。
|
||||
*/
|
||||
private boolean isStringOrNull(JsonNode node) {
|
||||
return node != null && (node.isTextual() || node.isNull());
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断数组是否只包含字符串。
|
||||
*/
|
||||
private boolean isStringArray(JsonNode node) {
|
||||
if (node == null || !node.isArray()) {
|
||||
return false;
|
||||
}
|
||||
for (JsonNode item : node) {
|
||||
if (!item.isTextual()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造稳定 MCP submit payload 校验异常。
|
||||
*/
|
||||
private SuperAgentMcpSubmitPayloadException invalid(String message, String field) {
|
||||
return new SuperAgentMcpSubmitPayloadException(ERROR_CODE, message, field);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package cn.nianxx.thhotel.integrations.mcp.superagent.service.impl;
|
||||
|
||||
/**
|
||||
* MCP submit payload 校验异常。只暴露字段和安全摘要,不包含邮件正文、附件 URL 或 Secret。
|
||||
*/
|
||||
public class SuperAgentMcpSubmitPayloadException extends RuntimeException {
|
||||
|
||||
private final String errorCode;
|
||||
private final String field;
|
||||
|
||||
/**
|
||||
* 构造 MCP submit payload 校验异常。
|
||||
*/
|
||||
public SuperAgentMcpSubmitPayloadException(String errorCode, String message, String field) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
this.field = field;
|
||||
}
|
||||
|
||||
public String getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
public String getField() {
|
||||
return field;
|
||||
}
|
||||
}
|
||||
@@ -130,8 +130,11 @@ class SuperAgentMcpControllerTest {
|
||||
.andExpect(jsonPath("$.result.tools[3].inputSchema.required.length()").value(0))
|
||||
.andExpect(jsonPath("$.result.tools[3].annotations.readOnlyHint").value(true))
|
||||
.andExpect(jsonPath("$.result.tools[4].name").value("th_hotel_submit_task_results"))
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.required[0]").value("source_message_id"))
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.required[1]").value("ai_task_results"))
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_message").exists())
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.message_events").exists())
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.ai_task_results").exists())
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.message_events.items.properties.source_event_index.description")
|
||||
.value(containsString("MCP Adapter")))
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_provider.description")
|
||||
.value(containsString("兼容字段")))
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_channel.description")
|
||||
|
||||
@@ -2,19 +2,31 @@ package cn.nianxx.thhotel.integrations.mcp.superagent.control;
|
||||
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import cn.nianxx.thhotel.ThHotelApplication;
|
||||
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand;
|
||||
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
|
||||
import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = ThHotelApplication.class,
|
||||
@@ -34,6 +46,15 @@ class SuperAgentMcpSubmitEnabledControllerTest {
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private SourceMessageCaptureService captureService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Test
|
||||
void shouldDelegateSubmitTaskResultsToolWhenWriteToolEnabled() throws Exception {
|
||||
String body = """
|
||||
@@ -70,4 +91,275 @@ class SuperAgentMcpSubmitEnabledControllerTest {
|
||||
.andExpect(jsonPath("$.result.structuredContent.error.code").value("SOURCE_MESSAGE_NOT_FOUND"))
|
||||
.andExpect(content().string(not(containsString("MCP_TOOL_DISABLED"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectUnknownV3RootFieldBeforeDelegatingSubmit() throws Exception {
|
||||
String externalId = "mail-mcp-v3-unknown-root-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
String businessRoot = parentSplitBusinessRoot(externalId)
|
||||
.replace("\"unhandled_current_intents\": []", "\"unhandled_current_intents\": [], \"unexpected_root\": true");
|
||||
|
||||
mockMvc.perform(post(ENDPOINT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", AUTHORIZATION)
|
||||
.content(toolCall("mcp-submit-v3-unknown-root-001", businessRoot)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.result.isError").value(true))
|
||||
.andExpect(jsonPath("$.result.structuredContent.error.code").value("MCP_SUBMIT_PAYLOAD_INVALID"))
|
||||
.andExpect(jsonPath("$.result.structuredContent.error.details.field").value("unexpected_root"));
|
||||
|
||||
assertNoReservationWorkflowRows(source.inboxId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldKeepInfrastructureErrorWhenV3SourceMessageIdMissing() throws Exception {
|
||||
String businessRoot = parentSplitBusinessRootWithoutSourceMessageId("mail-mcp-v3-missing-source-id-001");
|
||||
|
||||
mockMvc.perform(post(ENDPOINT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", AUTHORIZATION)
|
||||
.content(toolCall("mcp-submit-v3-missing-source-id-001", businessRoot)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.result.isError").value(true))
|
||||
.andExpect(jsonPath("$.result.structuredContent.error.code").value("MISSING_SOURCE_MESSAGE_ID"))
|
||||
.andExpect(jsonPath("$.result.structuredContent.error.details.http_status").value(400));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectDanglingRelatedEventIndexBeforeDelegatingSubmit() throws Exception {
|
||||
String externalId = "mail-mcp-v3-dangling-related-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
String businessRoot = parentSplitBusinessRootWithSecondParentRelation(externalId, "E_UNKNOWN_CHILD");
|
||||
|
||||
mockMvc.perform(post(ENDPOINT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", AUTHORIZATION)
|
||||
.content(toolCall("mcp-submit-v3-dangling-related-001", businessRoot)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.result.isError").value(true))
|
||||
.andExpect(jsonPath("$.result.structuredContent.error.code").value("MCP_SUBMIT_PAYLOAD_INVALID"))
|
||||
.andExpect(jsonPath("$.result.structuredContent.error.details.field").value("related_source_event_indices"));
|
||||
|
||||
assertNoReservationWorkflowRows(source.inboxId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectDuplicateRelatedEventIndexBeforeDelegatingSubmit() throws Exception {
|
||||
String externalId = "mail-mcp-v3-duplicate-related-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
String businessRoot = parentSplitBusinessRootWithSecondParentRelation(externalId, "E_CHILD_1");
|
||||
|
||||
mockMvc.perform(post(ENDPOINT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", AUTHORIZATION)
|
||||
.content(toolCall("mcp-submit-v3-duplicate-related-001", businessRoot)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.result.isError").value(true))
|
||||
.andExpect(jsonPath("$.result.structuredContent.error.code").value("MCP_SUBMIT_PAYLOAD_INVALID"))
|
||||
.andExpect(jsonPath("$.result.structuredContent.error.details.field").value("related_source_event_indices"));
|
||||
|
||||
assertNoReservationWorkflowRows(source.inboxId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMapBusinessEventIdsToMcpIndicesAndSubmitOnce() throws Exception {
|
||||
String externalId = "mail-mcp-v3-event-map-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
|
||||
MvcResult result = mockMvc.perform(post(ENDPOINT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", AUTHORIZATION)
|
||||
.content(toolCall("mcp-submit-v3-event-map-001", parentSplitBusinessRoot(externalId))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.result.isError").value(false))
|
||||
.andExpect(jsonPath("$.result.structuredContent.accepted_count").value(3))
|
||||
.andExpect(jsonPath("$.result.structuredContent.items[0].source_event_index").value(1))
|
||||
.andExpect(jsonPath("$.result.structuredContent.items[1].source_event_index").value(2))
|
||||
.andExpect(jsonPath("$.result.structuredContent.items[2].source_event_index").value(3))
|
||||
.andReturn();
|
||||
|
||||
Long batchCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_ai_batch
|
||||
WHERE source_message_id = ?
|
||||
""", Long.class, source.inboxId());
|
||||
assertThat(batchCount).isEqualTo(1L);
|
||||
|
||||
String parentPayloadJson = jdbcTemplate.queryForObject("""
|
||||
SELECT ai_payload_json
|
||||
FROM workflow_reservation_ai_transition
|
||||
WHERE source_message_id = ?
|
||||
AND source_event_index = 3
|
||||
""", String.class, source.inboxId());
|
||||
JsonNode parentPayload = objectMapper.readTree(parentPayloadJson);
|
||||
assertThat(parentPayload.path("v3_message_event").path("source_event_index").asInt()).isEqualTo(3);
|
||||
assertThat(parentPayload.path("v3_message_event").path("related_source_event_indices").get(0).asText())
|
||||
.isEqualTo("1");
|
||||
assertThat(parentPayload.path("v3_message_event").path("related_source_event_indices").get(1).asText())
|
||||
.isEqualTo("2");
|
||||
assertThat(result.getResponse().getContentAsString()).doesNotContain("E_CHILD_1");
|
||||
}
|
||||
|
||||
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
|
||||
return captureService.capture(new CaptureSourceMessageCommand(
|
||||
"HOTEL-TEST",
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
externalMessageId,
|
||||
"thread-" + externalMessageId,
|
||||
"frame-" + externalMessageId,
|
||||
"session-mcp-submit",
|
||||
Instant.parse("2026-07-12T04:00:00Z"),
|
||||
"agent@example.test",
|
||||
"MCP submit fixture",
|
||||
"MCP submit fixture source message.",
|
||||
"<html><body>MCP submit fixture source message.</body></html>",
|
||||
"{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}",
|
||||
"agentbus-outlook-v1",
|
||||
List.of()
|
||||
));
|
||||
}
|
||||
|
||||
private void assertNoReservationWorkflowRows(Long sourceMessageId) {
|
||||
Long batchCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_ai_batch
|
||||
WHERE source_message_id = ?
|
||||
""", Long.class, sourceMessageId);
|
||||
Long taskCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_task
|
||||
WHERE source_message_id = ?
|
||||
""", Long.class, sourceMessageId);
|
||||
assertThat(batchCount).isZero();
|
||||
assertThat(taskCount).isZero();
|
||||
}
|
||||
|
||||
private String toolCall(String id, String argumentsJson) {
|
||||
return """
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": "%s",
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "th_hotel_submit_task_results",
|
||||
"arguments": %s
|
||||
}
|
||||
}
|
||||
""".formatted(id, argumentsJson);
|
||||
}
|
||||
|
||||
private String parentSplitBusinessRootWithSecondParentRelation(String externalMessageId, String relatedEventIndex)
|
||||
throws Exception {
|
||||
ObjectNode root = (ObjectNode) objectMapper.readTree(parentSplitBusinessRoot(externalMessageId));
|
||||
ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2);
|
||||
ArrayNode relatedIndices = (ArrayNode) parentEvent.path("related_source_event_indices");
|
||||
relatedIndices.set(1, objectMapper.getNodeFactory().textNode(relatedEventIndex));
|
||||
return objectMapper.writeValueAsString(root);
|
||||
}
|
||||
|
||||
private String parentSplitBusinessRootWithoutSourceMessageId(String externalMessageId) throws Exception {
|
||||
ObjectNode root = (ObjectNode) objectMapper.readTree(parentSplitBusinessRoot(externalMessageId));
|
||||
((ObjectNode) root.path("source_message")).remove("source_message_id");
|
||||
return objectMapper.writeValueAsString(root);
|
||||
}
|
||||
|
||||
private String parentSplitBusinessRoot(String externalMessageId) {
|
||||
return """
|
||||
{
|
||||
"source_message": {
|
||||
"source_message_id": "%s",
|
||||
"subject": "Parent split booking request",
|
||||
"from": "agent@example.test",
|
||||
"cc": [],
|
||||
"received_at": "2026-07-12T04:00:00Z",
|
||||
"source_channel": "Email"
|
||||
},
|
||||
"message_events": [
|
||||
{
|
||||
"event_type": "New Booking",
|
||||
"event_role": "travel_agent_request",
|
||||
"source_event_index": "E_CHILD_1",
|
||||
"current_or_history": "current",
|
||||
"case_keys": {
|
||||
"group_code": "MCP-CHILD-A",
|
||||
"confirmation_number": null,
|
||||
"reservation_number": null,
|
||||
"block_code": null
|
||||
},
|
||||
"relevant_message_excerpt": "Please create child group A.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"extracted_fields": {
|
||||
"booking_object_type": "Group Block",
|
||||
"arrival_date": "2026-09-01",
|
||||
"departure_date": "2026-09-03"
|
||||
},
|
||||
"manual_review": null
|
||||
},
|
||||
{
|
||||
"event_type": "New Booking",
|
||||
"event_role": "travel_agent_request",
|
||||
"source_event_index": "E_CHILD_2",
|
||||
"current_or_history": "current",
|
||||
"case_keys": {
|
||||
"group_code": "MCP-CHILD-B",
|
||||
"confirmation_number": null,
|
||||
"reservation_number": null,
|
||||
"block_code": null
|
||||
},
|
||||
"relevant_message_excerpt": "Please create child group B.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"extracted_fields": {
|
||||
"booking_object_type": "Group Block",
|
||||
"arrival_date": "2026-09-01",
|
||||
"departure_date": "2026-09-03"
|
||||
},
|
||||
"manual_review": null
|
||||
},
|
||||
{
|
||||
"event_type": "Cancel Allotment",
|
||||
"event_role": "travel_agent_request",
|
||||
"source_event_index": "E_PARENT",
|
||||
"current_or_history": "current",
|
||||
"case_keys": {
|
||||
"group_code": "MCP-PARENT",
|
||||
"confirmation_number": null,
|
||||
"reservation_number": null,
|
||||
"block_code": "MCP-PARENT"
|
||||
},
|
||||
"relevant_message_excerpt": "Release parent group after splitting allocation to child groups.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"related_event_type": "New Booking",
|
||||
"requires_downstream_hard_validation": true,
|
||||
"related_source_event_indices": [
|
||||
"E_CHILD_1",
|
||||
"E_CHILD_2"
|
||||
],
|
||||
"extracted_fields": {
|
||||
"relationship_type": "linked_parent_release_after_child_split",
|
||||
"parent_group_code": "MCP-PARENT",
|
||||
"cancel_scope": "entire_allotment_control_block",
|
||||
"parent_release_or_cancel_candidate": true,
|
||||
"release_reason": "parent_to_child_allocation_split",
|
||||
"allocation_split_from_parent": true,
|
||||
"child_group_codes": [
|
||||
"MCP-CHILD-A",
|
||||
"MCP-CHILD-B"
|
||||
]
|
||||
},
|
||||
"manual_review": null
|
||||
}
|
||||
],
|
||||
"case_candidates": [],
|
||||
"extraction_warnings": [],
|
||||
"unhandled_current_intents": []
|
||||
}
|
||||
""".formatted(externalMessageId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user