From eda3e7087366a406667d1916b166473e8d3d05d3 Mon Sep 17 00:00:00 2001 From: andy Date: Sun, 12 Jul 2026 19:39:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DMCP=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E6=8F=90=E4=BA=A4=E5=A5=91=E7=BA=A6=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integrations/superagent-api-contract.md | 6 +- .../superagent-mcp/integration-guide.md | 2 + .../superagent-mcp/submit-payload-mapping.md | 60 +++- .../integrations/superagent-mcp/test-cases.md | 11 +- .../integrations/superagent-mcp/tools.md | 47 ++- .../M002-order-task-workflow-v3.md | 5 +- .../SuperAgentMcpSubmitPayloadAdaptation.java | 16 + .../SuperAgentMcpSubmitPayloadAdapter.java | 5 +- .../impl/SuperAgentMcpServiceImpl.java | 103 +++++- ...SuperAgentMcpSubmitPayloadAdapterImpl.java | 279 +++++++++++----- .../enums/ReservationAiV3EventType.java | 44 +++ .../ReservationAiTaskIntakeServiceImpl.java | 17 +- .../control/SuperAgentMcpControllerTest.java | 8 + ...erAgentMcpSubmitEnabledControllerTest.java | 306 +++++++++++++++++- 14 files changed, 787 insertions(+), 122 deletions(-) create mode 100644 server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/dto/SuperAgentMcpSubmitPayloadAdaptation.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationAiV3EventType.java diff --git a/docs/project/integrations/superagent-api-contract.md b/docs/project/integrations/superagent-api-contract.md index eaf74cf..59dfa00 100644 --- a/docs/project/integrations/superagent-api-contract.md +++ b/docs/project/integrations/superagent-api-contract.md @@ -561,7 +561,9 @@ 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[]` 是否悬空或重复。 +- 如果通过 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[]` 是否悬空或重复;成功响应会额外返回 `mapping_diagnostics`,该诊断不写入业务 JSON。 +- MCP adapter 只拦 transport 层问题,例如未知字段、缺失或重复 `source_event_index`、关系引用悬空或重复、V2 item schema 不完整;V3 event 的业务契约问题,例如 unsupported `event_type`、不完整 `case_keys`、不完整 `manual_review`,继续由业务入站层保存为 `adapter_contract_error` transition。 +- MCP 路径缺失 `source_message.source_message_id` 或整个 `source_message` 时,保留业务入站层 `MISSING_SOURCE_MESSAGE_ID` 错误语义。 - 40 条 P0.1 路由进入后端枚举 / 稳定配置。 - `route_code` 是稳定代码,不因路由总数从 42 调整为 40 而重编号;联调方不要按数字连续性判断合法性。 - 结构化 `S10/S99` 创建只读 `SOURCE_MESSAGE_ONLY` 任务,任务列表可见,订单列表不可见。 @@ -633,7 +635,7 @@ V3 字段说明: | `ai_task_results[].source_event_index` | 是 | AI current 事件序号 | | `ai_task_results[].catalog_code` | 是 | Skill 目录代码 | | `ai_task_results[].skill_id` | 是 | Skill 标识 | -| `ai_task_results[].result_type` | 是 | 当前代码契约只接受 `normal_task`、`manual_review`;`informational_message` 仅历史兼容 | +| `ai_task_results[].result_type` | 是 | 当前代码契约接受 `normal_task`、`manual_review`;`informational_message` 仅历史兼容;MCP 路径会在 adapter 层校验 | | `ai_task_results[].task_type` | 是 | AI 原始任务类型 | | `ai_task_results[].task_subtype` | 否 | 业务动作 subtype | | `ai_task_results[].case_keys` | 否 | 订单关联候选键 | diff --git a/docs/project/integrations/superagent-mcp/integration-guide.md b/docs/project/integrations/superagent-mcp/integration-guide.md index 920c884..90e2e71 100644 --- a/docs/project/integrations/superagent-mcp/integration-guide.md +++ b/docs/project/integrations/superagent-mcp/integration-guide.md @@ -134,6 +134,8 @@ REST request M002 V3 后,写入工具优先接收结构化 `S10/S99` 或 `source_message + message_events[]` 业务根。`message_events[].source_event_index` 可以是 SuperAgent 内部事件 ID,例如 `E_CHILD_1`;MCP adapter 会在提交前按数组顺序映射为本系统一基数字索引,并校验跨事件关系是否悬空或重复。详细映射规则见 `submit-payload-mapping.md`。 +V3 业务根写入成功时,MCP tool result 会返回 `mapping_diagnostics.source_event_index_mapping[]`,用于联调排查原始事件 ID 到本系统索引的映射;该字段不是业务任务字段,不会写入 TH Hotel 业务 payload。 + ## 9. 当前 checkpoint 当前 checkpoint: diff --git a/docs/project/integrations/superagent-mcp/submit-payload-mapping.md b/docs/project/integrations/superagent-mcp/submit-payload-mapping.md index aed1fb9..8e885c8 100644 --- a/docs/project/integrations/superagent-mcp/submit-payload-mapping.md +++ b/docs/project/integrations/superagent-mcp/submit-payload-mapping.md @@ -4,7 +4,7 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.1 | +| 文档版本 | 0.2 | | 日期 | 2026-07-12 | | 状态 | 已落地第一版 | | 适用范围 | `th_hotel_submit_task_results` 的 V3/P0.1 payload adapter、schema validator 和一次提交规则 | @@ -34,13 +34,15 @@ server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/ | 形态 | 判断方式 | 处理方式 | | --- | --- | --- | -| 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[]` 兼容路径 | +| V3 业务根 | 包含 `message_events` | 校验根结构、source message、event transport 字段和关系索引,再按 `message_events[]` 顺序映射 `source_event_index`;event 业务合法性仍交给业务入站层落 `adapter_contract_error` | +| V3 S10/S99 入口通知 | 包含 `route_code` 或入口通知 `result_type` | 校验 S10/S99 根字段和 source message 后透传给业务入站服务 | +| V2 兼容任务结果 | 包含 `ai_task_results` | 保留旧 `source_message_id + ai_task_results[]` 兼容路径,并在 MCP 层校验完整 item schema | 未知根字段会在 MCP 层被拒绝,错误码为 `MCP_SUBMIT_PAYLOAD_INVALID`。 -`source_message_id` 缺失属于既有基础设施输入错误,MCP adapter 不改写该错误通道;业务入站服务会返回 `MISSING_SOURCE_MESSAGE_ID`。 +`source_message_id` 缺失或整个 `source_message` 缺失属于既有基础设施输入错误,MCP adapter 不改写该错误通道;业务入站服务会返回 `MISSING_SOURCE_MESSAGE_ID`。 + +V3 业务根和 S10/S99 如果携带 MCP schema 中的兼容字段 `hotel_id`、`source_provider`、`source_channel`,adapter 会接受并在转交业务入站层前移除。业务定位仍以系统酒店和 `source_message.source_message_id` 为准。 ## 4. source_message_id 定义 @@ -72,10 +74,25 @@ MCP adapter 按 `message_events[]` 数组顺序生成一基数字索引: - `parent_source_event_index` 输出为数字。 - `related_source_event_indices[]` 输出为字符串数字数组,保持原顺序。 - 悬空引用、重复引用、缺失事件 ID 都在 MCP 层拒绝。 +- MCP tool result 会返回 `mapping_diagnostics.source_event_index_mapping[]`,记录原始事件 ID 到本系统索引的映射;该诊断不写入 `workflow_reservation_ai_transition.ai_payload_json`。 ## 6. ai_task_results[] 兼容 item schema -V2 兼容路径仍保留,`ai_task_results[]` item 以 REST 总契约为准,第一版 MCP schema 只暴露 object,但业务入站会校验核心字段。 +V2 兼容路径仍保留,`ai_task_results[]` item 以 REST 总契约为准。当前 MCP `tools/list` 已暴露 item schema,adapter 也会在提交业务层前校验必填字段、字段类型、允许 `result_type`、未知字段和根级 `extraction_warnings`。 + +必填字段: + +- `source_event_index` +- `catalog_code` +- `skill_id` +- `result_type` +- `task_type` + +允许的 `result_type`: + +- `normal_task` +- `manual_review` +- `informational_message`,仅历史兼容 建议 item 结构: @@ -216,6 +233,30 @@ MCP adapter 进入业务层前会把父事件关系映射为: } ``` +MCP tool result 同时返回非业务诊断: + +```json +{ + "mapping_diagnostics": { + "mapping_policy": "message_events_array_order_1_based", + "source_event_index_mapping": [ + { + "original_source_event_index": "E_CHILD_1", + "mapped_source_event_index": 1 + }, + { + "original_source_event_index": "E_CHILD_2", + "mapped_source_event_index": 2 + }, + { + "original_source_event_index": "E_PARENT", + "mapped_source_event_index": 3 + } + ] + } +} +``` + ## 9. 成功示例:跨 Child Trace 当 Trace 覆盖完整 Parent split 的全部 Child 时,不能只绑定第一个 Child。业务结果应保留全部 Child 引用: @@ -300,9 +341,14 @@ MCP adapter 会把它映射为: 覆盖内容: - `tools/list` 暴露 V3 submit schema。 -- 缺失 `source_message_id` 保持既有 `MISSING_SOURCE_MESSAGE_ID` 错误。 +- `tools/list` 暴露 V2 `ai_task_results[]` item schema 和 V3 `relationship_type`。 +- 缺失 `source_message_id` 或整个 `source_message` 保持既有 `MISSING_SOURCE_MESSAGE_ID` 错误。 - 未知根字段在业务层前被拒绝。 +- V2 item 缺必填字段在业务层前被拒绝。 - 悬空 `related_source_event_indices` 在业务层前被拒绝。 - 重复 `related_source_event_indices` 在业务层前被拒绝。 - `E_CHILD_1/E_CHILD_2/E_PARENT` 按数组顺序映射为 `1/2/3`。 +- 跨 Child Trace 关系映射为完整 `["1", "2"]`,不压缩到第一个 Child。 +- S10/S99 可通过 MCP 写入工具创建只读来源消息任务。 +- V3 event 业务契约错误由业务入站层保存为 `adapter_contract_error` transition。 - 合法 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 e12bccb..13c8558 100644 --- a/docs/project/integrations/superagent-mcp/test-cases.md +++ b/docs/project/integrations/superagent-mcp/test-cases.md @@ -99,6 +99,11 @@ | 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 | +| MCP-T05-016 | V3 缺失整个 source_message | 业务根或 S10/S99 没有 `source_message` | 返回既有 `MISSING_SOURCE_MESSAGE_ID` typed infrastructure error | +| MCP-T05-017 | V2 item 缺必填字段 | `ai_task_results[]` item 缺 `task_type` 等必填字段 | 返回 `MCP_SUBMIT_PAYLOAD_INVALID`,不调用业务写入 Service | +| MCP-T05-018 | V3 event 业务契约错误 | event_type unsupported 但 transport 字段完整 | MCP adapter 不整批拒绝,业务入站层保存 `adapter_contract_error` transition | +| MCP-T05-019 | V3 S10 入口通知 | `route_code=S10` | 通过 MCP 写入只读 `SOURCE_MESSAGE_ONLY` 任务 | +| MCP-T05-020 | V3 S99 入口通知 | `route_code=S99` 且 `manual_review` 完整 | 通过 MCP 写入只读 `SOURCE_MESSAGE_ONLY` 任务 | 写入验证: @@ -108,6 +113,8 @@ - MCP endpoint 不能在日志输出完整 `extracted_fields` 中的敏感内容。 - 失败响应应保留后端错误码和 message。 - MCP adapter 校验失败时不进入业务写入 Service,且不自动重试。 +- V3 成功响应返回 `mapping_diagnostics.source_event_index_mapping[]`,但 AI transition 业务 payload 不包含该诊断字段。 +- V3 `relationship_type` 可在 event 根节点透传,也可在 `extracted_fields` 中作为业务关系字段;MCP adapter 不据此派生业务含义。 ## 9. MCP 鉴权和开关测试 @@ -136,7 +143,9 @@ - 5 个工具正常成功调用。 - 查询接口错误 envelope 不丢失。 - 任务结果写入成功和幂等重放正常。 -- V3 submit payload adapter 的事件索引映射、悬空关系拒绝、重复关系拒绝和未知字段拒绝正常。 +- V3 submit payload adapter 的事件索引映射、mapping 诊断、悬空关系拒绝、重复关系拒绝和未知字段拒绝正常。 +- V2 `ai_task_results[]` item schema 和 adapter 校验正常。 +- S10/S99 结构化入口通知可通过 MCP 写入工具。 - 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 eb6ef03..f41c966 100644 --- a/docs/project/integrations/superagent-mcp/tools.md +++ b/docs/project/integrations/superagent-mcp/tools.md @@ -347,7 +347,7 @@ POST /api/ai-query/v1/message-conversation/messages ### 7.4 输入 Schema -当前 MCP tool 已支持三种输入形态: +当前 MCP tool 已支持三种输入形态,真实机器可读 schema 以 `/mcp` 的 `tools/list` 返回为准: 1. V3 业务根:`source_message + message_events[]`。 2. V3 S10/S99 入口通知:`source_message + route_code`。 @@ -355,6 +355,15 @@ POST /api/ai-query/v1/message-conversation/messages 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[]`。 +补充规则: + +- V3 业务根或 S10/S99 如果携带 `hotel_id`、`source_provider`、`source_channel`,MCP adapter 会接受这些兼容字段,但转交业务入站层前会移除;业务定位仍以系统酒店和外部 `source_message_id` 为准。 +- 缺失 `source_message.source_message_id` 或整个 `source_message` 时,MCP adapter 不改写错误,业务入站层返回 `MISSING_SOURCE_MESSAGE_ID`。 +- V3 event 业务合法性问题,例如 unsupported `event_type`、不完整 `case_keys`、不完整 `manual_review`,由业务入站层保存为 `adapter_contract_error` transition;MCP adapter 只拦 transport 必需问题,包括未知字段、`source_event_index` 缺失/重复、关系悬空/重复。 +- V2 `ai_task_results[]` item 已在 MCP schema 和 adapter 中完整校验,必填字段为 `source_event_index`、`catalog_code`、`skill_id`、`result_type`、`task_type`。 + +下面 JSON 是关键结构节选;不要手工复制为最终 schema,联调时应以 `tools/list` 实时返回为准。 + ```json { "type": "object", @@ -458,7 +467,27 @@ MCP 层会先调用 `SuperAgentMcpSubmitPayloadAdapter` 做提交前校验和事 "type": "array", "description": "V2 兼容字段:AI 拆分出的任务结果,必须保留数组顺序", "items": { - "type": "object" + "type": "object", + "additionalProperties": false, + "required": ["source_event_index", "catalog_code", "skill_id", "result_type", "task_type"], + "properties": { + "source_event_index": { + "type": ["integer", "string"] + }, + "catalog_code": { + "type": "string" + }, + "skill_id": { + "type": "string" + }, + "result_type": { + "type": "string", + "enum": ["normal_task", "manual_review", "informational_message"] + }, + "task_type": { + "type": "string" + } + } } }, "extraction_warnings": { @@ -469,7 +498,18 @@ MCP 层会先调用 `SuperAgentMcpSubmitPayloadAdapter` 做提交前校验和事 } } }, - "required": [] + "required": [], + "oneOf": [ + { + "required": ["source_message", "message_events", "case_candidates", "extraction_warnings", "unhandled_current_intents"] + }, + { + "required": ["source_message", "route_code", "handler_type", "result_type", "current_or_history", "agent_assessment", "notification", "manual_review"] + }, + { + "required": ["source_message_id", "ai_task_results"] + } + ] } ``` @@ -499,6 +539,7 @@ POST /api/integrations/superagent/task-results - `items[].order_id`:订单 ID。 - `items[].task_id`:任务 ID。 - `items[].task_status`:任务状态。 +- `mapping_diagnostics`:仅 MCP 写入工具在 V3 业务根映射时返回,记录 Agent 原始事件 ID 到本系统一基索引的映射;不写入业务 JSON。 完整字段以 `superagent-api-contract.md` 第 8 节为准。 diff --git a/docs/project/requirements/M002-order-task-workflow-v3.md b/docs/project/requirements/M002-order-task-workflow-v3.md index 548a331..222de21 100644 --- a/docs/project/requirements/M002-order-task-workflow-v3.md +++ b/docs/project/requirements/M002-order-task-workflow-v3.md @@ -406,7 +406,7 @@ V3 建议拆成以下 checkpoint,避免一次性重构过大: | 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 路由修订 | 已完成:将 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`,并拒绝未知字段、悬空关系和重复关系 | +| M002-V3-CP8 | MCP submit 稳定性 | 已完成 review 修正版:MCP `th_hotel_submit_task_results` 支持 V3 业务根、结构化 S10/S99 和 V2 兼容;V2 item 已暴露完整 schema 并在提交前校验;V3 按 `message_events[]` 顺序把 Agent 内部事件 ID 映射为本系统一基 `source_event_index`,返回 `mapping_diagnostics` 但不污染业务 JSON;MCP 层只拦 transport 问题,event 业务契约问题继续由业务入站层落 `adapter_contract_error` | ## 13. 明确不做 @@ -443,6 +443,9 @@ V3 P0.1 不做以下事项: - 订单 / 任务列表、任务详情、草稿保存、最终确认、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 多事件关系保留顺序并拒绝悬空或重复引用。 +- MCP submit V3 成功响应返回 `mapping_diagnostics.source_event_index_mapping[]`,用于排查 Agent 内部事件 ID 到本系统索引的映射;该诊断不写入 AI transition 业务 payload。 +- MCP submit V2 兼容路径已在 `tools/list` 暴露 `ai_task_results[]` item schema,并在 adapter 层校验必填字段、字段类型、允许 `result_type` 和未知字段。 +- MCP submit 对缺失 `source_message.source_message_id` 或整个 `source_message` 保留业务入站层 `MISSING_SOURCE_MESSAGE_ID` 错误语义;对 V3 event 业务契约问题不提前整批拒绝,由业务入站层保存 `adapter_contract_error` transition。 仍需后续 checkpoint 实现: diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/dto/SuperAgentMcpSubmitPayloadAdaptation.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/dto/SuperAgentMcpSubmitPayloadAdaptation.java new file mode 100644 index 0000000..66f1165 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/dto/SuperAgentMcpSubmitPayloadAdaptation.java @@ -0,0 +1,16 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.common.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.Map; + +/** + * MCP submit payload 适配结果。 + * + * @param payload 可提交给业务入站服务的稳定 payload + * @param mappingDiagnostics MCP 层 transport 映射诊断,只用于返回给调用方,不写入业务 JSON + */ +public record SuperAgentMcpSubmitPayloadAdaptation( + JsonNode payload, + Map mappingDiagnostics +) { +} 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 index b9b4e2c..9a4c819 100644 --- 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 @@ -1,5 +1,6 @@ package cn.nianxx.thhotel.integrations.mcp.superagent.service; +import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpSubmitPayloadAdaptation; import com.fasterxml.jackson.databind.JsonNode; /** @@ -8,7 +9,7 @@ import com.fasterxml.jackson.databind.JsonNode; public interface SuperAgentMcpSubmitPayloadAdapter { /** - * 校验并转换 th_hotel_submit_task_results 的 arguments,返回可提交给业务入站服务的稳定 payload。 + * 校验并转换 th_hotel_submit_task_results 的 arguments,返回可提交给业务入站服务的稳定 payload 和 MCP 诊断。 */ - JsonNode adapt(JsonNode arguments); + SuperAgentMcpSubmitPayloadAdaptation 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 cf9c284..3c62e0a 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 @@ -1,6 +1,7 @@ package cn.nianxx.thhotel.integrations.mcp.superagent.service.impl; import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultException; +import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpSubmitPayloadAdaptation; import cn.nianxx.thhotel.integrations.mcp.superagent.common.request.SuperAgentMcpJsonRpcRequest; import cn.nianxx.thhotel.integrations.mcp.superagent.common.request.SuperAgentMcpToolCallParams; import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpJsonRpcResponse; @@ -16,14 +17,17 @@ import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiObjec import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationMessageConversationQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryErrorResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryResponse; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiV3EventType; import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultResponse; import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiQueryService; import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiTaskIntakeService; import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationAiQueryException; import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationAiTaskIntakeException; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -260,14 +264,32 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { "MCP 写入工具未启用。", Map.of("tool", TOOL_SUBMIT_TASK_RESULTS))); } - JsonNode mappedPayload = submitPayloadAdapter.adapt(arguments); - String rawBody = objectMapper.writeValueAsString(mappedPayload); + SuperAgentMcpSubmitPayloadAdaptation adaptation = submitPayloadAdapter.adapt(arguments); + String rawBody = objectMapper.writeValueAsString(adaptation.payload()); SuperAgentTaskResultResponse response = intakeService.accept( rawBody, MCP_CLIENT_ID, null, hotelContextService.resolveSystemHotelId()); - return SuperAgentMcpToolCallResult.success(TOOL_SUBMIT_TASK_RESULTS + " 调用成功。", response); + return SuperAgentMcpToolCallResult.success( + TOOL_SUBMIT_TASK_RESULTS + " 调用成功。", + submitStructuredContent(response, adaptation.mappingDiagnostics())); + } + + /** + * 构造写入工具结构化响应,将 MCP transport 诊断放在业务响应之外,避免污染业务入站 JSON。 + */ + private Map submitStructuredContent( + SuperAgentTaskResultResponse response, + Map mappingDiagnostics) { + Map structuredContent = objectMapper.convertValue( + response, + new TypeReference>() { + }); + if (mappingDiagnostics != null && !mappingDiagnostics.isEmpty()) { + structuredContent.put("mapping_diagnostics", mappingDiagnostics); + } + return structuredContent; } /** @@ -398,12 +420,32 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { propertiesMap.put("ai_task_results", Map.of( "type", "array", "description", "V2 兼容字段:AI 拆分出的任务结果,必须保留数组顺序", - "items", Map.of("type", "object"))); + "items", legacyV2TaskResultItemSchema())); propertiesMap.put("extraction_warnings", Map.of( "type", "array", "description", "AI 抽取警告;V3/V2 都允许,缺省按空数组处理", "items", Map.of("type", "object"))); - return objectSchema(propertiesMap, List.of()); + Map schema = new LinkedHashMap<>(objectSchema(propertiesMap, List.of())); + schema.put("oneOf", List.of( + Map.of( + "required", List.of( + "source_message", + "message_events", + "case_candidates", + "extraction_warnings", + "unhandled_current_intents")), + Map.of( + "required", List.of( + "source_message", + "route_code", + "handler_type", + "result_type", + "current_or_history", + "agent_assessment", + "notification", + "manual_review")), + Map.of("required", List.of("source_message_id", "ai_task_results")))); + return schema; } /** @@ -431,7 +473,10 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { */ private Map messageEventSchema() { Map propertiesMap = new LinkedHashMap<>(); - propertiesMap.put("event_type", stringField("V3 业务事件类型")); + propertiesMap.put("event_type", Map.of( + "type", "string", + "enum", v3EventTypeSchemaValues(), + "description", "V3 业务事件类型;Need Manual Review 等业务契约问题由业务入站层落 adapter_contract_error")); propertiesMap.put("event_role", stringField("V3 事件来源角色")); propertiesMap.put("source_event_index", Map.of( "type", List.of("string", "integer"), @@ -455,6 +500,7 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { 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("relationship_type", nullableStringField("Parent split 等联动事件关系类型,MCP 只透传不派生业务语义")); 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"))); @@ -472,6 +518,51 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { "manual_review")); } + /** + * 返回 V3 event_type schema 允许值;Need Manual Review 是人工复核兜底事件,不属于 activeCodes 但业务层支持。 + */ + private List v3EventTypeSchemaValues() { + List values = new ArrayList<>(ReservationAiV3EventType.activeCodes()); + values.add("Need Manual Review"); + return values.stream().sorted().toList(); + } + + /** + * 构造 V2 兼容 ai_task_results[] item 的 MCP schema。 + */ + private Map legacyV2TaskResultItemSchema() { + Map propertiesMap = new LinkedHashMap<>(); + propertiesMap.put("source_event_index", Map.of( + "type", List.of("integer", "string"), + "description", "V2 兼容事件序号,必须非空")); + propertiesMap.put("catalog_code", stringField("Skill 目录代码")); + propertiesMap.put("skill_id", stringField("Skill 标识")); + propertiesMap.put("result_type", Map.of( + "type", "string", + "enum", List.of("normal_task", "manual_review", "informational_message"), + "description", "V2 兼容结果类型,informational_message 仅历史兼容")); + propertiesMap.put("task_type", stringField("AI 原始任务类型")); + propertiesMap.put("task_subtype", nullableStringField("业务动作 subtype")); + propertiesMap.put("current_or_history", nullableStringField("current/history 标记")); + propertiesMap.put("case_keys", Map.of("type", "object", "description", "订单关联候选键")); + propertiesMap.put("visible_reason", nullableStringField("前端可见原因")); + propertiesMap.put("relevant_message_excerpt", nullableStringField("当前任务证据摘录")); + 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", "人工复核信息")); + propertiesMap.put("informational_message", Map.of("type", List.of("object", "null"), "description", "历史信息类消息兼容字段")); + propertiesMap.put("additional_operations", Map.of("type", "array", "description", "补充操作", "items", Map.of("type", "object"))); + propertiesMap.put("idempotency_key", nullableStringField("调用方幂等键,通常为空")); + return objectSchema(propertiesMap, List.of( + "source_event_index", + "catalog_code", + "skill_id", + "result_type", + "task_type")); + } + private Map objectSchema(Map propertiesMap, List required) { return Map.of( "type", "object", 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 index 7f6d520..50c0b31 100644 --- 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 @@ -1,10 +1,12 @@ package cn.nianxx.thhotel.integrations.mcp.superagent.service.impl; +import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpSubmitPayloadAdaptation; 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.ArrayList; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -25,7 +27,14 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi "message_events", "case_candidates", "extraction_warnings", - "unhandled_current_intents"); + "unhandled_current_intents", + "hotel_id", + "source_provider", + "source_channel"); + private static final Set V3_TRANSPORT_COMPATIBILITY_ROOT_FIELDS = Set.of( + "hotel_id", + "source_provider", + "source_channel"); private static final Set V3_SOURCE_MESSAGE_FIELDS = Set.of( "source_message_id", "subject", @@ -60,39 +69,10 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi "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"); + "missing_fields", + "relationship_type"); + private static final List V3_EVENT_TRANSPORT_REQUIRED_FIELDS = List.of( + "source_event_index"); private static final Set V3_NOTIFICATION_ROOT_FIELDS = Set.of( "source_message", "route_code", @@ -101,7 +81,10 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi "current_or_history", "agent_assessment", "notification", - "manual_review"); + "manual_review", + "hotel_id", + "source_provider", + "source_channel"); private static final List V3_NOTIFICATION_REQUIRED_FIELDS = List.of( "source_message", "route_code", @@ -118,6 +101,35 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi "source_message_id", "ai_task_results", "extraction_warnings"); + private static final Set LEGACY_V2_ITEM_FIELDS = Set.of( + "source_event_index", + "catalog_code", + "skill_id", + "result_type", + "task_type", + "task_subtype", + "current_or_history", + "case_keys", + "visible_reason", + "relevant_message_excerpt", + "attachments", + "file_references", + "context_used", + "extracted_fields", + "manual_review", + "informational_message", + "additional_operations", + "idempotency_key"); + private static final List LEGACY_V2_ITEM_REQUIRED_FIELDS = List.of( + "source_event_index", + "catalog_code", + "skill_id", + "result_type", + "task_type"); + private static final Set LEGACY_V2_RESULT_TYPES = Set.of( + "normal_task", + "manual_review", + "informational_message"); private static final List REQUIRED_V3_ROOT_ARRAY_FIELDS = List.of( "message_events", "case_candidates", @@ -137,24 +149,24 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi * 根据 submit arguments 的根结构选择 V3 业务根、S10/S99 通知或 V2 兼容映射。 */ @Override - public JsonNode adapt(JsonNode arguments) { + public SuperAgentMcpSubmitPayloadAdaptation 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 new SuperAgentMcpSubmitPayloadAdaptation(arguments, Map.of()); } return adaptV3BusinessRoot(arguments); } if (isV3SourceMessageNotification(arguments)) { if (isV3SourceMessageIdentityMissing(arguments)) { - return arguments; + return new SuperAgentMcpSubmitPayloadAdaptation(arguments, Map.of()); } validateRootFields(arguments, V3_NOTIFICATION_ROOT_FIELDS); validateRequiredFields(arguments, V3_NOTIFICATION_REQUIRED_FIELDS); validateSourceMessage(arguments.path("source_message")); - return arguments; + return new SuperAgentMcpSubmitPayloadAdaptation(removeCompatibilityFields(arguments), Map.of()); } if (arguments.has("ai_task_results")) { validateRootFields(arguments, LEGACY_V2_ROOT_FIELDS); @@ -164,7 +176,9 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi if (!arguments.path("ai_task_results").isArray()) { throw invalid("ai_task_results 必须是数组。", "ai_task_results"); } - return arguments; + validateLegacyV2Items(arguments.path("ai_task_results")); + validateLegacyExtractionWarnings(arguments); + return new SuperAgentMcpSubmitPayloadAdaptation(arguments, Map.of()); } throw invalid("MCP submit arguments 不是支持的 V3 或 V2 任务结果结构。", "arguments"); } @@ -172,7 +186,7 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi /** * 校验并转换 V3 业务根,将业务事件 ID 映射为本系统一基 source_event_index。 */ - private JsonNode adaptV3BusinessRoot(JsonNode root) { + private SuperAgentMcpSubmitPayloadAdaptation adaptV3BusinessRoot(JsonNode root) { validateRootFields(root, V3_BUSINESS_ROOT_FIELDS); validateSourceMessage(root.path("source_message")); for (String fieldName : REQUIRED_V3_ROOT_ARRAY_FIELDS) { @@ -182,10 +196,10 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi } ArrayNode events = (ArrayNode) root.path("message_events"); if (events.isEmpty()) { - return root.deepCopy(); + return new SuperAgentMcpSubmitPayloadAdaptation(removeCompatibilityFields(root), Map.of()); } Map eventIndexMap = eventIndexMap(events); - ObjectNode mappedRoot = root.deepCopy(); + ObjectNode mappedRoot = removeCompatibilityFields(root); ArrayNode mappedEvents = (ArrayNode) mappedRoot.path("message_events"); for (int index = 0; index < mappedEvents.size(); index++) { JsonNode eventNode = mappedEvents.get(index); @@ -194,13 +208,20 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi } ObjectNode event = (ObjectNode) eventNode; validateRootFields(event, V3_EVENT_FIELDS); - validateV3EventShape(event); + validateRequiredFields(event, V3_EVENT_TRANSPORT_REQUIRED_FIELDS); + if (!hasText(event.path("source_event_index"))) { + throw invalid("message_events[].source_event_index 不能为空。", "source_event_index"); + } 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; + return new SuperAgentMcpSubmitPayloadAdaptation( + mappedRoot, + Map.of( + "mapping_policy", "message_events_array_order_1_based", + "source_event_index_mapping", sourceEventIndexMapping(eventIndexMap))); } /** @@ -214,7 +235,7 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi throw invalid("message_events[] item 必须是 object。", "message_events"); } validateRootFields(event, V3_EVENT_FIELDS); - validateV3EventShape((ObjectNode) event); + validateRequiredFields(event, V3_EVENT_TRANSPORT_REQUIRED_FIELDS); String rawIndex = referenceText(event.path("source_event_index")); if (rawIndex == null) { throw invalid("message_events[].source_event_index 不能为空。", "source_event_index"); @@ -226,6 +247,20 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi return result; } + /** + * 构造原始业务事件 ID 到本系统一基索引的映射诊断。 + */ + private List> sourceEventIndexMapping(Map eventIndexMap) { + List> result = new ArrayList<>(); + for (Map.Entry entry : eventIndexMap.entrySet()) { + Map mapping = new LinkedHashMap<>(); + mapping.put("original_source_event_index", entry.getKey()); + mapping.put("mapped_source_event_index", entry.getValue()); + result.add(mapping); + } + return result; + } + /** * 映射单事件关系字段;parent_source_event_index 按业务入站服务约定输出数字。 */ @@ -297,29 +332,16 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi } /** - * 校验 V3 业务事件最小结构和字段类型,提交前 fail closed。 + * 校验 V2 兼容 item 完整结构,避免 MCP 调用方发送半结构化对象后才落到业务层失败。 */ - 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"); + private void validateLegacyV2Items(JsonNode taskResults) { + for (JsonNode item : taskResults) { + if (item == null || !item.isObject()) { + throw invalid("ai_task_results[] item 必须是 object。", "ai_task_results"); + } + validateRootFields(item, LEGACY_V2_ITEM_FIELDS); + validateLegacyV2RequiredFields(item); + validateLegacyV2Item((ObjectNode) item); } } @@ -351,38 +373,118 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi } /** - * 校验 case_keys 四字段结构,避免 source_event_index 映射后才被业务层拒绝。 + * 校验 V2 兼容 item 的字段类型、允许值和常见子对象形态。 */ - private boolean isValidCaseKeys(JsonNode caseKeys) { - if (caseKeys == null || !caseKeys.isObject()) { - return false; + private void validateLegacyV2Item(ObjectNode item) { + if (!hasText(item.path("source_event_index"))) { + throw invalid("ai_task_results[].source_event_index 不能为空。", "ai_task_results[].source_event_index"); } - for (String key : V3_CASE_KEY_FIELDS) { - if (!caseKeys.has(key) || !isStringOrNull(caseKeys.path(key))) { - return false; + validateText(item, "catalog_code"); + validateText(item, "skill_id"); + validateText(item, "result_type"); + validateText(item, "task_type"); + if (!LEGACY_V2_RESULT_TYPES.contains(referenceText(item.path("result_type")))) { + throw invalid("ai_task_results[].result_type 不在允许范围。", "ai_task_results[].result_type"); + } + validateOptionalText(item, "task_subtype"); + validateOptionalText(item, "current_or_history"); + validateOptionalText(item, "visible_reason"); + validateOptionalText(item, "relevant_message_excerpt"); + validateOptionalText(item, "idempotency_key"); + validateOptionalObject(item, "case_keys"); + validateOptionalObject(item, "context_used"); + validateOptionalObject(item, "extracted_fields"); + validateOptionalObjectOrNull(item, "manual_review"); + validateOptionalObjectOrNull(item, "informational_message"); + validateOptionalArray(item, "attachments"); + validateOptionalArray(item, "file_references"); + validateOptionalArray(item, "additional_operations"); + } + + /** + * 校验 V2 item 必填字段,并返回包含数组路径的错误字段名。 + */ + private void validateLegacyV2RequiredFields(JsonNode item) { + for (String requiredField : LEGACY_V2_ITEM_REQUIRED_FIELDS) { + if (!item.has(requiredField)) { + throw invalid( + "MCP submit payload 缺少必填字段。", + "ai_task_results[]." + requiredField); } } - Iterator fieldNames = caseKeys.fieldNames(); - while (fieldNames.hasNext()) { - if (!V3_CASE_KEY_FIELDS.contains(fieldNames.next())) { - return false; - } + } + + /** + * 校验 V2 根级 extraction_warnings,存在时必须是数组。 + */ + private void validateLegacyExtractionWarnings(JsonNode arguments) { + JsonNode extractionWarnings = arguments.get("extraction_warnings"); + if (extractionWarnings != null && !extractionWarnings.isArray()) { + throw invalid("extraction_warnings 必须是数组。", "extraction_warnings"); + } + } + + /** + * 校验 V2 必填文本字段。 + */ + private void validateText(ObjectNode item, String fieldName) { + if (!hasText(item.path(fieldName))) { + throw invalid("ai_task_results[]." + fieldName + " 不能为空。", "ai_task_results[]." + fieldName); + } + } + + /** + * 校验 V2 可选文本字段。 + */ + private void validateOptionalText(ObjectNode item, String fieldName) { + JsonNode value = item.get(fieldName); + if (value != null && !isStringOrNull(value)) { + throw invalid("ai_task_results[]." + fieldName + " 必须是字符串或 null。", "ai_task_results[]." + fieldName); + } + } + + /** + * 校验 V2 可选对象字段。 + */ + private void validateOptionalObject(ObjectNode item, String fieldName) { + JsonNode value = item.get(fieldName); + if (value != null && !value.isObject()) { + throw invalid("ai_task_results[]." + fieldName + " 必须是 object。", "ai_task_results[]." + fieldName); + } + } + + /** + * 校验 V2 可选对象或 null 字段。 + */ + private void validateOptionalObjectOrNull(ObjectNode item, String fieldName) { + JsonNode value = item.get(fieldName); + if (value != null && !value.isObject() && !value.isNull()) { + throw invalid("ai_task_results[]." + fieldName + " 必须是 object 或 null。", "ai_task_results[]." + fieldName); + } + } + + /** + * 校验 V2 可选数组字段。 + */ + private void validateOptionalArray(ObjectNode item, String fieldName) { + JsonNode value = item.get(fieldName); + if (value != null && !value.isArray()) { + throw invalid("ai_task_results[]." + fieldName + " 必须是数组。", "ai_task_results[]." + fieldName); } - return true; } /** * 判断是否为 V3 业务根。 */ private boolean isV3BusinessRoot(JsonNode arguments) { - return arguments.has("source_message") && arguments.has("message_events"); + return arguments.has("message_events"); } /** * 判断是否为 V3 S10/S99 入口通知。 */ private boolean isV3SourceMessageNotification(JsonNode arguments) { - return arguments.has("source_message") && arguments.has("route_code"); + return arguments.has("route_code") || arguments.has("result_type"); } /** @@ -395,6 +497,17 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi || !hasText(sourceMessage.path("source_message_id")); } + /** + * 移除 MCP transport 兼容字段,避免进入业务入站 exact root 校验。 + */ + private ObjectNode removeCompatibilityFields(JsonNode root) { + ObjectNode copy = root.deepCopy(); + for (String fieldName : V3_TRANSPORT_COMPATIBILITY_ROOT_FIELDS) { + copy.remove(fieldName); + } + return copy; + } + /** * 提取事件引用文本,数字和字符串都统一成字符串键。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationAiV3EventType.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationAiV3EventType.java new file mode 100644 index 0000000..a60fc9e --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationAiV3EventType.java @@ -0,0 +1,44 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * SuperAgent 0711 P0 V3 业务事件类型枚举,集中维护后端可识别的 active event_type。 + */ +public enum ReservationAiV3EventType { + NEW_BOOKING("New Booking"), + UPDATE_BOOKING_AMENDMENT("Update Booking / Amendment"), + CANCEL_BOOKING("Cancel Booking"), + CANCEL_ALLOTMENT("Cancel Allotment"), + VOUCHER_RECEIVED("Voucher Received"), + PAYMENT_EVIDENCE("Payment Evidence"), + ROOMING_LIST("Rooming List"), + AMEND_GROUP_CODE("AMEND GROUP CODE"), + INVOICE_GENERATION("Invoice Generation"), + INVOICE_RECEIVED("Invoice Received"), + PAYMENT_NOTICE("Payment Notice"), + TRACE("Trace"), + MANUAL_RATE_CODE("Manual RateCode"), + TA_RECORDER("TA RECORDER"); + + private final String code; + + ReservationAiV3EventType(String code) { + this.code = code; + } + + public String code() { + return code; + } + + /** + * 返回所有 active event_type 代码,供业务入站和外部适配层共享。 + */ + public static Set activeCodes() { + return Arrays.stream(values()) + .map(ReservationAiV3EventType::code) + .collect(Collectors.toUnmodifiableSet()); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java index 5cd75a0..0e36dae 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java @@ -13,6 +13,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskDraft; import cn.nianxx.thhotel.workflows.reservation.common.enums.AiResultType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiRouteDefinition; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiSystemProcessCategory; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiV3EventType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationBusinessKeySource; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus; @@ -137,21 +138,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta "conflicting_points", "suggested_human_actions", "evidence_to_check"); - 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", - "Invoice Generation", - "Invoice Received", - "Payment Notice", - "Trace", - "Manual RateCode", - "TA RECORDER"); + private static final Set V3_ACTIVE_EVENT_TYPES = ReservationAiV3EventType.activeCodes(); private final ObjectMapper objectMapper; private final SourceMessageInboxRepository sourceMessageInboxRepository; 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 81b2216..4c0dc74 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 @@ -133,8 +133,16 @@ class SuperAgentMcpControllerTest { .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.oneOf.length()").value(3)) .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.message_events.items.properties.relationship_type").exists()) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.ai_task_results.items.required[0]") + .value("source_event_index")) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.ai_task_results.items.required[4]") + .value("task_type")) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.ai_task_results.items.properties.result_type.enum.length()") + .value(3)) .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 cb1b8f6..3921b16 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 @@ -1,8 +1,8 @@ package cn.nianxx.thhotel.integrations.mcp.superagent.control; +import static org.assertj.core.api.Assertions.assertThat; 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; @@ -125,6 +125,58 @@ class SuperAgentMcpSubmitEnabledControllerTest { .andExpect(jsonPath("$.result.structuredContent.error.details.http_status").value(400)); } + @Test + void shouldKeepInfrastructureErrorWhenV3SourceMessageMissing() throws Exception { + String businessRoot = parentSplitBusinessRootWithoutSourceMessage("mail-mcp-v3-missing-source-object-001"); + + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", AUTHORIZATION) + .content(toolCall("mcp-submit-v3-missing-source-object-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 shouldRejectIncompleteV2ItemBeforeDelegatingSubmit() throws Exception { + String externalId = "mail-mcp-v2-incomplete-item-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalId); + String body = """ + { + "jsonrpc": "2.0", + "id": "mcp-submit-v2-incomplete-item-001", + "method": "tools/call", + "params": { + "name": "th_hotel_submit_task_results", + "arguments": { + "source_message_id": "%s", + "ai_task_results": [ + { + "source_event_index": 1, + "catalog_code": "S01", + "skill_id": "S01_new_booking_skill", + "result_type": "normal_task" + } + ] + } + } + } + """.formatted(externalId); + + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", AUTHORIZATION) + .content(body)) + .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("ai_task_results[].task_type")); + + assertNoReservationWorkflowRows(source.inboxId()); + } + @Test void shouldRejectDanglingRelatedEventIndexBeforeDelegatingSubmit() throws Exception { String externalId = "mail-mcp-v3-dangling-related-001"; @@ -176,6 +228,14 @@ class SuperAgentMcpSubmitEnabledControllerTest { .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)) + .andExpect(jsonPath("$.result.structuredContent.mapping_diagnostics.source_event_index_mapping[0].original_source_event_index") + .value("E_CHILD_1")) + .andExpect(jsonPath("$.result.structuredContent.mapping_diagnostics.source_event_index_mapping[0].mapped_source_event_index") + .value(1)) + .andExpect(jsonPath("$.result.structuredContent.mapping_diagnostics.source_event_index_mapping[2].original_source_event_index") + .value("E_PARENT")) + .andExpect(jsonPath("$.result.structuredContent.mapping_diagnostics.source_event_index_mapping[2].mapped_source_event_index") + .value(3)) .andReturn(); Long batchCount = jdbcTemplate.queryForObject(""" @@ -197,7 +257,118 @@ class SuperAgentMcpSubmitEnabledControllerTest { .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"); + assertThat(parentPayload.toString()).doesNotContain("mapping_diagnostics"); + assertThat(result.getResponse().getContentAsString()).contains("E_CHILD_1"); + } + + @Test + void shouldMapCrossChildTraceRelationsAndSubmitOnce() throws Exception { + String externalId = "mail-mcp-v3-trace-cross-child-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalId); + + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", AUTHORIZATION) + .content(toolCall("mcp-submit-v3-trace-cross-child-001", traceCrossChildBusinessRoot(externalId)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.isError").value(false)) + .andExpect(jsonPath("$.result.structuredContent.accepted_count").value(3)) + .andExpect(jsonPath("$.result.structuredContent.items[2].source_event_index").value(3)) + .andExpect(jsonPath("$.result.structuredContent.mapping_diagnostics.source_event_index_mapping[2].original_source_event_index") + .value("E_TRACE")) + .andExpect(jsonPath("$.result.structuredContent.mapping_diagnostics.source_event_index_mapping[2].mapped_source_event_index") + .value(3)); + + String tracePayloadJson = 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 tracePayload = objectMapper.readTree(tracePayloadJson); + assertThat(tracePayload.path("v3_message_event").path("event_type").asText()).isEqualTo("Trace"); + assertThat(tracePayload.path("v3_message_event").path("related_source_event_indices").get(0).asText()) + .isEqualTo("1"); + assertThat(tracePayload.path("v3_message_event").path("related_source_event_indices").get(1).asText()) + .isEqualTo("2"); + } + + @Test + void shouldAllowRelationshipTypeOnV3EventAndLetBusinessLayerHandleIt() throws Exception { + String externalId = "mail-mcp-v3-relationship-root-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalId); + String businessRoot = parentSplitBusinessRootWithRootRelationshipType(externalId); + + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", AUTHORIZATION) + .content(toolCall("mcp-submit-v3-relationship-root-001", businessRoot))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.isError").value(false)) + .andExpect(jsonPath("$.result.structuredContent.accepted_count").value(3)); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + assertThat(transitionCount).isPositive(); + } + + @Test + void shouldLetBusinessLayerPersistInvalidV3EventAsContractError() throws Exception { + String externalId = "mail-mcp-v3-business-contract-error-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalId); + String businessRoot = businessRootWithInvalidEventShape(externalId); + + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", AUTHORIZATION) + .content(toolCall("mcp-submit-v3-business-contract-error-001", businessRoot))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.isError").value(false)) + .andExpect(jsonPath("$.result.structuredContent.items[0].system_process_category") + .value("ADAPTER_CONTRACT_ERROR")) + .andExpect(jsonPath("$.result.structuredContent.items[0].adapter_error_code") + .value("EVENT_CONTRACT_INCOMPLETE")); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND result_type = 'adapter_contract_error' + """, Long.class, source.inboxId()); + assertThat(transitionCount).isEqualTo(1L); + } + + @Test + void shouldSubmitS10ThroughMcpAdapter() throws Exception { + String externalId = "mail-mcp-s10-001"; + captureSourceMessage(externalId); + + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", AUTHORIZATION) + .content(toolCall("mcp-submit-s10-001", s10NotificationRoot(externalId)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.isError").value(false)) + .andExpect(jsonPath("$.result.structuredContent.accepted_count").value(1)) + .andExpect(jsonPath("$.result.structuredContent.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY")); + } + + @Test + void shouldSubmitS99ThroughMcpAdapter() throws Exception { + String externalId = "mail-mcp-s99-001"; + captureSourceMessage(externalId); + + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", AUTHORIZATION) + .content(toolCall("mcp-submit-s99-001", s99NotificationRoot(externalId)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.isError").value(false)) + .andExpect(jsonPath("$.result.structuredContent.accepted_count").value(1)) + .andExpect(jsonPath("$.result.structuredContent.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY")); } private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) { @@ -264,6 +435,137 @@ class SuperAgentMcpSubmitEnabledControllerTest { return objectMapper.writeValueAsString(root); } + private String parentSplitBusinessRootWithoutSourceMessage(String externalMessageId) throws Exception { + ObjectNode root = (ObjectNode) objectMapper.readTree(parentSplitBusinessRoot(externalMessageId)); + root.remove("source_message"); + return objectMapper.writeValueAsString(root); + } + + private String parentSplitBusinessRootWithRootRelationshipType(String externalMessageId) throws Exception { + ObjectNode root = (ObjectNode) objectMapper.readTree(parentSplitBusinessRoot(externalMessageId)); + ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2); + parentEvent.put("relationship_type", "linked_parent_release_after_child_split"); + return objectMapper.writeValueAsString(root); + } + + private String businessRootWithInvalidEventShape(String externalMessageId) throws Exception { + ObjectNode root = (ObjectNode) objectMapper.readTree(parentSplitBusinessRoot(externalMessageId)); + ArrayNode events = objectMapper.createArrayNode(); + ObjectNode invalidEvent = ((ObjectNode) root.path("message_events").get(0)).deepCopy(); + invalidEvent.put("event_type", "Unsupported Event Type"); + events.add(invalidEvent); + root.set("message_events", events); + return objectMapper.writeValueAsString(root); + } + + private String traceCrossChildBusinessRoot(String externalMessageId) throws Exception { + ObjectNode root = (ObjectNode) objectMapper.readTree(parentSplitBusinessRoot(externalMessageId)); + ArrayNode events = objectMapper.createArrayNode(); + events.add(root.path("message_events").get(0)); + events.add(root.path("message_events").get(1)); + ObjectNode trace = objectMapper.createObjectNode(); + trace.put("event_type", "Trace"); + trace.put("event_role", "travel_agent_request"); + trace.put("source_event_index", "E_TRACE"); + trace.put("current_or_history", "current"); + ObjectNode caseKeys = objectMapper.createObjectNode(); + caseKeys.putNull("group_code"); + caseKeys.putNull("confirmation_number"); + caseKeys.putNull("reservation_number"); + caseKeys.putNull("block_code"); + trace.set("case_keys", caseKeys); + trace.put("relevant_message_excerpt", "Please add a shared trace to both child groups."); + trace.set("attachments", objectMapper.createArrayNode()); + trace.set("file_references", objectMapper.createArrayNode()); + trace.set("context_used", objectMapper.createObjectNode()); + trace.put("related_event_type", "New Booking"); + ArrayNode related = objectMapper.createArrayNode(); + related.add("E_CHILD_1"); + related.add("E_CHILD_2"); + trace.set("related_source_event_indices", related); + ObjectNode extractedFields = objectMapper.createObjectNode(); + extractedFields.put("trace_subtype", "general_request"); + trace.set("extracted_fields", extractedFields); + trace.set("manual_review", objectMapper.nullNode()); + events.add(trace); + root.set("message_events", events); + return objectMapper.writeValueAsString(root); + } + + private String s10NotificationRoot(String externalMessageId) { + return """ + { + "source_message": { + "source_message_id": "%s", + "subject": "Informational message", + "from": "agent@example.test", + "cc": [], + "received_at": "2026-07-12T04:00:00Z", + "source_channel": "Email" + }, + "route_code": "S10", + "handler_type": "main_agent_outcome", + "result_type": "source_message_review_notification", + "current_or_history": "current", + "agent_assessment": { + "status": "no_booking_action_detected", + "reason_code": "no_booking_action_detected", + "automation_action": "none" + }, + "notification": { + "required": true, + "notification_type": "source_message_review", + "show_source_message": true, + "requires_user_decision": true, + "visible_message": "纯信息类邮件,请人工查看原邮件。" + }, + "manual_review": null + } + """.formatted(externalMessageId); + } + + private String s99NotificationRoot(String externalMessageId) { + return """ + { + "source_message": { + "source_message_id": "%s", + "subject": "Material unavailable", + "from": "agent@example.test", + "cc": [], + "received_at": "2026-07-12T04:00:00Z", + "source_channel": "Email" + }, + "route_code": "S99", + "handler_type": "main_agent_outcome", + "result_type": "source_message_review_notification", + "current_or_history": "current", + "agent_assessment": { + "status": "material_package_unavailable", + "reason_code": "material_package_unavailable", + "automation_action": "none" + }, + "notification": { + "required": true, + "notification_type": "source_message_review", + "show_source_message": true, + "requires_user_decision": true, + "visible_message": "无法形成业务素材包,请人工查看原邮件。" + }, + "manual_review": { + "reason_code": "material_package_unavailable", + "visible_reason": "邮件内容无法形成业务素材包。", + "review_record_type": "main_agent_entry_review", + "missing_fields": [], + "blocking_points": [], + "conflicting_points": [], + "suggested_human_actions": [], + "evidence_to_check": [], + "known_fields": {} + } + } + """.formatted(externalMessageId); + } + private String parentSplitBusinessRoot(String externalMessageId) { return """ {