修复 M002 V3 P0 回归校验问题

This commit is contained in:
andy
2026-07-11 23:18:32 +08:00
parent 3ae3fea6bc
commit fd027f124c
7 changed files with 740 additions and 70 deletions

View File

@@ -850,12 +850,19 @@ class SuperAgentTaskResultControllerTest {
"message_events": [
{
"event_type": "New Booking",
"event_role": "travel_agent_request",
"source_event_index": "E1",
"current_or_history": "current",
"case_keys": {
"group_code": null,
"confirmation_number": "CNF-V3-NEW-001"
"confirmation_number": "CNF-V3-NEW-001",
"reservation_number": null,
"block_code": null
},
"relevant_message_excerpt": "Please create a new FIT reservation.",
"attachments": [],
"file_references": [],
"context_used": {},
"extracted_fields": {
"booking_object_type": "FIT Reservation",
"arrival_date": "2026-09-01"
@@ -942,12 +949,19 @@ class SuperAgentTaskResultControllerTest {
"message_events": [
{
"event_type": "Trace",
"event_role": "travel_agent_request",
"source_event_index": "E2",
"current_or_history": "current",
"case_keys": {
"group_code": "GRP-V3-ERR-001",
"confirmation_number": null
"confirmation_number": null,
"reservation_number": null,
"block_code": null
},
"relevant_message_excerpt": "Please add a trace note.",
"attachments": [],
"file_references": [],
"context_used": {},
"extracted_fields": {},
"manual_review": null
}
@@ -1000,12 +1014,19 @@ class SuperAgentTaskResultControllerTest {
"message_events": [
{
"event_type": "%s",
"event_role": "travel_agent_request",
"source_event_index": "E1",
"current_or_history": "current",
"case_keys": {
"group_code": "%s",
"confirmation_number": null
"confirmation_number": null,
"reservation_number": null,
"block_code": null
},
"relevant_message_excerpt": "Unsupported event with explicit contract error.",
"attachments": [],
"file_references": [],
"context_used": {},
"extracted_fields": {},
"contract_errors": [
{
@@ -1017,12 +1038,19 @@ class SuperAgentTaskResultControllerTest {
},
{
"event_type": "New Booking",
"event_role": "travel_agent_request",
"source_event_index": "E2",
"current_or_history": "current",
"case_keys": {
"group_code": null,
"confirmation_number": "CNF-V3-SIBLING-001"
"confirmation_number": "CNF-V3-SIBLING-001",
"reservation_number": null,
"block_code": null
},
"relevant_message_excerpt": "Please create sibling FIT reservation.",
"attachments": [],
"file_references": [],
"context_used": {},
"extracted_fields": {
"booking_object_type": "FIT Reservation"
},
@@ -1073,12 +1101,19 @@ class SuperAgentTaskResultControllerTest {
"message_events": [
{
"event_type": "Trace",
"event_role": "travel_agent_request",
"source_event_index": "E1",
"current_or_history": "current",
"case_keys": {
"group_code": "GRP-V3-FRONTEND-BLOCK-001",
"confirmation_number": null
"confirmation_number": null,
"reservation_number": null,
"block_code": null
},
"relevant_message_excerpt": "Trace event has explicit contract error.",
"attachments": [],
"file_references": [],
"context_used": {},
"extracted_fields": {},
"contract_errors": [
{
@@ -1090,12 +1125,19 @@ class SuperAgentTaskResultControllerTest {
},
{
"event_type": "New Booking",
"event_role": "travel_agent_request",
"source_event_index": "E2",
"current_or_history": "current",
"case_keys": {
"group_code": null,
"confirmation_number": "CNF-V3-FRONTEND-BLOCK-001"
"confirmation_number": "CNF-V3-FRONTEND-BLOCK-001",
"reservation_number": null,
"block_code": null
},
"relevant_message_excerpt": "Please create frontend-visible FIT reservation.",
"attachments": [],
"file_references": [],
"context_used": {},
"extracted_fields": {
"booking_object_type": "FIT Reservation"
},
@@ -1185,12 +1227,19 @@ class SuperAgentTaskResultControllerTest {
"message_events": [
{
"event_type": "New Booking",
"event_role": "travel_agent_request",
"source_event_index": "E1",
"current_or_history": "current",
"case_keys": {
"group_code": null,
"confirmation_number": "CNF-V3-MR-001"
"confirmation_number": "CNF-V3-MR-001",
"reservation_number": null,
"block_code": null
},
"relevant_message_excerpt": "Manual review payload is incomplete.",
"attachments": [],
"file_references": [],
"context_used": {},
"extracted_fields": {
"booking_object_type": "FIT Reservation"
},
@@ -2456,18 +2505,26 @@ class SuperAgentTaskResultControllerTest {
"message_events": [
{
"event_type": "New Booking",
"event_role": "travel_agent_request",
"source_event_index": "E1",
"current_or_history": "current",
"case_keys": {
"group_code": null,
"confirmation_number": "%s"
"confirmation_number": "%s",
"reservation_number": null,
"block_code": null
},
"relevant_message_excerpt": "Please create a new FIT reservation with manual room type review.",
"attachments": [],
"file_references": [],
"context_used": {},
"extracted_fields": {
"booking_object_type": "FIT Reservation",
"arrival_date": "%s",
"departure_date": "%s",
"room_quantity": 2,
"room_type": "Deluxe King"
"room_type": "Deluxe King",
"pms_room_type_code": null
},
"manual_review": {
"review_record_type": "business_event_review",

View File

@@ -158,6 +158,24 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
.andExpect(jsonPath("$.result_type").doesNotExist());
}
@Test
void shouldReturnTypedInfrastructureInputErrorWhenV3ShapeMissesSourceMessageObject() throws Exception {
mockMvc.perform(signedPost("""
{
"body_current": "Please cancel group HD260710A",
"message_events": [],
"case_candidates": [],
"extraction_warnings": [],
"unhandled_current_intents": []
}
""", "nonce-p0-missing-source-object-001"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.result_type").value("infrastructure_input_error"))
.andExpect(jsonPath("$.error_code").value("missing_source_message_id"))
.andExpect(jsonPath("$.retryable").value(true))
.andExpect(jsonPath("$.missing_fields[0]").value("source_message.source_message_id"));
}
@Test
void shouldLetNonBlankSourceIdentityPassP0GuardWithoutTypedMissingError() throws Exception {
JsonNode validInput = caseItem(
@@ -197,9 +215,57 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
.isEqualTo("Need Manual Review");
}
@Test
void shouldRejectS99WhenMainAgentReviewHasUnexpectedField() throws Exception {
JsonNode s99 = caseValue(fixture("main_outcomes.json").path("cases"), "legal_s99_material_unavailable");
ObjectNode copy = s99.deepCopy();
((ObjectNode) copy.path("manual_review")).put("unexpected_field", "not-allowed");
String externalId = "p0-s99-unexpected-review-field-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
mockMvc.perform(signedPost(withSourceMessageId(copy, externalId), "nonce-p0-s99-extra-field-001"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("ADAPTER_CONTRACT_ERROR"));
Long taskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(taskCount).isZero();
}
@Test
void shouldRejectV3BusinessRootWhenP0RootArraysAreMissing() throws Exception {
ObjectNode root = objectMapper.createObjectNode();
ObjectNode sourceMessage = root.putObject("source_message");
String externalId = "p0-root-arrays-missing-001";
sourceMessage.put("source_message_id", externalId);
sourceMessage.put("subject", "P0 fixture");
sourceMessage.put("from", "agent@example.test");
sourceMessage.putArray("cc");
sourceMessage.put("received_at", "2026-07-11T10:00:00+08:00");
sourceMessage.put("source_channel", "Email");
root.putArray("message_events")
.add(fixture("row_multiple_derived.json").path("message_events").get(0));
SourceMessageCaptureResult source = captureSourceMessage(externalId);
mockMvc.perform(signedPost(objectMapper.writeValueAsString(root), "nonce-p0-root-arrays-missing-001"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("ADAPTER_CONTRACT_ERROR"));
Long taskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(taskCount).isZero();
}
@Test
void shouldCreateParentSplitTasksFromP0FixtureInEventOrder() throws Exception {
ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
useParentSplitBusinessKeys(root, "PARENT-2608-OK", "CHILD-2608-OK-A", "CHILD-2608-OK-B");
String externalId = "p0-parent-split-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
@@ -234,6 +300,33 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
assertThat(transitionCount).isEqualTo(3L);
}
@Test
void shouldFailClosedWhenParentSplitReferencesDoNotMatchChildEvents() throws Exception {
ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
useParentSplitBusinessKeys(root, "PARENT-2608-MIS", "CHILD-2608-MIS-A", "CHILD-2608-MIS-B");
ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2);
((ArrayNode) parentEvent.path("related_source_event_indices"))
.set(1, objectMapper.getNodeFactory().textNode("E_UNKNOWN_CHILD"));
String externalId = "p0-parent-split-mismatch-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-parent-split-mismatch-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(3))
.andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
.andExpect(jsonPath("$.items[1].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
.andExpect(jsonPath("$.items[2].adapter_error_code")
.value("LINKED_PARENT_RELEASE_CONTRACT_INCOMPLETE"))
.andExpect(jsonPath("$.items[2].task_id").doesNotExist());
Long taskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(taskCount).isEqualTo(2L);
}
@Test
void shouldCreateRowDerivedMainParentAndTraceTasksFromP0Fixture() throws Exception {
ObjectNode root = fixture("row_multiple_derived.json").deepCopy();
@@ -255,6 +348,27 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
assertThat(taskCount).isEqualTo(3L);
}
@Test
void shouldFailClosedWhenV3BusinessEventMissesRequiredField() throws Exception {
ObjectNode event = fixture("row_multiple_derived.json").path("message_events").get(0).deepCopy();
event.remove("attachments");
String externalId = "p0-event-required-field-missing-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
mockMvc.perform(signedPost(businessRoot(externalId, event), "nonce-p0-event-required-missing-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(1))
.andExpect(jsonPath("$.items[0].adapter_error_code").value("EVENT_CONTRACT_INCOMPLETE"))
.andExpect(jsonPath("$.items[0].task_id").doesNotExist());
Long taskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(taskCount).isZero();
}
@Test
void shouldPreserveMixedAllotmentUnhandledIntentAndRejectPartialAllotmentAsBusinessTask() throws Exception {
JsonNode fixture = fixture("allotment_scope.json");
@@ -286,6 +400,7 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
void shouldCreateTypeKnownManualReviewAndResolveSameCardFromP0Fixture() throws Exception {
JsonNode manualReview = fixture("manual_review_resolution.json");
ObjectNode event = manualReview.path("known_subtype_manual_review").path("event").deepCopy();
useManualReviewGroupCode(event, "CHILD-SUITE-RESOLVE-001");
String externalId = "p0-manual-review-same-card-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
String body = businessRoot(externalId, event);
@@ -347,6 +462,69 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
assertThat(aiPayloadJson).doesNotContain("\"pms_room_type_code\":\"SU1\"");
}
@Test
void shouldFailClosedWhenManualReviewMissingFieldPointerDoesNotResolve() throws Exception {
ObjectNode event = fixture("manual_review_resolution.json")
.path("known_subtype_manual_review")
.path("event")
.deepCopy();
((ArrayNode) event.path("manual_review").path("missing_fields"))
.set(0, objectMapper.getNodeFactory()
.textNode("/extracted_fields/room_items/99/pms_room_type_code"));
String externalId = "p0-manual-review-invalid-pointer-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
mockMvc.perform(signedPost(businessRoot(externalId, event), "nonce-p0-manual-review-invalid-pointer-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(1))
.andExpect(jsonPath("$.items[0].adapter_error_code")
.value("MANUAL_REVIEW_MISSING_FIELD_POINTER_INVALID"))
.andExpect(jsonPath("$.items[0].task_id").doesNotExist());
Long taskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(taskCount).isZero();
}
@Test
void shouldRejectOutOfRangeRoomItemPointerWhenResolvingManualReview() throws Exception {
JsonNode manualReview = fixture("manual_review_resolution.json");
ObjectNode event = manualReview.path("known_subtype_manual_review").path("event").deepCopy();
useManualReviewGroupCode(event, "CHILD-SUITE-POINTER-001");
String externalId = "p0-manual-review-resolution-pointer-001";
captureSourceMessage(externalId);
MvcResult result = mockMvc.perform(signedPost(
businessRoot(externalId, event),
"nonce-p0-manual-review-resolution-pointer-create-001"))
.andExpect(status().isCreated())
.andReturn();
String taskId = com.jayway.jsonpath.JsonPath.read(
result.getResponse().getContentAsString(),
"$.items[0].task_id");
String orderId = com.jayway.jsonpath.JsonPath.read(
result.getResponse().getContentAsString(),
"$.items[0].order_id");
mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"confirmed_order_id": "%s",
"field_overrides": [
{
"field_pointer": "/extracted_fields/room_items/99/pms_room_type_code",
"value": "SU1"
}
]
}
""".formatted(orderId)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("TASK_REVIEW_POINTER_INVALID"));
}
private JsonNode fixture(String fileName) throws Exception {
return objectMapper.readTree(FIXTURES_DIR.resolve(fileName).toFile());
}
@@ -397,6 +575,36 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
return objectMapper.writeValueAsString(root);
}
private void useParentSplitBusinessKeys(
ObjectNode root,
String parentGroupCode,
String childGroupCodeOne,
String childGroupCodeTwo) {
ArrayNode events = (ArrayNode) root.path("message_events");
useEventGroupCode((ObjectNode) events.get(0), childGroupCodeOne);
useEventGroupCode((ObjectNode) events.get(1), childGroupCodeTwo);
((ObjectNode) events.get(0).path("extracted_fields")).put("parent_group_code", parentGroupCode);
((ObjectNode) events.get(1).path("extracted_fields")).put("parent_group_code", parentGroupCode);
ObjectNode parentEvent = (ObjectNode) events.get(2);
useEventGroupCode(parentEvent, parentGroupCode);
ObjectNode parentExtractedFields = (ObjectNode) parentEvent.path("extracted_fields");
parentExtractedFields.put("parent_group_code", parentGroupCode);
ArrayNode childGroupCodes = (ArrayNode) parentExtractedFields.path("child_group_codes");
childGroupCodes.removeAll();
childGroupCodes.add(childGroupCodeOne);
childGroupCodes.add(childGroupCodeTwo);
}
private void useManualReviewGroupCode(ObjectNode event, String groupCode) {
useEventGroupCode(event, groupCode);
((ObjectNode) event.path("manual_review").path("known_fields")).put("group_code", groupCode);
}
private void useEventGroupCode(ObjectNode event, String groupCode) {
((ObjectNode) event.path("case_keys")).put("group_code", groupCode);
}
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
return captureService.capture(new CaptureSourceMessageCommand(
"HOTEL-TEST",