diff --git a/docs/import/20260712/MCP_最终提交稳定性改造要求_给开发伙伴_2026-07-12.md b/docs/import/20260712/MCP_最终提交稳定性改造要求_给开发伙伴_2026-07-12.md new file mode 100644 index 0000000..c2b933f --- /dev/null +++ b/docs/import/20260712/MCP_最终提交稳定性改造要求_给开发伙伴_2026-07-12.md @@ -0,0 +1,159 @@ +# MCP 最终提交稳定性改造要求 + +## 一、改造目标 + +在不改变现有业务规则和业务系统输出规范的前提下,确保 Agent 生成的业务结果可以稳定转换为 MCP 可接收的 payload。 + +我们不要求 Agent 自行理解或猜测 MCP transport 字段。`source_event_index`、`ai_task_results[]` 等字段的含义、类型和映射,必须由 MCP/Adapter 侧提供确定性实现。 + +目标链路必须是: + +```text +booking-desk-event Skill + → 业务结果 + → Result Adapter + → MCP payload + → Schema Validator + → 冻结 + → MCP 提交一次 +``` + +## 二、必须保持不变的内容 + +MCP 侧不得通过修改以下内容来解决字段接收问题: + +- 现有公开业务 JSON 根结构; +- `message_events` 的业务事件类型和 subtype; +- Parent Group / Child Group 业务语义; +- Trace、Parent、linked/derived 事件关系; +- S10、S99 和基础设施错误规范; +- Skill 的业务裁决权; +- 一次冻结、一次提交、失败不重试的生命周期。 + +MCP 只负责接收和处理已经形成的业务结果,不负责重新解释业务含义。 + +## 三、必须由 MCP/Adapter 侧承担的职责 + +### 1. 提供确定性的 payload mapping + +必须建立固定的: + +```text +BusinessResult → MCP payload +``` + +映射层。 + +该层不能由 LLM 临时生成,也不能让 Agent 根据字段名称猜测。 + +至少需要明确: + +| 字段 | 必须明确的内容 | +|---|---| +| `source_message_id` | 来源、类型、必填规则、透传方式 | +| `source_event_index` | 真实含义、类型、索引基准、对应数组 | +| `related_source_event_index` | 单事件关系如何映射 | +| `related_source_event_indices` | 多事件关系如何映射、顺序和唯一性 | +| `ai_task_results[]` | item 的完整结构和业务结果映射方式 | +| `extraction_warnings` | 是否必填、允许的结构和空值规则 | + +`E1`、`E2` 只能作为 Agent/业务结果中的内部事件标识,不能默认当作 MCP 的 `source_event_index`。 + +### 2. 负责生成 `source_event_index` + +Adapter 必须建立事件映射,例如: + +```text +E_CHILD_1 → MCP index 0 +E_CHILD_2 → MCP index 1 +E_PARENT → MCP index 2 +E_TRACE → MCP index 3 +``` + +实际索引类型和起始值必须以真实 MCP schema 为准,不允许 Agent 猜测。 + +对于: + +```json +"related_source_event_indices": ["E_CHILD_1", "E_CHILD_2"] +``` + +Adapter 必须将其转换成 MCP 真实要求的索引格式,并保持顺序、唯一性和关联正确。 + +### 3. 支持跨事件 Trace + +当前业务要求: + +- 一个补充请求覆盖完整 Parent split 的全部 Child; +- Trace 必须关联全部 Child; +- 不得绑定到第一个 Child E1; +- 必须保留完整跨事件关系。 + +因此 MCP/Adapter 必须能够接收和保持: + +```text +Trace + → related_source_event_indices[] + → 全部 Child New Booking +``` + +不能把多目标 Trace 压缩成单一 `source_event_index`。 + +## 四、提交前必须有 Schema Validator + +MCP 调用前必须校验: + +- 必填字段; +- 字段类型; +- 允许值; +- 数组顺序; +- 索引是否存在; +- 关系是否悬空; +- 是否存在重复索引; +- 是否存在未知字段; +- `ai_task_results[]` item 是否符合完整 schema; +- `source_message_id` 是否正确传播。 + +校验失败时: + +- 不让 Agent 猜字段; +- 不修改已经冻结的业务结果; +- 不重新调用 Skill; +- 不重试 MCP; +- 按既有 transport/基础设施错误通道结束; +- 保留原始错误和 mapping 诊断。 + +## 五、必须提供的 MCP 交付物 + +请提供以下内容: + +1. 当前实际使用的完整 MCP tool schema; +2. `ai_task_results[]` 的完整 item schema; +3. `source_event_index` 和关联索引的正式定义; +4. BusinessResult → MCP payload mapping 文档; +5. 一个成功的完整 payload 示例; +6. 一个跨 Child Trace 的成功示例; +7. 一个错误字段被拒绝的示例; +8. Adapter 或 MCP wrapper 的实现位置; +9. Schema validation 和一次提交测试结果; +10. 失败不重试、payload 不被修改的测试证据。 + +## 六、验收条件 + +以下测试必须通过: + +1. Agent 使用 `E1` 时,Adapter 不会盲目把它当成 MCP index; +2. 错误关系索引会在提交前被拒绝; +3. 缺失 `source_message_id` 会按既有基础设施错误处理; +4. MCP 失败时只提交一次,不重试; +5. 提交 payload 与冻结结果的 mapping 可追溯; +6. MCP 响应不会污染业务 JSON。 + + +## 最终要求 + +请不要通过继续增加 Prompt 文字,让 Agent 学习 MCP 内部字段含义来解决问题。 + +我们要求的是: + +> MCP/Adapter 提供稳定、确定性、可验证的业务结果到 MCP payload 的转换能力;Agent 负责业务结果,MCP 负责 transport 接收,两者之间不能依赖模型猜测。 diff --git a/docs/project/integrations/superagent-api-contract.md b/docs/project/integrations/superagent-api-contract.md index a9c8258..eaf74cf 100644 --- a/docs/project/integrations/superagent-api-contract.md +++ b/docs/project/integrations/superagent-api-contract.md @@ -561,6 +561,7 @@ V3 字段说明: 当前已支持的 V3 行为: +- 如果通过 MCP `th_hotel_submit_task_results` 调用,MCP adapter 会在进入业务入站服务前按 `message_events[]` 顺序把 Agent 内部事件 ID 映射为本系统一基 `source_event_index`,并校验 `related_source_event_index`、`parent_source_event_index` 和 `related_source_event_indices[]` 是否悬空或重复。 - 40 条 P0.1 路由进入后端枚举 / 稳定配置。 - `route_code` 是稳定代码,不因路由总数从 42 调整为 40 而重编号;联调方不要按数字连续性判断合法性。 - 结构化 `S10/S99` 创建只读 `SOURCE_MESSAGE_ONLY` 任务,任务列表可见,订单列表不可见。 diff --git a/docs/project/integrations/superagent-mcp/README.md b/docs/project/integrations/superagent-mcp/README.md index 27fa84b..579547d 100644 --- a/docs/project/integrations/superagent-mcp/README.md +++ b/docs/project/integrations/superagent-mcp/README.md @@ -23,6 +23,7 @@ server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/ | --- | --- | | `integration-guide.md` | MCP 总体接入说明和调用顺序 | | `tools.md` | 5 个 MCP tools 的工具契约 | +| `submit-payload-mapping.md` | `th_hotel_submit_task_results` 的 V3/P0.1 payload adapter、事件索引映射和提交前校验 | | `security-policy.md` | 鉴权、权限、正文、附件和日志边界 | | `deployment-guide.md` | 部署参数、环境变量和上线顺序 | | `test-cases.md` | SuperAgent 联调测试用例 | diff --git a/docs/project/integrations/superagent-mcp/integration-guide.md b/docs/project/integrations/superagent-mcp/integration-guide.md index dd1a541..920c884 100644 --- a/docs/project/integrations/superagent-mcp/integration-guide.md +++ b/docs/project/integrations/superagent-mcp/integration-guide.md @@ -132,6 +132,8 @@ REST request 写入工具里的 `source_message_id` 必须来自 AgentBus payload 的 `source.external_message_id`。SuperAgent 不需要传数据库层 provider/channel;TH Hotel 后端按系统酒店和外部消息 ID 匹配唯一 SourceMessage Inbox,真实 channel 可能是 `OUTLOOK`。 +M002 V3 后,写入工具优先接收结构化 `S10/S99` 或 `source_message + message_events[]` 业务根。`message_events[].source_event_index` 可以是 SuperAgent 内部事件 ID,例如 `E_CHILD_1`;MCP adapter 会在提交前按数组顺序映射为本系统一基数字索引,并校验跨事件关系是否悬空或重复。详细映射规则见 `submit-payload-mapping.md`。 + ## 9. 当前 checkpoint 当前 checkpoint: @@ -146,5 +148,6 @@ checkpoint-superagent-mcp-embedded-endpoint - 5 个 MCP tools 均可通过 `tools/list` 发现。 - 只读 tool 直接复用现有查询 Service。 - 写入 tool 受 `MCP_ENABLE_SUBMIT_TASK_RESULTS` 开关控制。 +- 写入 tool 已提供 V3/P0.1 submit payload adapter 和提交前 schema validator。 - MCP endpoint 使用 Bearer Token 鉴权。 - 文档不包含生产 Secret、真实客户数据、附件 URL 或原始邮件正文。 diff --git a/docs/project/integrations/superagent-mcp/security-policy.md b/docs/project/integrations/superagent-mcp/security-policy.md index f4a7ac1..59e5894 100644 --- a/docs/project/integrations/superagent-mcp/security-policy.md +++ b/docs/project/integrations/superagent-mcp/security-policy.md @@ -129,6 +129,7 @@ MCP endpoint 日志允许记录: 写入工具: - 默认不自动重试已发送的写请求。 +- MCP submit payload adapter 校验失败时,不调用业务写入 Service,也不触发自动重试。 - 如果调用方需要重试,必须依赖后端幂等机制和任务结果中的幂等信息。 - 对于响应丢失但请求可能已到达后端的场景,应优先查询已有任务或人工排查,避免重复写入。 diff --git a/docs/project/integrations/superagent-mcp/submit-payload-mapping.md b/docs/project/integrations/superagent-mcp/submit-payload-mapping.md new file mode 100644 index 0000000..aed1fb9 --- /dev/null +++ b/docs/project/integrations/superagent-mcp/submit-payload-mapping.md @@ -0,0 +1,308 @@ +# TH Hotel MCP Submit Payload Mapping + +## 1. 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 0.1 | +| 日期 | 2026-07-12 | +| 状态 | 已落地第一版 | +| 适用范围 | `th_hotel_submit_task_results` 的 V3/P0.1 payload adapter、schema validator 和一次提交规则 | + +## 2. 核心原则 + +`th_hotel_submit_task_results` 接收的是 SuperAgent 已经冻结的业务结果。MCP 层只负责把业务结果转换为本系统可接收的提交 payload,不重新解释业务含义,不调用 Skill,不根据字段名猜测任务类型。 + +目标链路: + +```text +SuperAgent 业务结果 + -> MCP Submit Payload Adapter + -> MCP Submit Schema Validator + -> ReservationAiTaskIntakeService + -> MCP tool result +``` + +实现位置: + +```text +server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/SuperAgentMcpSubmitPayloadAdapter.java +server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpSubmitPayloadAdapterImpl.java +``` + +## 3. 支持的输入形态 + +| 形态 | 判断方式 | 处理方式 | +| --- | --- | --- | +| V3 业务根 | 同时包含 `source_message` 和 `message_events` | 先校验根结构、source message、event 必填字段和关系索引,再按 `message_events[]` 顺序映射 `source_event_index` | +| V3 S10/S99 入口通知 | 同时包含 `source_message` 和 `route_code` | 校验 S10/S99 根字段和 source message 后透传给业务入站服务 | +| V2 兼容任务结果 | 包含 `ai_task_results` | 保留旧 `source_message_id + ai_task_results[]` 兼容路径 | + +未知根字段会在 MCP 层被拒绝,错误码为 `MCP_SUBMIT_PAYLOAD_INVALID`。 + +`source_message_id` 缺失属于既有基础设施输入错误,MCP adapter 不改写该错误通道;业务入站服务会返回 `MISSING_SOURCE_MESSAGE_ID`。 + +## 4. source_message_id 定义 + +| 字段 | 规则 | +| --- | --- | +| `source_message.source_message_id` | V3 必填;必须来自 AgentBus 原始 payload 的外部消息 ID,对应 `platform_source_message_inbox.external_message_id` | +| `source_message_id` | V2 兼容必填;同样对应 `platform_source_message_inbox.external_message_id` | +| `hotel_id` | SuperAgent 正式提交不需要传;本系统按平台唯一 ACTIVE 酒店解析 | + +`source_message_id` 不是本系统数据库主键,也不是 `platform_source_message_inbox.id`。 + +## 5. source_event_index 映射 + +SuperAgent 业务结果中的 `E1`、`E_CHILD_1`、`E_PARENT` 等值只是 Agent 内部事件 ID,不能直接当成本系统的 `source_event_index`。 + +MCP adapter 按 `message_events[]` 数组顺序生成一基数字索引: + +| 业务事件 ID | 数组位置 | 本系统 `source_event_index` | +| --- | --- | --- | +| `E_CHILD_1` | 第 1 个 event | `1` | +| `E_CHILD_2` | 第 2 个 event | `2` | +| `E_PARENT` | 第 3 个 event | `3` | +| `E_TRACE` | 第 4 个 event | `4` | + +映射规则: + +- `message_events[].source_event_index` 输出为数字。 +- `related_source_event_index` 输出为字符串数字,例如 `"1"`。 +- `parent_source_event_index` 输出为数字。 +- `related_source_event_indices[]` 输出为字符串数字数组,保持原顺序。 +- 悬空引用、重复引用、缺失事件 ID 都在 MCP 层拒绝。 + +## 6. ai_task_results[] 兼容 item schema + +V2 兼容路径仍保留,`ai_task_results[]` item 以 REST 总契约为准,第一版 MCP schema 只暴露 object,但业务入站会校验核心字段。 + +建议 item 结构: + +```json +{ + "source_event_index": 1, + "catalog_code": "S01", + "skill_id": "S01_new_booking_skill", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_fit_reservation", + "current_or_history": "current", + "case_keys": { + "group_code": null, + "confirmation_number": null, + "reservation_number": null, + "block_code": null + }, + "relevant_message_excerpt": "Please create a new booking.", + "attachments": [], + "file_references": [], + "context_used": {}, + "extracted_fields": {}, + "manual_review": null, + "additional_operations": [], + "idempotency_key": null +} +``` + +新数据优先使用 V3 业务根或 V3 S10/S99,不建议继续新增 V2 `ai_task_results[]`。 + +## 7. extraction_warnings 规则 + +| 形态 | 规则 | +| --- | --- | +| V3 业务根 | `extraction_warnings` 必须存在且为数组,无警告传 `[]` | +| V2 兼容 | `extraction_warnings` 可存在且为数组;缺省由业务层按空数组处理 | + +MCP 层不解释 warning 语义,不因为 warning 自动创建任务。 + +## 8. 成功示例:Parent Split + +提交给 MCP tool 的业务结果可以使用 Agent 内部事件 ID: + +```json +{ + "source_message": { + "source_message_id": "mail-20260712-parent-split-001", + "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": "CHILD-A", + "confirmation_number": null, + "reservation_number": null, + "block_code": null + }, + "relevant_message_excerpt": "Create child group A.", + "attachments": [], + "file_references": [], + "context_used": {}, + "extracted_fields": { + "booking_object_type": "Group Block" + }, + "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": "CHILD-B", + "confirmation_number": null, + "reservation_number": null, + "block_code": null + }, + "relevant_message_excerpt": "Create child group B.", + "attachments": [], + "file_references": [], + "context_used": {}, + "extracted_fields": { + "booking_object_type": "Group Block" + }, + "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": "PARENT", + "confirmation_number": null, + "reservation_number": null, + "block_code": "PARENT" + }, + "relevant_message_excerpt": "Release parent after child split.", + "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": "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": ["CHILD-A", "CHILD-B"] + }, + "manual_review": null + } + ], + "case_candidates": [], + "extraction_warnings": [], + "unhandled_current_intents": [] +} +``` + +MCP adapter 进入业务层前会把父事件关系映射为: + +```json +{ + "source_event_index": 3, + "related_source_event_indices": ["1", "2"] +} +``` + +## 9. 成功示例:跨 Child Trace + +当 Trace 覆盖完整 Parent split 的全部 Child 时,不能只绑定第一个 Child。业务结果应保留全部 Child 引用: + +```json +{ + "event_type": "Trace", + "source_event_index": "E_TRACE", + "related_source_event_indices": ["E_CHILD_1", "E_CHILD_2"], + "extracted_fields": { + "trace_subtype": "general_request" + } +} +``` + +MCP adapter 会把它映射为: + +```json +{ + "source_event_index": 4, + "related_source_event_indices": ["1", "2"] +} +``` + +## 10. 拒绝示例 + +未知字段: + +```json +{ + "source_message": {}, + "message_events": [], + "case_candidates": [], + "extraction_warnings": [], + "unhandled_current_intents": [], + "unexpected_root": true +} +``` + +返回: + +```json +{ + "isError": true, + "structuredContent": { + "success": false, + "error": { + "code": "MCP_SUBMIT_PAYLOAD_INVALID", + "details": { + "field": "unexpected_root" + } + } + } +} +``` + +悬空关系: + +```json +{ + "related_source_event_indices": ["E_CHILD_1", "E_UNKNOWN_CHILD"] +} +``` + +同样返回 `MCP_SUBMIT_PAYLOAD_INVALID`,`details.field=related_source_event_indices`,不会调用业务写入 Service。 + +## 11. 一次提交和失败不重试 + +当前 MCP endpoint 与后端业务 Service 同进程: + +- adapter 校验失败时,不调用 `ReservationAiTaskIntakeService`。 +- adapter 校验通过后,只调用一次 `ReservationAiTaskIntakeService.accept`。 +- MCP endpoint 不做自动重试。 +- 业务结果不会因为 MCP 响应被回写或污染。 + +当前回归测试: + +```text +./mvnw -Dtest=SuperAgentMcpControllerTest,SuperAgentMcpSubmitEnabledControllerTest test +``` + +覆盖内容: + +- `tools/list` 暴露 V3 submit schema。 +- 缺失 `source_message_id` 保持既有 `MISSING_SOURCE_MESSAGE_ID` 错误。 +- 未知根字段在业务层前被拒绝。 +- 悬空 `related_source_event_indices` 在业务层前被拒绝。 +- 重复 `related_source_event_indices` 在业务层前被拒绝。 +- `E_CHILD_1/E_CHILD_2/E_PARENT` 按数组顺序映射为 `1/2/3`。 +- 合法 Parent split 只创建一个 AI batch,并返回 `accepted_count=3`。 diff --git a/docs/project/integrations/superagent-mcp/test-cases.md b/docs/project/integrations/superagent-mcp/test-cases.md index d086101..e12bccb 100644 --- a/docs/project/integrations/superagent-mcp/test-cases.md +++ b/docs/project/integrations/superagent-mcp/test-cases.md @@ -93,12 +93,21 @@ | MCP-T05-007 | hotel id 不一致 | 显式传非系统酒店 `hotel_id` | 返回 `HOTEL_ID_MISMATCH` | | MCP-T05-008 | source message 入库渠道为 OUTLOOK | 不传 `source_channel`,只传外部 `source_message_id` | 后端按真实 Inbox 渠道写入成功 | | MCP-T05-009 | source message 多渠道重复 | 同一酒店存在相同外部 `source_message_id` 的多条 Inbox | 返回 `SOURCE_MESSAGE_AMBIGUOUS` | +| MCP-T05-010 | V3 Parent split 使用 Agent 内部事件 ID | `message_events[].source_event_index=E_CHILD_1/E_CHILD_2/E_PARENT` | MCP adapter 按数组顺序映射为 `1/2/3`,返回 `accepted_count=3` | +| MCP-T05-011 | V3 缺失 source message id | `source_message.source_message_id` 缺失 | 返回既有 `MISSING_SOURCE_MESSAGE_ID` typed infrastructure error | +| MCP-T05-012 | V3 多事件关系悬空 | `related_source_event_indices` 引用不存在的事件 ID | 返回 `MCP_SUBMIT_PAYLOAD_INVALID`,不写 AI batch、订单或任务 | +| MCP-T05-013 | V3 多事件关系重复 | `related_source_event_indices=["E_CHILD_1","E_CHILD_1"]` | 返回 `MCP_SUBMIT_PAYLOAD_INVALID`,不写 AI batch、订单或任务 | +| MCP-T05-014 | V3 根节点未知字段 | 根节点存在 `unexpected_root` | 返回 `MCP_SUBMIT_PAYLOAD_INVALID`,不调用业务写入 Service | +| MCP-T05-015 | V3 跨 Child Trace | Trace event 关联全部 Child event | `related_source_event_indices[]` 保留全量关系并映射为真实索引,不压缩到第一个 Child | 写入验证: - `ai_task_results[]` 顺序不能被 MCP endpoint 改变。 +- V3 `message_events[]` 顺序是 `source_event_index` 的唯一基准,MCP endpoint 不能重排。 +- `E1/E2/E_PARENT` 等 Agent 内部事件 ID 只能由 MCP adapter 转换,不能作为本系统最终 `source_event_index`。 - MCP endpoint 不能在日志输出完整 `extracted_fields` 中的敏感内容。 - 失败响应应保留后端错误码和 message。 +- MCP adapter 校验失败时不进入业务写入 Service,且不自动重试。 ## 9. MCP 鉴权和开关测试 @@ -127,6 +136,7 @@ - 5 个工具正常成功调用。 - 查询接口错误 envelope 不丢失。 - 任务结果写入成功和幂等重放正常。 +- V3 submit payload adapter 的事件索引映射、悬空关系拒绝、重复关系拒绝和未知字段拒绝正常。 - provider/channel 隔离正常。 - 受控正文不返回附件 URL。 - MCP auth 失败不进入业务 Service。 diff --git a/docs/project/integrations/superagent-mcp/tools.md b/docs/project/integrations/superagent-mcp/tools.md index 177c5e6..eb6ef03 100644 --- a/docs/project/integrations/superagent-mcp/tools.md +++ b/docs/project/integrations/superagent-mcp/tools.md @@ -347,7 +347,13 @@ POST /api/ai-query/v1/message-conversation/messages ### 7.4 输入 Schema -迁移提醒:当前 MCP tool 仍对应 M002 V2 的 `ai_task_results[]` 阶段契约。M002 V3 已确认迁移到结构化 `S10/S99` 和业务根 `message_events[]`,后续 MCP tool schema 必须跟随 `docs/project/integrations/superagent-api-contract.md` 和 `docs/project/requirements/M002-order-task-workflow-v3.md` 同步更新;在实现前不要把下方 schema 当作 V3 新入口。 +当前 MCP tool 已支持三种输入形态: + +1. V3 业务根:`source_message + message_events[]`。 +2. V3 S10/S99 入口通知:`source_message + route_code`。 +3. V2 兼容任务结果:`source_message_id + ai_task_results[]`。 + +MCP 层会先调用 `SuperAgentMcpSubmitPayloadAdapter` 做提交前校验和事件索引映射。`E1`、`E_CHILD_1`、`E_PARENT` 等 Agent 内部事件 ID 不会直接进入业务层;adapter 会按 `message_events[]` 顺序生成本系统一基数字 `source_event_index`,并同步映射 `related_source_event_index`、`parent_source_event_index` 和 `related_source_event_indices[]`。 ```json { @@ -366,36 +372,115 @@ POST /api/ai-query/v1/message-conversation/messages "type": ["string", "null"], "description": "兼容字段;写入工具通常不需要传,后端写入定位不使用该字段,AgentBus 实际入库渠道可能是 OUTLOOK" }, + "source_message": { + "type": "object", + "description": "V3 来源邮件元数据;source_message_id 对应 AgentBus source.external_message_id", + "properties": { + "source_message_id": { + "type": "string" + }, + "subject": { + "type": ["string", "null"] + }, + "from": { + "type": ["string", "null"] + }, + "cc": { + "type": "array", + "items": { + "type": "string" + } + }, + "received_at": { + "type": ["string", "null"] + }, + "source_channel": { + "type": "string", + "enum": ["Email"] + } + }, + "required": ["source_message_id", "subject", "from", "cc", "received_at", "source_channel"] + }, + "route_code": { + "type": ["string", "null"], + "description": "V3 S10/S99 入口通知路由码" + }, + "handler_type": { + "type": ["string", "null"], + "description": "V3 Main Agent 输出处理器类型" + }, + "result_type": { + "type": ["string", "null"], + "description": "V3 入口通知或 V2 任务结果类型" + }, + "current_or_history": { + "type": ["string", "null"], + "description": "V3 current/history 标记" + }, + "agent_assessment": { + "type": "object", + "description": "V3 S10/S99 入口判断摘要" + }, + "notification": { + "type": "object", + "description": "V3 S10/S99 通知展示信息" + }, + "manual_review": { + "type": ["object", "null"], + "description": "V3 人工复核对象;S10 可为空,S99 必须完整" + }, + "message_events": { + "type": "array", + "description": "V3 业务事件数组;MCP Adapter 会按数组顺序生成一基 source_event_index", + "items": { + "type": "object" + } + }, + "case_candidates": { + "type": "array", + "description": "V3 订单候选数组,无候选传空数组", + "items": { + "type": "object" + } + }, + "unhandled_current_intents": { + "type": "array", + "description": "V3 未覆盖当前意图数组,无意图传空数组", + "items": { + "type": "object" + } + }, "source_message_id": { "type": "string", - "description": "外部来源消息 ID,对应 AgentBus source.external_message_id" + "description": "V2 兼容字段:外部来源消息 ID,对应 AgentBus source.external_message_id" }, "ai_task_results": { "type": "array", - "description": "AI 拆分出的任务结果,必须保留数组顺序", + "description": "V2 兼容字段:AI 拆分出的任务结果,必须保留数组顺序", "items": { "type": "object" } }, "extraction_warnings": { "type": "array", - "description": "AI 抽取警告", + "description": "AI 抽取警告;V3/V2 都允许,缺省按空数组处理", "items": { "type": "object" } } }, - "required": ["source_message_id", "ai_task_results"] + "required": [] } ``` 说明: -- `ai_task_results[]` 内部字段较多,完整结构以 `superagent-api-contract.md` 第 8 节为准。 -- MCP endpoint 不应重排 `ai_task_results[]`。 +- V3 业务根字段、S10/S99 字段和 V2 `ai_task_results[]` item 完整语义以 `superagent-api-contract.md` 第 8 节为准。 +- BusinessResult 到 MCP payload 的稳定映射、`source_event_index` 正式定义、跨 Child Trace 示例和拒绝示例见 `submit-payload-mapping.md`。 +- MCP endpoint 不应重排 V2 `ai_task_results[]`,也不应重排 V3 `message_events[]`。 - `source_message_id` 必须是 AgentBus payload 的 `source.external_message_id`,不是内部 `platform_source_message_inbox.id`;写入工具不要求 SuperAgent 知道 Inbox 的真实 channel。 - 同一系统酒店下如果外部 `source_message_id` 匹配多条 Inbox,业务 Service 返回 `SOURCE_MESSAGE_AMBIGUOUS`,MCP tool result 应原样保留该错误码和 message。 -- 如后续需要强 schema 校验,可在 MCP endpoint 内复制 REST 契约中的细粒度字段约束。 +- MCP adapter 校验失败时返回 `MCP_SUBMIT_PAYLOAD_INVALID`,不会调用业务写入 Service,也不会自动重试。 ### 7.5 输出 @@ -448,4 +533,5 @@ MCP 层新增错误建议: | `MCP_REQUEST_BODY_TOO_LARGE` | MCP 请求体超过 10MB 默认限制或环境配置限制 | | `MCP_METHOD_NOT_FOUND` | MCP 方法不存在 | | `MCP_TOOL_NOT_FOUND` | MCP 工具不存在 | +| `MCP_SUBMIT_PAYLOAD_INVALID` | 写入工具 payload 未通过 MCP adapter 提交前校验 | | `MCP_INTERNAL_ERROR` | MCP endpoint 内部异常 | diff --git a/docs/project/requirements/M002-order-task-workflow-v3.md b/docs/project/requirements/M002-order-task-workflow-v3.md index c83ed3b..548a331 100644 --- a/docs/project/requirements/M002-order-task-workflow-v3.md +++ b/docs/project/requirements/M002-order-task-workflow-v3.md @@ -405,7 +405,8 @@ V3 建议拆成以下 checkpoint,避免一次性重构过大: | M002-V3-CP4 | 列表 / 详情展示 | 已完成第一版:任务列表、订单任务时间线和任务详情透出 V3 路由字段;任务详情支持 S10/S99 入口通知结构、unhandled intent 展示块和 adapter contract error 展示块 | | M002-V3-CP5 | 同卡复核解阻 | 已完成第一版:支持 review_status、review_resolution.field_overrides[]、复核场景订单归属确认、JSON Pointer 校验和 READY 流转 | | M002-V3-CP6 | P0 fixtures 回归 | 已完成第一版:引入 0711 P0 fixtures / validator 作为后端适配测试参考,覆盖 main_outcomes、candidate_gate、manual_review_resolution、source_identity_errors、parent_split_two_children、row_multiple_derived、allotment_scope;其中 candidate_gate 是 Main Agent 调 Skill 前契约,后端以 validator 和 fixture reference 固化,不作为任务结果回调直接建任务 | -| M002-V3-CP7 | P0.1 Parent Group 路由修订 | 当前 checkpoint:将 Parent split 父事件从旧 Cancel Booking 迁移为 Cancel Allotment,路由总数 42 → 40,并保留旧 payload 只读兼容 | +| M002-V3-CP7 | P0.1 Parent Group 路由修订 | 已完成:将 Parent split 父事件从旧 Cancel Booking 迁移为 Cancel Allotment,路由总数 42 → 40,并保留旧 payload 只读兼容 | +| M002-V3-CP8 | MCP submit 稳定性 | 已完成第一版:MCP `th_hotel_submit_task_results` 支持 V3 业务根、结构化 S10/S99 和 V2 兼容,新增提交前 payload adapter / schema validator,按 `message_events[]` 顺序把 Agent 内部事件 ID 映射为本系统一基 `source_event_index`,并拒绝未知字段、悬空关系和重复关系 | ## 13. 明确不做 @@ -441,6 +442,7 @@ V3 P0.1 不做以下事项: - `field_contract_version` 历史迁移已收紧:V18 只把没有 `draft_payload_json` 且没有 `confirmed_payload_json` 的 `code-v1` 任务卡标记为 `20260711-p0`;已经存在用户草稿或确认 payload 的历史任务卡保留旧版本,等待重新保存、确认或后续专项 backfill。 - 订单 / 任务列表、任务详情、草稿保存、最终确认、OPERA 模拟骨架和审计列表。 - SuperAgent 查询上下文接口 1、2,以及邮件会话相关查询。 +- MCP `th_hotel_submit_task_results` 的 V3/P0.1 payload adapter 和提交前校验;`E1/E_CHILD_1/E_PARENT` 等 Agent 内部事件 ID 不直接进入业务层,由 MCP adapter 映射为 `1/2/3` 等本系统一基索引,跨 Child Trace / Parent split 多事件关系保留顺序并拒绝悬空或重复引用。 仍需后续 checkpoint 实现: diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/SuperAgentMcpSubmitPayloadAdapter.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/SuperAgentMcpSubmitPayloadAdapter.java new file mode 100644 index 0000000..b9b4e2c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/SuperAgentMcpSubmitPayloadAdapter.java @@ -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); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java index 08791bf..cf9c284 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java @@ -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 sourceMessageSchema() { + Map 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 messageEventSchema() { + Map 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 objectSchema(Map propertiesMap, List required) { diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpSubmitPayloadAdapterImpl.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpSubmitPayloadAdapterImpl.java new file mode 100644 index 0000000..7f6d520 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpSubmitPayloadAdapterImpl.java @@ -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 V3_BUSINESS_ROOT_FIELDS = Set.of( + "source_message", + "message_events", + "case_candidates", + "extraction_warnings", + "unhandled_current_intents"); + private static final Set V3_SOURCE_MESSAGE_FIELDS = Set.of( + "source_message_id", + "subject", + "from", + "cc", + "received_at", + "source_channel"); + private static final List V3_SOURCE_MESSAGE_REQUIRED_FIELDS = List.of( + "source_message_id", + "subject", + "from", + "cc", + "received_at", + "source_channel"); + private static final Set 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 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 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 V3_CASE_KEY_FIELDS = List.of( + "group_code", + "confirmation_number", + "reservation_number", + "block_code"); + private static final Set 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 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 LEGACY_V2_ROOT_FIELDS = Set.of( + "hotel_id", + "source_provider", + "source_channel", + "source_message_id", + "ai_task_results", + "extraction_warnings"); + private static final List 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 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 eventIndexMap(ArrayNode events) { + Map 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 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 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 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 allowedFields) { + if (node == null || !node.isObject()) { + throw invalid("payload 节点必须是对象。", "payload"); + } + Iterator 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 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 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); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpSubmitPayloadException.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpSubmitPayloadException.java new file mode 100644 index 0000000..287ca37 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpSubmitPayloadException.java @@ -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; + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpControllerTest.java index ccef289..81b2216 100644 --- a/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpControllerTest.java @@ -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") diff --git a/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpSubmitEnabledControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpSubmitEnabledControllerTest.java index 1ea2379..cb1b8f6 100644 --- a/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpSubmitEnabledControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpSubmitEnabledControllerTest.java @@ -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.", + "MCP submit fixture source message.", + "{\"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); + } }