diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index d64d21c..c0f7ce3 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -12,7 +12,7 @@ - `GET /api/source-messages/{id}/original`:受控读取邮件原文、HTML 和媒体 URL,并记录访问审计。 - `GET /api/system/agentbus-probe`:查看 AgentBus WebSocket 连接状态和安全计数器。 - AgentBus WebSocket 入站链路:默认关闭,开启后只把业务 frame 写入 SourceMessage Inbox。 -- SuperAgent 任务结果接收接口:接收一个 `source_message_id` 下的 AI 任务结果,写入 AI 过渡层、订单、任务和任务卡。 +- SuperAgent 任务结果接收接口:接收一个外部 `source_message_id` 下的 AI 任务结果,反查 SourceMessage Inbox 后写入 AI 过渡层、订单、任务和任务卡。 - Reservation 任务详情接口:返回任务字段、队列可处理状态和 OPERA 模拟操作摘要。 - Reservation 任务草稿保存和最终确认接口:按任务卡矩阵做第一版后端校验,确认后生成 `confirmed_payload_json`。 - Reservation OPERA 模拟骨架:已确认任务固定生成两条模拟操作,支持执行、失败重试、attempt 记录和任务审计列表。 @@ -97,6 +97,8 @@ - 查询接口和任务结果通知接口使用同一套 Header、签名串、secret、timestamp 和 nonce 规则。 - SuperAgent 侧也需要配置同一个 secret,并按原始请求体计算 SHA-256。 - 当前第一版只支持一个 HMAC secret,secret 轮换需要协调部署窗口。 +- 任务结果通知接口里的 `source_message_id` 是外部来源消息 ID,对应 AgentBus `source.external_message_id`;正式请求必须带 `hotel_id`,后端用 `hotel_id + provider + channel + external_message_id` 反查内部 SourceMessage Inbox。 +- SuperAgent 查询上下文接口中的 `source_message_id`、`source_event_index` 第一版仅兼容接收,不参与查询和校验;不要依赖它们限制查询范围。 ## 4. 数据库上线注意事项 diff --git a/docs/project/integrations/superagent-api-contract.md b/docs/project/integrations/superagent-api-contract.md index c9a2608..8b76945 100644 --- a/docs/project/integrations/superagent-api-contract.md +++ b/docs/project/integrations/superagent-api-contract.md @@ -4,7 +4,7 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.1 | +| 文档版本 | 0.2 | | 日期 | 2026-07-08 | | 状态 | 第一版后端已实现接口契约 | | 适用范围 | SuperAgent 调用本系统查询上下文、提交 AI 任务结果 | @@ -73,9 +73,22 @@ X-TH-Hotel-SuperAgent-Signature: sha256= 8. 查询接口校验 `Content-Type` 是否为 `application/json`。 9. 鉴权和协议校验通过后再解析业务 JSON。 -## 3. 接口 1:查询订单上下文 +## 3. SourceMessage ID 口径 -### 3.1 请求 +本系统存在两个容易混淆的 ID: + +| 名称 | 中文说明 | 使用位置 | +| --- | --- | --- | +| 外部来源消息 ID | AgentBus 邮件 payload 中的 `source.external_message_id`,SuperAgent / Main Agent 在最终 JSON 中原样带回为 `source_message_id` | SuperAgent 任务结果通知接口入参和响应回显 | +| 内部 SourceMessage Inbox ID | `platform_source_message_inbox.id`,本系统数据库内部主键 | `workflow_*` 表的 `source_message_id` 外键、前端和运维排查 | + +SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通知接口收到外部 `source_message_id` 后,后端使用 `hotel_id + provider + channel + external_message_id` 反查内部 Inbox 记录,再用内部 ID 写入业务表。 + +查询接口 1、2 在 SuperAgent 查询阶段不依赖当前邮件是否已经入库。若请求体兼容旧契约传入 `source_message_id` 或 `source_event_index`,第一版后端会接收但忽略,不校验它们的格式,也不把它们作为查询边界。 + +## 4. 接口 1:查询订单上下文 + +### 4.1 请求 | 项目 | 内容 | | --- | --- | @@ -85,13 +98,11 @@ X-TH-Hotel-SuperAgent-Signature: sha256= | Content-Type | `application/json` | | 业务动作 | 只读查询,不创建任务、不修改订单、不写 OPERA | -### 3.2 请求体 +### 4.2 请求体 ```json { "hotel_id": "HOTEL-TEST", - "source_message_id": "1900000000000000001", - "source_event_index": 1, "group_code": "GRP-001", "confirmation_number": null, "reservation_no": null, @@ -106,8 +117,6 @@ X-TH-Hotel-SuperAgent-Signature: sha256= | 字段 | 是否必填 | 中文说明 | | --- | --- | --- | | `hotel_id` | 是 | 酒店上下文 ID | -| `source_message_id` | 否 | 当前 SourceMessage ID;全局上下文查询时可不传,只作为审计和排查字段 | -| `source_event_index` | 否 | 当前 AI 事件序号;全局上下文查询可不传,传入时必须为正整数 | | `group_code` | 条件必填 | Group / Allotment 查询 key | | `confirmation_number` | 条件必填 | FIT Confirmation Number 查询 key | | `reservation_no` | 条件必填 | OPERA reservation no;当前系统无可靠表源,只传该字段时会返回人工复核原因 | @@ -117,9 +126,9 @@ X-TH-Hotel-SuperAgent-Signature: sha256= `group_code`、`confirmation_number`、`reservation_no` 至少一个非空。当前稳定查询能力优先支持 `group_code` 和 `confirmation_number`。 -全局上下文查询只依赖 `hotel_id + 业务 key`;`source_message_id` 和 `source_event_index` 仅用于审计、追踪或排查,不作为查询边界。 +全局上下文查询只依赖 `hotel_id + 业务 key`;`source_message_id` 和 `source_event_index` 不作为查询边界,传入时也不会影响查询结果。 -### 3.3 成功响应 +### 4.3 成功响应 ```json { @@ -152,7 +161,7 @@ X-TH-Hotel-SuperAgent-Signature: sha256= } ``` -### 3.4 主要数据来源 +### 4.4 主要数据来源 | 返回字段 | 来源 | | --- | --- | @@ -161,9 +170,9 @@ X-TH-Hotel-SuperAgent-Signature: sha256= | `terminated_records[]` | 订单 `ENDED` / `LOGIC_DELETED`,任务 `FAILED` / `COMPLETED` | | `active_workflows[]` | 当前无独立 workflow 表,固定空数组 | -## 4. 接口 2:查询对象详情 +## 5. 接口 2:查询对象详情 -### 4.1 请求 +### 5.1 请求 | 项目 | 内容 | | --- | --- | @@ -173,7 +182,7 @@ X-TH-Hotel-SuperAgent-Signature: sha256= | Content-Type | `application/json` | | 业务动作 | 只读查询对象详情,不创建任务、不修改订单、不写 OPERA | -### 4.2 请求体 +### 5.2 请求体 ```json { @@ -191,7 +200,7 @@ X-TH-Hotel-SuperAgent-Signature: sha256= | `object_id` | 是 | 查询对象 ID,第一版只支持 `ORDER:{order_id}` | | `object_type` | 否 | 调用方对象类型提示,第一版不作为强校验 | -### 4.3 成功响应 +### 5.3 成功响应 ```json { @@ -237,9 +246,11 @@ X-TH-Hotel-SuperAgent-Signature: sha256= } ``` -## 5. 接口 3:SuperAgent 通知 AI 任务结果 +说明:接口 2 响应中的 `source_message_id` 当前是本系统内部 SourceMessage Inbox ID,用于对象溯源和排查;不要把该字段当作 SuperAgent 任务结果通知接口的外部 `source_message_id` 使用。 -### 5.1 请求 +## 6. 接口 3:SuperAgent 通知 AI 任务结果 + +### 6.1 请求 | 项目 | 内容 | | --- | --- | @@ -249,11 +260,12 @@ X-TH-Hotel-SuperAgent-Signature: sha256= | Content-Type | `application/json` | | 业务动作 | 接收 AI 任务结果,写入 AI 过渡层、订单、任务和任务卡 | -### 5.2 请求体 +### 6.2 请求体 ```json { - "source_message_id": "1900000000000000001", + "hotel_id": "HOTEL-TEST", + "source_message_id": "mail-20260708-0001", "ai_task_results": [ { "source_event_index": 1, @@ -287,7 +299,10 @@ X-TH-Hotel-SuperAgent-Signature: sha256= | 字段 | 是否必填 | 中文说明 | | --- | --- | --- | -| `source_message_id` | 是 | 本系统 SourceMessage ID,一次请求只能有一个 | +| `hotel_id` | 是 | 酒店上下文 ID,用于反查 SourceMessage Inbox 幂等键 | +| `source_message_id` | 是 | SuperAgent / Main Agent 原样带回的外部来源消息 ID,对应 AgentBus `source.external_message_id`,一次请求只能有一个 | +| `source_provider` | 否 | 来源提供方,第一版缺省为 `AGENTBUS` | +| `source_channel` | 否 | 来源渠道,第一版缺省为 `EMAIL` | | `ai_task_results[]` | 是 | AI 拆分出的任务结果列表,必须保留数组顺序 | | `ai_task_results[].source_event_index` | 是 | AI current 事件序号 | | `ai_task_results[].catalog_code` | 是 | Skill 目录代码 | @@ -298,12 +313,14 @@ X-TH-Hotel-SuperAgent-Signature: sha256= | `ai_task_results[].case_keys` | 否 | 订单关联候选键 | | `ai_task_results[].extracted_fields` | 否 | 业务字段主体 | -### 5.3 成功响应 +正式联调时,后端通过 `hotel_id + source_provider(默认 AGENTBUS) + source_channel(默认 EMAIL) + source_message_id` 查找 `platform_source_message_inbox.external_message_id`。如果没有找到,返回 `SOURCE_MESSAGE_NOT_FOUND`。本地旧夹具允许在缺少 `hotel_id` 时使用内部数字 SourceMessage ID,但该兼容路径不作为 SuperAgent 正式契约。 + +### 6.3 成功响应 ```json { "request_id": "req-003", - "source_message_id": "1900000000000000001", + "source_message_id": "mail-20260708-0001", "batch_id": "1900000000000000200", "idempotent_replay": false, "accepted_count": 1, @@ -324,9 +341,9 @@ X-TH-Hotel-SuperAgent-Signature: sha256= } ``` -## 6. 错误响应 +## 7. 错误响应 -### 6.1 查询接口错误响应 +### 7.1 查询接口错误响应 ```json { @@ -343,7 +360,7 @@ X-TH-Hotel-SuperAgent-Signature: sha256= } ``` -### 6.2 任务结果通知接口错误响应 +### 7.2 任务结果通知接口错误响应 ```json { @@ -354,7 +371,7 @@ X-TH-Hotel-SuperAgent-Signature: sha256= } ``` -### 6.3 常见错误码 +### 7.3 常见错误码 | 错误码 | HTTP 状态 | 中文说明 | | --- | --- | --- | @@ -368,9 +385,10 @@ X-TH-Hotel-SuperAgent-Signature: sha256= | `REQUEST_BODY_INVALID` | 400 | JSON 不合法 | | `QUERY_KEY_REQUIRED` | 400 | 查询接口缺少可用业务 key | | `OBJECT_NOT_FOUND` | 404 | 对象详情查询目标不存在 | -| `SOURCE_MESSAGE_NOT_FOUND` | 400 | 任务结果通知引用的 SourceMessage 不存在 | +| `HOTEL_ID_REQUIRED` | 400 | 任务结果通知使用外部 `source_message_id` 时缺少 `hotel_id` | +| `SOURCE_MESSAGE_NOT_FOUND` | 404 | 任务结果通知引用的外部来源消息尚未写入 SourceMessage Inbox | -## 7. HMAC 上线配置 +## 8. HMAC 上线配置 上线需要配置: diff --git a/docs/project/requirements/M002-ai-query-minimal-fields.md b/docs/project/requirements/M002-ai-query-minimal-fields.md index c1226c6..e028664 100644 --- a/docs/project/requirements/M002-ai-query-minimal-fields.md +++ b/docs/project/requirements/M002-ai-query-minimal-fields.md @@ -32,7 +32,7 @@ - `body_current` 才能触发业务动作;`body_thread` 只能作为目标绑定证据。 - 如果查询 key 来自历史线程,调用方必须传 `target_key_source=body_thread_evidence` 和 `body_thread_used_only_as_evidence=true`。 - 第一版不伪造 OPERA 字段。当前系统没有可靠来源的字段返回 `null`,并在 `warnings` 或 `hard_validation_warnings` 中说明。 -- SuperAgent 允许作为全局上下文查询方按任意业务 key 查询;`source_message_id` 和 `source_event_index` 只作为可选审计和排查字段,不作为查询边界。 +- SuperAgent 允许作为全局上下文查询方按任意业务 key 查询;`source_message_id` 和 `source_event_index` 在查询阶段对本系统没有业务作用,第一版接收但忽略,不做格式校验,也不作为查询边界。 - 导入契约没有显式要求 `hotel_id`,但本系统订单、任务和 AI 过渡表均按 `hotel_id` 隔离。第一版请求体必须显式传 `hotel_id`。 ## 3. Skill 对接口 1、2 的实际需要 @@ -120,7 +120,7 @@ POST 适用字段包括: -- `source_message_id` +- `source_message_id`,响应中的内部 SourceMessage Inbox ID;查询请求中若出现同名字段则视为 SuperAgent 透传字段,后端忽略 - `order_id` - `task_id` - `record_id` @@ -141,8 +141,8 @@ POST /api/ai-query/v1/case-context | 字段 | 是否必填 | 中文说明 | 当前系统来源或用途 | | --- | --- | --- | --- | | `hotel_id` | 是 | 酒店或业务上下文 ID | 用于隔离 `workflow_reservation_*` 表 | -| `source_message_id` | 否 | 当前 SourceMessage ID | 全局上下文查询可不传;传入时只做格式校验和排查辅助 | -| `source_event_index` | 否 | 当前 current 事件序号 | 全局上下文查询可不传;传入时必须为正整数 | +| `source_message_id` | 否 | SuperAgent 透传的外部来源消息 ID | 全局上下文查询可不传;传入时后端接收但忽略,不做格式校验 | +| `source_event_index` | 否 | SuperAgent 透传的 current 事件序号 | 全局上下文查询可不传;传入时后端接收但忽略,不做正整数校验 | | `group_code` | 条件必填 | Group / Allotment 优先业务 key | 查询 `GROUP_CODE` 类型订单和 AI 过渡记录 | | `confirmation_number` | 条件必填 | FIT 优先业务 key | 查询 `CONFIRMATION_NUMBER` 类型订单和 AI 过渡记录 | | `reservation_no` | 否 | OPERA reservation no | 当前无可靠表源,第一版不作为主查询条件 | @@ -185,7 +185,7 @@ POST /api/ai-query/v1/case-context | --- | --- | --- | --- | | `task_id` | 是 | 本系统任务 ID | `workflow_reservation_task.id` | | `order_id` | 是 | 任务当前挂靠订单 | `workflow_reservation_task.order_id` | -| `source_message_id` | 是 | 任务来源消息 ID | `workflow_reservation_task.source_message_id` | +| `source_message_id` | 是 | 任务来源内部 SourceMessage Inbox ID | `workflow_reservation_task.source_message_id` | | `source_event_index` | 是 | AI current 事件序号 | `workflow_reservation_ai_transition.source_event_index` | | `catalog_code` | 否 | Skill 目录代码 | `workflow_reservation_ai_transition.catalog_code` | | `skill_id` | 否 | Skill 标识 | `workflow_reservation_ai_transition.skill_id` | @@ -203,6 +203,8 @@ POST /api/ai-query/v1/case-context | `blocked_until_parent_completed` | 是 | 是否等待父任务完成 | `workflow_reservation_task.blocked_until_parent_completed` | | `last_updated_at` | 是 | 最近更新时间 | `workflow_reservation_task.updated_at` | +说明:响应字段中的 `source_message_id` 来自 `workflow_reservation_task.source_message_id`,表示本系统内部 SourceMessage Inbox ID;它不同于查询请求和任务结果通知请求中 SuperAgent 透传的外部来源消息 ID。 + 第一版 `pending_or_open_tasks[]` 至少包含状态为 `PENDING_CONFIRM`、`READY`、`EXECUTING` 的任务。`FAILED` 在本系统第一版视为结束状态,不阻塞后续任务。 #### `active_workflows[]` @@ -336,7 +338,7 @@ POST /api/ai-query/v1/object-detail | `temporary_order_code` | 是 | 临时订单编号 | `workflow_reservation_order.temporary_order_code` | | `display_name` | 是 | 用户可读展示名 | `workflow_reservation_order.display_name` | | `status` | 是 | 订单状态 | `workflow_reservation_order.order_status` | -| `source_message_id` | 是 | 首次创建订单的来源消息 | `workflow_reservation_order.source_message_id` | +| `source_message_id` | 是 | 首次创建订单的内部 SourceMessage Inbox ID | `workflow_reservation_order.source_message_id` | | `created_from_task_id` | 否 | 首次创建订单的任务 ID | `workflow_reservation_order.created_from_task_id` | | `created_at` | 是 | 创建时间 | `workflow_reservation_order.created_at` | | `last_updated_at` | 是 | 最近更新时间 | `workflow_reservation_order.updated_at` | @@ -455,7 +457,7 @@ POST /api/ai-query/v1/object-detail 已落地能力: -- 接口 1 可按 `hotel_id + group_code` 或 `hotel_id + confirmation_number` 查询订单上下文,允许不传 `source_message_id` 和 `source_event_index` 的全局上下文查询。 +- 接口 1 可按 `hotel_id + group_code` 或 `hotel_id + confirmation_number` 查询订单上下文,允许不传 `source_message_id` 和 `source_event_index` 的全局上下文查询;即使传入这两个字段,后端也不把它们作为查询或校验条件。 - 接口 1 返回 `matched_order_records`、`pending_or_open_tasks`、`active_workflows`、`terminated_records`、`target_object_validation` 和 `key_relationships`。 - `active_workflows` 当前无独立表源,固定返回空数组。 - 接口 2 支持 `ORDER:{order_id}` 查询本系统订单快照。 diff --git a/docs/project/requirements/M002-backend-checkpoint-plan.md b/docs/project/requirements/M002-backend-checkpoint-plan.md index bceefd3..6bd3d77 100644 --- a/docs/project/requirements/M002-backend-checkpoint-plan.md +++ b/docs/project/requirements/M002-backend-checkpoint-plan.md @@ -89,7 +89,7 @@ Controller、Service、Service 实现类的方法必须有中文注释。Entity 目标: - 建立 AI 接收批次表和 AI 过渡表。 -- 保存 `source_message_id + ai_task_results[] + extraction_warnings[]`。 +- 保存外部 `source_message_id + ai_task_results[] + extraction_warnings[]`,并将外部来源消息 ID 反查为内部 SourceMessage Inbox ID 后落业务表。 - 实现系统生成幂等键,保证重复提交不重复创建记录。 建议范围: @@ -98,13 +98,13 @@ Controller、Service、Service 实现类的方法必须有中文注释。Entity - Entity、Mapper、Repository。 - 批次幂等键生成。 - item 幂等键生成。 -- `source_message_id` 存在性校验。 +- 外部 `source_message_id` 存在性校验:通过 `hotel_id + provider + channel + external_message_id` 查找 SourceMessage Inbox。 - 保存 AI 原始 JSON 和常用物理列。 验收标准: - 同一请求重复提交返回幂等重放,不重复插入。 -- 同一 `source_message_id` 不同请求体第一版返回 `409`。 +- 同一外部 `source_message_id` 不同请求体第一版返回 `409`。 - 每个 item 保存 `array_index`、`source_event_index` 和 `execution_order`。 - 保存 `ai_payload_json`,用户后续修改不得覆盖该字段。 diff --git a/docs/project/requirements/M002-order-task-workflow-v2.md b/docs/project/requirements/M002-order-task-workflow-v2.md index 815a3c0..9959e53 100644 --- a/docs/project/requirements/M002-order-task-workflow-v2.md +++ b/docs/project/requirements/M002-order-task-workflow-v2.md @@ -82,7 +82,7 @@ AI 聚合输出的顶层结构应包含: | 字段 | 中文说明 | | --- | --- | -| `source_message_id` | 关联的 SourceMessage ID | +| `source_message_id` | SuperAgent / Main Agent 原样带回的外部来源消息 ID,对应 AgentBus `source.external_message_id`,不是本系统内部 SourceMessage Inbox 主键 | | `ai_task_results[]` | AI 拆分出的一个或多个任务结果,顺序必须保留 | | `extraction_warnings[]` | 抽取警告,不直接等同于业务任务 | @@ -108,7 +108,7 @@ AI 聚合输出的顶层结构应包含: | `additional_operations` | AI 建议的附加动作 | | `idempotency_key` | 幂等键,最终来源和生成规则待确认 | -待补充的 HTTP 契约包括 URL、Method、Header、鉴权、幂等策略、错误响应格式、是否允许重复推送同一个 `source_message_id`。 +HTTP 契约已在 `docs/project/requirements/M002-superagent-task-result-api-contract.md` 和 `docs/project/integrations/superagent-api-contract.md` 中补充。正式契约要求任务结果通知携带 `hotel_id + source_message_id`,其中 `source_message_id` 是外部来源消息 ID;后端会反查内部 SourceMessage Inbox ID 后再写入 `workflow_*` 表。 ## 6. AI 过渡层数据要求 @@ -131,7 +131,7 @@ AI 聚合输出的顶层结构应包含: | 物理列 | 中文说明 | | --- | --- | -| `source_message_id` | 来源消息 ID | +| `source_message_id` | 内部 SourceMessage Inbox ID,来自外部 `source_message_id` 反查后的 `platform_source_message_inbox.id` | | `source_event_index` | AI 事件序号 | | `catalog_code` | Skill 目录代码 | | `skill_id` | Skill 标识 | @@ -484,6 +484,7 @@ Fallback 处理规则: - SuperAgent 创建任务接口的 URL、Method、Header、鉴权和错误响应格式。 - SuperAgent 查询上下文接口 1、2 已实现最小字段版,并已启用与任务结果接收接口一致的 HMAC;接口 3 文件解析和接口 4 订单及任务查询仍需后续确认与实现,后续梳理未完成事项时必须持续提醒。 +- SuperAgent 任务结果通知中的 `source_message_id` 已确认是外部来源消息 ID,对应 AgentBus `source.external_message_id`;查询接口中的 `source_message_id` 和 `source_event_index` 第一版接收但忽略。 - `source_event_index`、批次 item index、`execution_order` 的最终编号规则是否都从 1 开始。 - SuperAgent 查询上下文接口 3、4 的 URL、入参、返回字段和鉴权方式。 - `Message Notification` 是否需要在前端订单列表上单独标识为只读提醒。 diff --git a/docs/project/requirements/M002-superagent-task-result-api-contract.md b/docs/project/requirements/M002-superagent-task-result-api-contract.md index e87956a..604ee84 100644 --- a/docs/project/requirements/M002-superagent-task-result-api-contract.md +++ b/docs/project/requirements/M002-superagent-task-result-api-contract.md @@ -4,7 +4,7 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.1 | +| 文档版本 | 0.2 | | 日期 | 2026-07-07 | | 状态 | 后端接口契约草稿 | | 适用范围 | SuperAgent / Main Agent 调用本系统提交 AI 任务结果 | @@ -34,6 +34,21 @@ - SuperAgent 不直连数据库,只能通过本接口提交任务结果。 - 本接口只做技术校验和系统接收,不替代用户确认和 OPERA 模拟操作。 +### 2.1 SourceMessage ID 口径 + +本接口请求体中的 `source_message_id` 是 SuperAgent / Main Agent 从通用溯源字段中原样带回的外部来源消息 ID,对应 AgentBus 邮件 payload 的 `source.external_message_id`,不是 `platform_source_message_inbox.id`。 + +后端正式处理路径: + +```text +hotel_id + source_provider(默认 AGENTBUS) + source_channel(默认 EMAIL) + source_message_id +→ platform_source_message_inbox.external_message_id +→ platform_source_message_inbox.id +→ workflow_* 表 source_message_id 内部外键 +``` + +数据库 `workflow_*` 表中的 `source_message_id` 仍然保存内部 SourceMessage Inbox ID。只有对外接口的 `source_message_id` 使用外部来源消息 ID。无 `hotel_id` 时仅兼容本地旧夹具使用内部数字 ID,正式 SuperAgent 调用不得依赖该兼容路径。 + ## 3. 鉴权方案 第一版使用 HMAC-SHA256 签名,做到简单、可实现、可防重放。 @@ -106,7 +121,8 @@ X-TH-Hotel-SuperAgent-Signature: sha256= ```json { - "source_message_id": "1900000000000000001", + "hotel_id": "HOTEL-TEST", + "source_message_id": "mail-20260708-0001", "ai_task_results": [ { "source_event_index": 1, @@ -140,7 +156,10 @@ X-TH-Hotel-SuperAgent-Signature: sha256= | 字段 | 是否必填 | 中文说明 | | --- | --- | --- | -| `source_message_id` | 是 | 关联本系统 SourceMessage ID;一次请求只能有一个 | +| `hotel_id` | 是 | 酒店上下文 ID,用于反查 SourceMessage Inbox 幂等键 | +| `source_message_id` | 是 | 外部来源消息 ID,对应 AgentBus `source.external_message_id`;一次请求只能有一个 | +| `source_provider` | 否 | 来源提供方,第一版缺省为 `AGENTBUS` | +| `source_channel` | 否 | 来源渠道,第一版缺省为 `EMAIL` | | `ai_task_results[]` | 是 | AI 拆分出的任务结果列表,必须保留数组顺序 | | `extraction_warnings[]` | 否 | 抽取警告;不直接等同于业务任务 | @@ -177,8 +196,9 @@ X-TH-Hotel-SuperAgent-Signature: sha256= - 鉴权签名合法。 - 请求体大小不超过限制。 - JSON 可解析。 +- `hotel_id` 存在。仅本地旧夹具兼容缺少 `hotel_id` 且 `source_message_id` 为内部数字 ID 的调用。 - 顶层只有一个 `source_message_id`。 -- `source_message_id` 对应的 SourceMessage 存在。 +- `source_message_id` 对应的外部来源消息已经写入 SourceMessage Inbox。 - `ai_task_results[]` 是非空数组。 - `result_type` 属于允许值。 - `task_type` 属于当前系统可识别的稳定值或可进入 Fallback 处理。 @@ -214,7 +234,7 @@ request_payload_sha256 = sha256(raw_request_body) batch_idempotency_key = sha256( "superagent-task-result-batch:v1" - + "|" + source_message_id + + "|" + source_message_internal_id + "|" + request_payload_sha256 ) ``` @@ -222,7 +242,7 @@ sha256( 作用: - 相同请求体重复提交时识别为幂等重放。 -- 同一个 `source_message_id` 如果提交了不同请求体,不会被误认为同一个批次。 +- 同一个外部 `source_message_id` 解析到的内部 SourceMessage 如果提交了不同请求体,不会被误认为同一个批次。 ### 6.3 item 幂等键 @@ -232,7 +252,7 @@ sha256( item_idempotency_key = sha256( "superagent-task-result-item:v1" - + "|" + source_message_id + + "|" + source_message_internal_id + "|" + source_event_index + "|" + array_index + "|" + catalog_code @@ -255,7 +275,7 @@ sha256( | 场景 | 处理方式 | | --- | --- | | 完全相同请求体重复提交 | 返回已有 batch 和 item,不重复创建任务 | -| 同一 `source_message_id` 不同请求体 | 作为潜在冲突处理,第一版建议拒绝并返回 `IDEMPOTENCY_CONFLICT`,除非后续明确支持重新抽取版本 | +| 同一外部 `source_message_id` 不同请求体 | 作为潜在冲突处理,第一版建议拒绝并返回 `IDEMPOTENCY_CONFLICT`,除非后续明确支持重新抽取版本 | | 同一请求内 item 幂等键重复 | 拒绝请求,返回 `DUPLICATE_TASK_RESULT_ITEM` | ## 7. 系统映射规则 @@ -286,7 +306,7 @@ sha256( ```json { "request_id": "req-20260707-0001", - "source_message_id": "1900000000000000001", + "source_message_id": "mail-20260708-0001", "batch_id": "1900000000000001001", "idempotent_replay": false, "accepted_count": 1, @@ -315,7 +335,7 @@ sha256( ```json { "request_id": "req-20260707-0002", - "source_message_id": "1900000000000000001", + "source_message_id": "mail-20260708-0001", "batch_id": "1900000000000001001", "idempotent_replay": true, "accepted_count": 1, @@ -352,8 +372,9 @@ sha256( | 409 | `AUTH_NONCE_REPLAY` | Nonce 重放 | | 413 | `REQUEST_BODY_TOO_LARGE` | 请求体过大 | | 400 | `INVALID_JSON` | JSON 不可解析 | +| 400 | `HOTEL_ID_REQUIRED` | 使用外部 `source_message_id` 时缺少 `hotel_id` | | 400 | `SOURCE_MESSAGE_REQUIRED` | `source_message_id` 缺失 | -| 404 | `SOURCE_MESSAGE_NOT_FOUND` | SourceMessage 不存在 | +| 404 | `SOURCE_MESSAGE_NOT_FOUND` | 外部来源消息尚未写入 SourceMessage Inbox | | 400 | `TASK_RESULTS_EMPTY` | `ai_task_results[]` 为空 | | 400 | `TASK_RESULT_UNSUPPORTED_TYPE` | `result_type` 或 `task_type` 不可识别 | | 400 | `DUPLICATE_TASK_RESULT_ITEM` | 同一请求内 item 重复 | @@ -371,8 +392,8 @@ sha256( - nonce 重放时返回 `409`。 - 签名错误时返回 `401`。 - 一个请求只能包含一个 `source_message_id`。 -- `source_message_id` 不存在时返回 `404`。 +- 外部 `source_message_id` 未匹配 SourceMessage Inbox 时返回 `404`。 - 相同请求重复提交不会重复创建任务。 -- 同一 `source_message_id` 不同请求体重复提交返回 `409`。 +- 同一外部 `source_message_id` 不同请求体重复提交返回 `409`。 - 成功接收后能保存 AI 过渡层记录,并返回 batch、task 和 order 标识。 - 日志和响应不暴露 secret、签名原文、完整邮件正文或附件 URL。 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationAiCaseContextQueryRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationAiCaseContextQueryRequest.java index 1e0d582..22ac76b 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationAiCaseContextQueryRequest.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationAiCaseContextQueryRequest.java @@ -6,8 +6,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; * SuperAgent 查询订单上下文请求。该请求只用于只读查询,不触发任务创建或 OPERA 写入。 * * @param hotelId 酒店上下文 ID,用于隔离订单、任务和 AI transition 数据 - * @param sourceMessageId 当前 SourceMessage ID,全局上下文查询可不传 - * @param sourceEventIndex 当前 AI 事件序号,全局上下文查询可不传,传入时必须为正整数 + * @param sourceMessageId SuperAgent 透传的外部来源消息 ID,本接口第一版接收但不作为查询边界 + * @param sourceEventIndex SuperAgent 透传的 AI 事件序号,本接口第一版接收但不作为查询边界 * @param groupCode Group Code / Allotment Code 查询 key * @param confirmationNumber Confirmation Number 查询 key * @param reservationNo OPERA reservation no,第一版无可靠表源,仅参与入参完整性校验 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/SuperAgentTaskResultResponse.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/SuperAgentTaskResultResponse.java index 4aecb23..0bdc181 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/SuperAgentTaskResultResponse.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/SuperAgentTaskResultResponse.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; /** - * SuperAgent 任务结果入站响应。创建成功和幂等重放都使用该结构。 + * SuperAgent 任务结果入站响应。source_message_id 对外回显 SuperAgent 传入的外部邮件 ID。 */ public record SuperAgentTaskResultResponse( @JsonProperty("request_id") diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java index 67dbd42..d3297ef 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java @@ -293,12 +293,6 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService throw badRequest("MISSING_REQUEST_BODY", "请求体不能为空"); } requireText(request.hotelId(), "HOTEL_ID_REQUIRED", "hotel_id 不能为空"); - if (trimToNull(request.sourceMessageId()) != null) { - parseLong(request.sourceMessageId(), "SOURCE_MESSAGE_ID_INVALID", "source_message_id 必须是数字字符串"); - } - if (request.sourceEventIndex() != null && request.sourceEventIndex() <= 0) { - throw badRequest("SOURCE_EVENT_INDEX_INVALID", "source_event_index 必须是正整数"); - } if (trimToNull(request.groupCode()) == null && trimToNull(request.confirmationNumber()) == null && trimToNull(request.reservationNo()) == null) { 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 9cda7a8..fa0468e 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 @@ -45,9 +45,12 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta private static final String FIELD_CONTRACT_VERSION = "code-v1"; private static final String BATCH_KEY_PREFIX = "superagent-task-result-batch:v1"; private static final String ITEM_KEY_PREFIX = "superagent-task-result-item:v1"; + private static final String DEFAULT_SOURCE_PROVIDER = "AGENTBUS"; + private static final String DEFAULT_SOURCE_CHANNEL = "EMAIL"; private static final int LENGTH_32 = 32; private static final int LENGTH_64 = 64; private static final int LENGTH_128 = 128; + private static final int LENGTH_256 = 256; private static final int MAX_QUEUE_ORDER_RETRY = 5; private final ObjectMapper objectMapper; @@ -73,14 +76,15 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta @Transactional public SuperAgentTaskResultResponse accept(String rawBody, String clientId, String requestId) { JsonNode root = parseJson(rawBody); - Long sourceMessageId = parseSourceMessageId(root); + ResolvedSourceMessage resolvedSourceMessage = resolveSourceMessage(root); + SourceMessageInboxSnapshot sourceMessage = resolvedSourceMessage.snapshot(); + Long sourceMessageId = sourceMessage.id(); + String responseSourceMessageId = resolvedSourceMessage.responseSourceMessageId(); JsonNode taskResults = root.path("ai_task_results"); if (!taskResults.isArray() || taskResults.isEmpty()) { throw error(HttpStatus.BAD_REQUEST, "TASK_RESULTS_EMPTY", "ai_task_results 不能为空。"); } - SourceMessageInboxSnapshot sourceMessage = sourceMessageInboxRepository.findById(sourceMessageId) - .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。")); String hotelId = sourceMessage.hotelId(); String requestPayloadSha256 = sha256(rawBody == null ? "" : rawBody); String batchIdempotencyKey = sha256(BATCH_KEY_PREFIX + "|" + sourceMessageId + "|" + requestPayloadSha256); @@ -89,7 +93,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta .findBatchBySourceMessageId(hotelId, sourceMessageId) .orElse(null); if (existingBatch != null) { - return handleExistingBatch(requestId, sourceMessageId, requestPayloadSha256, existingBatch); + return handleExistingBatch(requestId, responseSourceMessageId, requestPayloadSha256, existingBatch); } LocalDateTime now = nowUtc(); @@ -109,7 +113,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta if (batchId == null) { return handleExistingBatch( requestId, - sourceMessageId, + responseSourceMessageId, requestPayloadSha256, workflowRepository.findBatchBySourceMessageId(hotelId, sourceMessageId) .orElseThrow(() -> error(HttpStatus.CONFLICT, "IDEMPOTENCY_CONFLICT", "AI 批次并发写入状态不确定。"))); @@ -123,7 +127,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta return new SuperAgentTaskResultResponse( safeRequestId, - sourceMessageId.toString(), + responseSourceMessageId, batchId.toString(), false, responseItems.size(), @@ -148,7 +152,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta */ private SuperAgentTaskResultResponse handleExistingBatch( String requestId, - Long sourceMessageId, + String responseSourceMessageId, String requestPayloadSha256, ReservationAiBatchSnapshot existingBatch) { if (!requestPayloadSha256.equals(existingBatch.requestPayloadSha256())) { @@ -156,7 +160,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta } return new SuperAgentTaskResultResponse( requestId, - sourceMessageId.toString(), + responseSourceMessageId, existingBatch.id().toString(), true, existingBatch.itemCount() == null ? 0 : existingBatch.itemCount(), @@ -484,18 +488,46 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta } /** - * 解析 SourceMessage ID,接口一次只能处理一个来源消息。 + * 解析 SuperAgent 来源消息引用。正式契约使用外部邮件 ID,旧本地夹具仍兼容内部 SourceMessage ID。 */ - private Long parseSourceMessageId(JsonNode root) { - String rawId = trimToNull(textAt(root, "source_message_id")); - if (rawId == null) { + private ResolvedSourceMessage resolveSourceMessage(JsonNode root) { + String sourceMessageReference = trimToNull(textAt(root, "source_message_id")); + if (sourceMessageReference == null) { throw error(HttpStatus.BAD_REQUEST, "SOURCE_MESSAGE_REQUIRED", "source_message_id 缺失。"); } - try { - return Long.valueOf(rawId); - } catch (NumberFormatException exception) { - throw error(HttpStatus.BAD_REQUEST, "SOURCE_MESSAGE_REQUIRED", "source_message_id 格式无效。"); + validateLength(sourceMessageReference, "source_message_id", LENGTH_256); + + String hotelId = trimToNull(textAt(root, "hotel_id")); + if (hotelId == null) { + return resolveLegacyInternalSourceMessage(sourceMessageReference); } + validateLength(hotelId, "hotel_id", LENGTH_64); + String sourceProvider = optionalText(textAt(root, "source_provider"), "source_provider", LENGTH_32); + String sourceChannel = optionalText(textAt(root, "source_channel"), "source_channel", LENGTH_32); + String provider = sourceProvider == null ? DEFAULT_SOURCE_PROVIDER : sourceProvider; + String channel = sourceChannel == null ? DEFAULT_SOURCE_CHANNEL : sourceChannel; + SourceMessageInboxSnapshot sourceMessage = sourceMessageInboxRepository + .findByIdempotencyKey(hotelId, provider, channel, sourceMessageReference) + .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。")); + return new ResolvedSourceMessage(sourceMessage, sourceMessageReference); + } + + /** + * 兼容历史本地测试和旧接口调用:无 hotel_id 时按内部 SourceMessage ID 查询。 + */ + private ResolvedSourceMessage resolveLegacyInternalSourceMessage(String rawId) { + Long sourceMessageId; + try { + sourceMessageId = Long.valueOf(rawId); + } catch (NumberFormatException exception) { + throw error(HttpStatus.BAD_REQUEST, "HOTEL_ID_REQUIRED", "使用外部 source_message_id 时 hotel_id 不能为空。"); + } + SourceMessageInboxSnapshot sourceMessage = sourceMessageInboxRepository.findById(sourceMessageId) + .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。")); + String responseSourceMessageId = trimToNull(sourceMessage.externalMessageId()) == null + ? sourceMessageId.toString() + : sourceMessage.externalMessageId(); + return new ResolvedSourceMessage(sourceMessage, responseSourceMessageId); } /** @@ -619,6 +651,15 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta return value != null && !value.trim().isEmpty(); } + /** + * 已解析的来源消息。snapshot 使用内部 ID 参与落库,responseSourceMessageId 面向 SuperAgent 回显外部 ID。 + */ + private record ResolvedSourceMessage( + SourceMessageInboxSnapshot snapshot, + String responseSourceMessageId + ) { + } + /** * 将空白字符串转换为 null。 */ diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java index d9b231f..e95c456 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java @@ -72,7 +72,7 @@ class ReservationAiQueryControllerTest { "target_key_source": "body_current", "body_thread_used_only_as_evidence": false } - """.formatted(source.inboxId()); + """.formatted("mail-ai-query-case-001"); mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-case-001", "req-ai-query-case-001") .header("X-AI-Trace-Id", "trace-ai-query-case-001")) @@ -108,7 +108,7 @@ class ReservationAiQueryControllerTest { "source_event_index": 1, "group_code": "GRP-AIQUERY-NOT-FOUND" } - """.formatted(source.inboxId()); + """.formatted("mail-ai-query-empty-001"); mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-empty-001", "req-ai-query-empty-001")) .andExpect(status().isOk()) @@ -132,7 +132,7 @@ class ReservationAiQueryControllerTest { "source_event_index": 1, "reservation_no": "RESV-AIQUERY-001" } - """.formatted(source.inboxId()); + """.formatted("mail-ai-query-reservation-no-001"); mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-reservation-no-001", "req-ai-query-reservation-no-001")) @@ -165,6 +165,27 @@ class ReservationAiQueryControllerTest { .andExpect(jsonPath("$.data.target_object_validation.status").value("single")); } + @Test + void shouldIgnoreExternalSourceMessageIdInGlobalCaseContextQuery() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-external-001"); + insertActiveGroupOrder(920000000000000601L, source.inboxId(), "GRP-AIQUERY-EXTERNAL-001"); + String body = """ + { + "hotel_id": "HOTEL-TEST", + "source_message_id": "mail-ai-query-external-001", + "source_event_index": -1, + "group_code": "GRP-AIQUERY-EXTERNAL-001" + } + """; + + mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-external-001", + "req-ai-query-external-001")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.matched_order_records[0].object_id").value("ORDER:920000000000000601")) + .andExpect(jsonPath("$.data.target_object_validation.status").value("single")); + } + @Test void shouldRejectCaseContextWhenHmacSignatureInvalid() throws Exception { String body = """ diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java index f62ab34..bc3983c 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java @@ -108,7 +108,7 @@ class SuperAgentTaskResultControllerTest { mockMvc.perform(signedPost(body, "nonce-new-booking-temp-001")) .andExpect(status().isCreated()) - .andExpect(jsonPath("$.source_message_id").value(source.inboxId().toString())) + .andExpect(jsonPath("$.source_message_id").value("mail-new-booking-temp-001")) .andExpect(jsonPath("$.idempotent_replay").value(false)) .andExpect(jsonPath("$.accepted_count").value(1)) .andExpect(jsonPath("$.items[0].source_event_index").value(1)) @@ -145,6 +145,42 @@ class SuperAgentTaskResultControllerTest { org.assertj.core.api.Assertions.assertThat(taskCardCount).isGreaterThanOrEqualTo(1L); } + @Test + void shouldResolveTaskResultSourceMessageByExternalMessageId() throws Exception { + String externalMessageId = "mail-external-source-id-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalMessageId); + String body = minimalBodyWithHotel(externalMessageId, "New Booking", "normal_task", "new_fit_reservation", """ + "case_keys": {}, + "extracted_fields": {} + """); + + mockMvc.perform(signedPost(body, "nonce-external-source-id-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.source_message_id").value(externalMessageId)) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].task_status").value("PENDING_CONFIRM")); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + assertThat(transitionCount).isEqualTo(1L); + } + + @Test + void shouldRejectExternalSourceMessageIdWhenHotelIdMissing() throws Exception { + String body = minimalBody("mail-external-without-hotel-001", "New Booking", "normal_task", "new_fit_reservation", """ + "case_keys": {}, + "extracted_fields": {} + """); + + mockMvc.perform(signedPost(body, "nonce-external-source-without-hotel-001")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("HOTEL_ID_REQUIRED")) + .andExpect(jsonPath("$.message").value("使用外部 source_message_id 时 hotel_id 不能为空。")); + } + @Test void shouldReturnIdempotentReplayForSameBodyWithNewNonce() throws Exception { SourceMessageCaptureResult source = captureSourceMessage("mail-idempotent-replay-001"); @@ -1125,6 +1161,40 @@ class SuperAgentTaskResultControllerTest { """.formatted(sourceMessageId, resultType, taskType, taskSubtype, itemFields); } + private String minimalBodyWithHotel( + String sourceMessageId, + String taskType, + String resultType, + String taskSubtype, + String itemFields) { + return """ + { + "hotel_id": "HOTEL-TEST", + "source_message_id": "%s", + "ai_task_results": [ + { + "source_event_index": 1, + "catalog_code": "S01", + "skill_id": "S01_new_booking_skill", + "result_type": "%s", + "task_type": "%s", + "task_subtype": "%s", + "current_or_history": "current", + "visible_reason": "AI extracted a task result.", + "relevant_message_excerpt": "Please handle booking message.", + "attachments": [], + "file_references": [], + "context_used": {}, + %s, + "additional_operations": [], + "idempotency_key": null + } + ], + "extraction_warnings": [] + } + """.formatted(sourceMessageId, resultType, taskType, taskSubtype, itemFields); + } + private String twoTaskBody(String sourceMessageId, String groupCode) { return """ {