完成 M002 V3 CP6 P0 fixtures 回归基线
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
package cn.nianxx.thhotel.integrations.ai.superagent.common.result;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* SuperAgent V3 基础设施输入错误响应。用于 source_message.source_message_id 缺失等调用层错误,
|
||||
* 不使用通用错误包装,保持 0711 P0 契约的扁平结构。
|
||||
*/
|
||||
public record SuperAgentTaskResultInfrastructureInputErrorResponse(
|
||||
@JsonProperty("result_type")
|
||||
String resultType,
|
||||
@JsonProperty("error_code")
|
||||
String errorCode,
|
||||
Boolean retryable,
|
||||
@JsonProperty("missing_fields")
|
||||
List<String> missingFields
|
||||
) {
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package cn.nianxx.thhotel.integrations.ai.superagent.control;
|
||||
|
||||
import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentTaskResultErrorResponse;
|
||||
import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentTaskResultInfrastructureInputErrorResponse;
|
||||
import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultException;
|
||||
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
|
||||
import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationAiTaskIntakeException;
|
||||
@@ -29,8 +30,16 @@ public class SuperAgentTaskResultControllerAdvice {
|
||||
* 处理 Reservation 接收阶段的 SourceMessage、幂等和 AI item 技术校验异常。
|
||||
*/
|
||||
@ExceptionHandler(ReservationAiTaskIntakeException.class)
|
||||
public ResponseEntity<SuperAgentTaskResultErrorResponse> handleIntakeException(
|
||||
public ResponseEntity<?> handleIntakeException(
|
||||
ReservationAiTaskIntakeException exception) {
|
||||
if ("MISSING_SOURCE_MESSAGE_ID".equals(exception.getErrorCode())) {
|
||||
return ResponseEntity.status(exception.getStatus())
|
||||
.body(new SuperAgentTaskResultInfrastructureInputErrorResponse(
|
||||
"infrastructure_input_error",
|
||||
"missing_source_message_id",
|
||||
true,
|
||||
List.of("source_message.source_message_id")));
|
||||
}
|
||||
return ResponseEntity.status(exception.getStatus())
|
||||
.body(error(exception.getErrorCode(), exception.getMessage()));
|
||||
}
|
||||
|
||||
@@ -108,6 +108,12 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
requestBody);
|
||||
}
|
||||
JsonNode root = parseJson(rawBody);
|
||||
if (isV3SourceMessageIdentityMissing(root)) {
|
||||
throw infrastructureInputError();
|
||||
}
|
||||
if (isMissingSourceMessageInfrastructureInputError(root)) {
|
||||
throw infrastructureInputError();
|
||||
}
|
||||
if (isInfrastructureInputError(root)) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "INFRASTRUCTURE_INPUT_ERROR", "SuperAgent 返回基础设施输入错误。");
|
||||
}
|
||||
@@ -334,18 +340,62 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
* 校验结构化 S10/S99 的最小形态,避免把联合值或空复核误收为正常通知。
|
||||
*/
|
||||
private void validateV3SourceMessageNotificationShape(JsonNode root, ReservationAiRouteDefinition route) {
|
||||
if (!"main_agent_outcome".equals(textAt(root, "handler_type"))
|
||||
|| !AiResultType.SOURCE_MESSAGE_REVIEW_NOTIFICATION.code().equals(textAt(root, "result_type"))
|
||||
|| !"current".equals(textAt(root, "current_or_history"))) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10/S99 根结构字段无效。");
|
||||
}
|
||||
JsonNode assessment = root.path("agent_assessment");
|
||||
JsonNode notification = root.path("notification");
|
||||
if (!"none".equals(textAt(assessment, "automation_action"))
|
||||
|| !isBooleanTrue(notification.path("required"))
|
||||
|| !"source_message_review".equals(textAt(notification, "notification_type"))
|
||||
|| !isBooleanTrue(notification.path("show_source_message"))
|
||||
|| !isBooleanTrue(notification.path("requires_user_decision"))
|
||||
|| trimToNull(textAt(notification, "visible_message")) == null) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10/S99 通知结构无效。");
|
||||
}
|
||||
JsonNode manualReview = root.get("manual_review");
|
||||
if (route == ReservationAiRouteDefinition.SOURCE_MESSAGE_S10
|
||||
&& manualReview != null
|
||||
&& !manualReview.isNull()) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10 manual_review 必须为空。");
|
||||
if (route == ReservationAiRouteDefinition.SOURCE_MESSAGE_S10) {
|
||||
if (!"no_booking_action_detected".equals(textAt(assessment, "status"))
|
||||
|| !"no_booking_action_detected".equals(textAt(assessment, "reason_code"))
|
||||
|| (manualReview != null && !manualReview.isNull())) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10 结构不符合 P0 契约。");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (route == ReservationAiRouteDefinition.SOURCE_MESSAGE_S99
|
||||
&& (manualReview == null || manualReview.isNull() || !manualReview.isObject())) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S99 manual_review 必须为入口复核对象。");
|
||||
if (!"material_package_unavailable".equals(textAt(assessment, "status"))
|
||||
|| !"material_package_unavailable".equals(textAt(assessment, "reason_code"))
|
||||
|| manualReview == null
|
||||
|| manualReview.isNull()
|
||||
|| !manualReview.isObject()
|
||||
|| inspectV3MainAgentReviewIssue(manualReview) != null
|
||||
|| !"material_package_unavailable".equals(textAt(manualReview, "reason_code"))) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S99 结构不符合 P0 契约。");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 S99 main_agent_entry_review 的 P0 九字段结构。
|
||||
*/
|
||||
private V3EventContractIssue inspectV3MainAgentReviewIssue(JsonNode manualReview) {
|
||||
if (!manualReview.isObject()) {
|
||||
return new V3EventContractIssue("MAIN_AGENT_REVIEW_INVALID", "manual_review 必须是对象。");
|
||||
}
|
||||
if (!"main_agent_entry_review".equals(textAt(manualReview, "review_record_type"))
|
||||
|| trimToNull(textAt(manualReview, "reason_code")) == null
|
||||
|| trimToNull(textAt(manualReview, "visible_reason")) == null
|
||||
|| !manualReview.path("known_fields").isObject()
|
||||
|| !manualReview.path("missing_fields").isArray()
|
||||
|| !manualReview.path("blocking_points").isArray()
|
||||
|| !manualReview.path("conflicting_points").isArray()
|
||||
|| !manualReview.path("suggested_human_actions").isArray()
|
||||
|| !manualReview.path("evidence_to_check").isArray()) {
|
||||
return new V3EventContractIssue("MAIN_AGENT_REVIEW_INCOMPLETE", "manual_review 九字段不完整。");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 V3 业务 event 转为旧任务结果 item 形态,复用现有订单、任务和任务卡创建逻辑。
|
||||
*/
|
||||
@@ -1320,6 +1370,13 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
return node.get(fieldName).asBoolean(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 JSON 节点是否为布尔 true。S10/S99 P0 契约不接受字符串 true 这类宽松输入。
|
||||
*/
|
||||
private boolean isBooleanTrue(JsonNode node) {
|
||||
return node != null && node.isBoolean() && node.booleanValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 节点序列化为字符串,缺失时返回 null。
|
||||
*/
|
||||
@@ -1400,6 +1457,42 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
return "infrastructure_input_error".equals(textAt(root, "result_type"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 P0 明确约定的缺失 source_message_id 基础设施错误;其他 infra 错误不得被改写成该错误。
|
||||
*/
|
||||
private boolean isMissingSourceMessageInfrastructureInputError(JsonNode root) {
|
||||
return isInfrastructureInputError(root)
|
||||
&& "missing_source_message_id".equals(textAt(root, "error_code"));
|
||||
}
|
||||
|
||||
/**
|
||||
* V3 或入口网关形态输入必须先有非空 source_message_id;纯 V2 兼容结构不受该预检查影响。
|
||||
*/
|
||||
private boolean isV3SourceMessageIdentityMissing(JsonNode root) {
|
||||
if (root == null || !root.has("source_message") || !isV3OrGatewayInputShape(root)) {
|
||||
return false;
|
||||
}
|
||||
return trimToNull(textAt(root.path("source_message"), "source_message_id")) == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 0711 P0 基础设施输入错误,ControllerAdvice 会转换为扁平 typed 响应。
|
||||
*/
|
||||
private ReservationAiTaskIntakeException infrastructureInputError() {
|
||||
return error(HttpStatus.BAD_REQUEST, "MISSING_SOURCE_MESSAGE_ID", "source_message.source_message_id 缺失。");
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断输入是否属于 V3 回调或 0711 P0 入口网关形态,避免误伤旧 V2 兼容 JSON。
|
||||
*/
|
||||
private boolean isV3OrGatewayInputShape(JsonNode root) {
|
||||
return root.path("message_events").isArray()
|
||||
|| root.path("unhandled_current_intents").isArray()
|
||||
|| root.path("candidate_events").isArray()
|
||||
|| root.has("body_current")
|
||||
|| isV3SourceMessageNotification(root);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 V3 结构化 S10/S99 来源邮件通知根。
|
||||
*/
|
||||
@@ -1426,7 +1519,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
JsonNode sourceMessageNode = root.path("source_message");
|
||||
String externalSourceMessageId = trimToNull(textAt(sourceMessageNode, "source_message_id"));
|
||||
if (externalSourceMessageId == null) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "INFRASTRUCTURE_INPUT_ERROR", "source_message.source_message_id 缺失。");
|
||||
throw infrastructureInputError();
|
||||
}
|
||||
validateLength(externalSourceMessageId, "source_message.source_message_id", LENGTH_256);
|
||||
String hotelId = requireText(defaultHotelId, "default_hotel_id", LENGTH_64);
|
||||
|
||||
@@ -898,9 +898,23 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
if (!collapsed.equals(candidates.get(0))) {
|
||||
candidates.add(collapsed);
|
||||
}
|
||||
String legacyAlias = p0ReviewPointerLegacyAlias(collapsed);
|
||||
if (legacyAlias != null && !candidates.contains(legacyAlias)) {
|
||||
candidates.add(legacyAlias);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容 0711 P0 fixtures 中已迁移为数组结构、但当前后端矩阵仍是扁平字段的复核 pointer。
|
||||
*/
|
||||
private String p0ReviewPointerLegacyAlias(String collapsedFieldPath) {
|
||||
if ("extracted_fields.room_items[].pms_room_type_code".equals(collapsedFieldPath)) {
|
||||
return "extracted_fields.pms_room_type_code";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JSON Pointer token,拒绝非法的 ~ 转义。
|
||||
*/
|
||||
@@ -1717,7 +1731,33 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
if (editedValues.containsKey(fieldPath)) {
|
||||
return editedValues.get(fieldPath);
|
||||
}
|
||||
return valueAt(aiPayload, fieldPath);
|
||||
Object value = valueAt(aiPayload, fieldPath);
|
||||
if (value != null) {
|
||||
return value;
|
||||
}
|
||||
for (String legacyPath : p0LegacyValuePathCandidates(fieldPath)) {
|
||||
Object legacyValue = valueAt(aiPayload, legacyPath);
|
||||
if (legacyValue != null) {
|
||||
return legacyValue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 0711 P0 已将房型字段迁移到 room_items[];当前矩阵仍使用扁平字段,读取时做最小别名兼容。
|
||||
*/
|
||||
private List<String> p0LegacyValuePathCandidates(String fieldPath) {
|
||||
String safeFieldPath = fieldPath == null ? "" : fieldPath;
|
||||
return switch (safeFieldPath) {
|
||||
case "extracted_fields.room_type" -> List.of(
|
||||
"extracted_fields.room_items.0.room_type_normalized",
|
||||
"extracted_fields.room_items.0.room_type_raw");
|
||||
case "extracted_fields.room_quantity" -> List.of("extracted_fields.room_items.0.room_quantity");
|
||||
case "extracted_fields.pms_room_type_code" -> List.of(
|
||||
"extracted_fields.room_items.0.pms_room_type_code");
|
||||
default -> List.of();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1880,7 +1920,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
definition.operaWriteParticipation(),
|
||||
definition.operaParameterMapping(),
|
||||
definition.notes(),
|
||||
valueAt(aiPayload, definition.fieldPath()));
|
||||
valueForField(aiPayload, Map.of(), definition.fieldPath()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1904,7 +1944,9 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
JsonNode current = root;
|
||||
for (String rawPart : fieldPath.split("\\.")) {
|
||||
String part = rawPart.replace("[]", "");
|
||||
current = current.path(part);
|
||||
current = current.isArray() && isNumericToken(part)
|
||||
? current.path(Integer.parseInt(part))
|
||||
: current.path(part);
|
||||
if (current.isMissingNode() || current.isNull()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user