diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更与路由说明_3.0_to_current.md b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更与路由说明_3.0_to_current.md new file mode 100644 index 0000000..d27a112 --- /dev/null +++ b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更与路由说明_3.0_to_current.md @@ -0,0 +1,306 @@ +# 任务卡前端字段变更与路由说明(3.0 → 当前版) + +> 对比基线:`任务卡前端展示字段表 3.0.xlsx` +> P0 契约基线:2026-07-11 00:32:56(Asia/Shanghai) +> `booking-desk-event.skill` SHA-256:`64063a6d24c2e70bb0d9d557923fdbecb9e133c1ea5971f3858df03469e48693` +> `归档.zip` SHA-256:`ebf3806cbdf1605d3a023fecff76654ba228c4d5eaf1cf68b609633bb07520cb` + +> 文档定位:本文是生产 Prompt、Skill 和 references 的派生开发交接件,不是覆盖上游的第二份规则源。如本文与生产契约不一致,adapter 必须 fail closed,停止使用冲突路由并修复交接件;不得声称本文自动覆盖 Prompt/Skill。 + +## 1. 本次 P0 收口结论 + +1. Gateway 在调用 Main Agent 前校验 `source_message.source_message_id`;缺失时只返回基础设施错误,不读正文、附件、OCR、历史或系统上下文,不调用 Skill,不建卡或通知。 +2. `S10` 和 `S99` 是两份不同的完整根契约,使用 `route_code` 判别;S10 的 `manual_review=null`,S99 必须带完整 `main_agent_entry_review`。 +3. Main 只产生粗分类 `candidate_events[]`;Skill 决定最终 event type、subtype、目标拆分和派生事件。 +4. Adapter 按每个 `message_events[i]` 派生三元组,不在邮件根对整封邮件只计算一次。 +5. 业务类型和 subtype 已知时,人工复核保留原业务 `task_type + task_subtype`;只有类型或 subtype 本身不可确定时才走 Fallback。 +6. 房型不唯一时保留 `room_type_raw`、置 `pms_room_type_code=null`,并要求用户在当前有效 PMS 房型目录中选择。Parent split 不再存在 generic `SUITE` 普通候选例外。 +7. Parent split 固定产生 N 个 Child Group New Booking 和 1 个 Parent Group Cancel Booking 候选;Parent 始终需要下游硬校验和 Preflight。 +8. 不再支持部分配额维护、部分释放或保留余量继续使用;独立新建 Allotment/Control Block 和明确整块取消仍受支持。 +9. “一行一事件”改为行级隔离:每个当前有效行独立处理,不跨行合并;Parent Cancel、linked Trace、TA Recorder 等明确派生事件可以额外产生。 +10. 复核在同一张业务卡上解阻;不新建第二张 linked normal task,不改写原 Agent payload。 + +## 2. 根结果分流 + +### 2.1 基础设施输入错误 + +`source_message.source_message_id` 为 `null`、空字符串或纯空白时,Gateway 只返回: + +```json +{ + "result_type": "infrastructure_input_error", + "error_code": "missing_source_message_id", + "retryable": true, + "missing_fields": [ + "source_message.source_message_id" + ] +} +``` + +该结果不是业务根、S10 或 S99,也不计入下文 42 条 adapter 路由。Adapter 应进入重新取数/重试流程;Main 若意外收到此类输入,使用同一结构作为防御性终态。 + +### 2.2 S10 和 S99 + +| `route_code` | 固定 `agent_assessment.status` | `manual_review` | Adapter 三元组 | 去向 | +| --- | --- | --- | --- | --- | +| `S10` | `no_booking_action_detected` | `null` | `source_message_review_notification + Message Notification + S10` | 展示源邮件,0 张业务卡 | +| `S99` | `material_package_unavailable` | 完整 `main_agent_entry_review` | `source_message_review_notification + Message Notification + S99` | 展示源邮件和入口复核信息,0 张业务卡 | + +两者的 `result_type` 都是 `source_message_review_notification`,所以必须使用 `route_code` 作为结构判别字段。不得接受 `S10 | S99`、联合 status 或 S99 空复核对象。 + +### 2.3 业务根 + +业务根固定包含: + +```json +{ + "source_message": {}, + "message_events": [], + "case_candidates": [], + "extraction_warnings": [], + "unhandled_current_intents": [] +} +``` + +Agent 不在邮件根输出业务 `result_type`、`task_type` 或 `task_subtype`,因为同一封邮件可同时存在 normal 和 manual-review sibling events。 + +## 3. Main → Skill 输入契约 + +`candidate_events[]` 是粗分类信号,不是最终业务事件。每项固定使用: + +```json +{ + "candidate_id": "C1", + "classification_status": "coarse_supported_signal", + "possible_event_types": [ + "Voucher Received", + "Payment Evidence" + ], + "evidence_refs": [ + { + "source": "current_attachments", + "index": 0 + } + ], + "target_hints": [], + "uncertainty_codes": [ + "document_subtype_unresolved" + ] +} +``` + +- `possible_event_types` 只允许 active event;禁止 S10、S99、Need Manual Review、Note 和 Allotment Maintenance。 +- Skill 在 `context_used.main_candidate_ids[]` 记录所使用的粗候选 ID。 +- Skill 否定全部粗候选时返回内部 `no_supported_event` disposition,由 Main 转成 S10;不伪装成人工复核或契约错误。 +- candidate 结构非法时 fail closed 为内部输入契约错误,不进入业务路由。 + +## 4. 42 条明确 Adapter 路由 + +### 4.1 19 个业务 subtype 的 normal/manual-review 成对路由 + +对下表每一行: + +- `event.manual_review=null` 时使用“普通三元组”; +- `event.manual_review` 是完整 `business_event_review` 时,使用“复核三元组”; +- 复核路由仅更改 `result_type`,保留同一业务 task type/subtype。 + +| # | Agent 判别 | 普通三元组 | 复核三元组 | +| ---: | --- | --- | --- | +| 1 | `New Booking` + `booking_object_type=FIT Reservation` | `normal_task + New Booking + new_fit_reservation` | `manual_review + New Booking + new_fit_reservation` | +| 2 | `New Booking` + `booking_object_type=Group Block` | `normal_task + New Booking + new_group_block` | `manual_review + New Booking + new_group_block` | +| 3 | `New Booking` + `booking_object_type=Allotment / Control Block` | `normal_task + New Booking + new_allotment_control_block` | `manual_review + New Booking + new_allotment_control_block` | +| 4 | `Update Booking / Amendment` | `normal_task + Update Booking + update_booking_amendment` | `manual_review + Update Booking + update_booking_amendment` | +| 5 | `Cancel Booking` + `cancel_object_type=fit_reservation` | `normal_task + Cancel Booking + cancel_fit_reservation` | `manual_review + Cancel Booking + cancel_fit_reservation` | +| 6 | `Cancel Booking` + `cancel_object_type=group_block` | `normal_task + Cancel Booking + cancel_group_block` | `manual_review + Cancel Booking + cancel_group_block` | +| 7 | `Cancel Booking` + `relationship_type=linked_parent_release_after_child_split` | `normal_task + Cancel Booking + linked_parent_release_after_child_split` | `manual_review + Cancel Booking + linked_parent_release_after_child_split` | +| 8 | `Cancel Allotment` | `normal_task + Cancel Allotment + cancel_allotment_control_block` | `manual_review + Cancel Allotment + cancel_allotment_control_block` | +| 9 | `Voucher Received` | `normal_task + Voucher Received + lian_tai_credit_voucher` | `manual_review + Voucher Received + lian_tai_credit_voucher` | +| 10 | `Payment Evidence` | `normal_task + Payment Evidence + payment_evidence` | `manual_review + Payment Evidence + payment_evidence` | +| 11 | `Rooming List` | `normal_task + Rooming List + rooming_list` | `manual_review + Rooming List + rooming_list` | +| 12 | `AMEND GROUP CODE` | `normal_task + Amend Group Code + amend_group_code` | `manual_review + Amend Group Code + amend_group_code` | +| 13 | `Trace` + `trace_subtype=extra_bed` | `normal_task + Trace / Reservation Notes + extra_bed` | `manual_review + Trace / Reservation Notes + extra_bed` | +| 14 | `Trace` + `trace_subtype=general_request` | `normal_task + Trace / Reservation Notes + general_request` | `manual_review + Trace / Reservation Notes + general_request` | +| 15 | `TA RECORDER` | `normal_task + TA Recorder + maintain_ta_recorder` | `manual_review + TA Recorder + maintain_ta_recorder` | +| 16 | `Invoice Generation` | `normal_task + Invoice Generation + invoice_generation` | `manual_review + Invoice Generation + invoice_generation` | +| 17 | `Invoice Received` | `normal_task + Invoice Received + invoice_received` | `manual_review + Invoice Received + invoice_received` | +| 18 | `Payment Notice` | `normal_task + Payment Notice + payment_notice` | `manual_review + Payment Notice + payment_notice` | +| 19 | `Manual RateCode` | `normal_task + Manual RateCode + manual_rate_code` | `manual_review + Manual RateCode + manual_rate_code` | + +上表是 19 条 normal + 19 条 type-known manual review,共 38 条。 + +### 4.2 其余 4 条 + +| # | 上游判别 | Adapter 三元组 | 去向 | +| ---: | --- | --- | --- | +| 39 | 根 `route_code=S10` | `source_message_review_notification + Message Notification + S10` | 源邮件通知,不建卡 | +| 40 | 根 `route_code=S99` | `source_message_review_notification + Message Notification + S99` | 源邮件 + 入口复核信息,不建卡 | +| 41 | `event_type=Need Manual Review` | `manual_review + Fallback + business_event_review` | 类型/subtype 未知的业务复核卡 | +| 42 | 每个 `unhandled_current_intents[i]` | `unhandled_current_intent + Unhandled Current Intent + requires_business_approval_or_unsupported_task_card` | 邮件详情展示块,不建卡 | + +`Note` 和 `Allotment Maintenance` 只是 legacy accepted event,各自为 0 条合法三元组。 + +## 5. 人工复核和同卡解阻 + +### 5.1 Agent 输出规则 + +- 业务类型和 subtype 已知:保留原 `event_type` 和 subtype 判别字段,继续输出可安全抽取值,`manual_review` 为完整对象。 +- 只有类型或 subtype 本身无法确定:`event_type=Need Manual Review`,adapter 走 Fallback。 +- `manual_review.missing_fields[]` 每项是指向当前 event 的 RFC 6901 JSON Pointer,例如 `/extracted_fields/room_items/0/pms_room_type_code`。 +- 不新增 `required_user_inputs[]`。Pointer 无法映射到已知可编辑业务字段时,属于 `adapter_contract_error`。 +- 房型必须从信息系统当前有效 PMS 房型目录选择,不得猜测或自由填写 PMS code。 + +### 5.2 信息系统状态 + +初始: + +```json +{ + "review_status": "pending", + "review_resolution": null +} +``` + +用户确认后: + +```json +{ + "review_status": "resolved", + "review_resolution": { + "field_overrides": [ + { + "field_pointer": "/extracted_fields/room_items/0/pms_room_type_code", + "value": "SU1" + } + ], + "resolved_by": "", + "resolved_at": "" + } +} +``` + +实施要求: + +- 原 Agent payload 和初始 `result_type=manual_review` 不可变;页面当前模式由 `review_status` 控制。 +- 用户覆盖值保存在 `review_resolution.field_overrides[]`,不回写 Agent 原值。 +- 所有缺失字段完成、目录校验和依赖字段重验通过后,才进入现有 Preflight。 +- 不创建第二张 linked normal task。 +- 业务字段可编辑;邮件原文、raw evidence、source ID 和审计字段只读。 + +## 6. Parent split、配额和行级隔离 + +### 6.1 Parent split + +1 Parent 拆成 N Children 时: + +- 每个 Child 输出 `New Booking`,`booking_object_type=Group Block`,`case_keys.group_code=`。 +- Parent 额外输出一个 `Cancel Booking`,`cancel_object_type=group_block`,`relationship_type=linked_parent_release_after_child_split`。 +- `child_group_codes[]` 与 `related_source_event_indices[]` 数量和顺序一一对应;每个 index 必须指向同根中对应 Child Group New Booking。 +- 一个 split cluster 只有一个 Parent 候选;Parent 始终 `requires_downstream_hard_validation=true`。 +- 禁止走 Update、Cancel Allotment、Allotment Maintenance 或单个复合事件。 + +某个 Child 房型不唯一时,该 Child 仍是 `New Booking + new_group_block`,但 adapter 派生该卡的 manual-review mode;其他明确 Child 仍走 normal mode。Parent Cancel 候选仍保留并经下游硬校验,不得因一个 Child 歧义丢弃 sibling events。 + +### 6.2 Allotment 范围 + +| 当前意图 | 结果 | +| --- | --- | +| 独立新建 Allotment/Control Block | `New Booking + new_allotment_control_block` | +| 明确整块取消 | `Cancel Allotment + cancel_allotment_control_block` | +| 明确部分日期/房量维护、部分释放或保留余量,且是唯一意图 | Main 输出 S10 | +| 上述不支持意图与 active event 混合 | active events 正常输出,该意图进入 `unhandled_current_intents[]` | +| 无法判断是整块拆分/取消还是部分保留 | 因 type/subtype 未知,走 `Need Manual Review` Fallback | + +当前 Agent 不得生成 `update_allotment_control_block` 或 `Allotment Maintenance`。 + +### 6.3 行级隔离 + +每个当前有效行独立处理,不跨行合并。普通情况下每行、每目标产生一个主事件;Parent Cancel、linked Trace、TA Recorder 等契约明确的 derived event 可以额外产生。测试不再断言“事件数=行数”,而是检查行/目标未跨行合并、主事件不遗漏、必需派生事件完整和关系引用有效。 + +## 7. 3.0 字段和路由迁移 + +### 7.1 必须新增或修改 + +| 作用域 | 字段/行为 | 开发动作 | +| --- | --- | --- | +| Gateway | `infrastructure_input_error` | 在 Main 前校验 source ID;独立重试,禁止建卡/通知 | +| Main/Skill | `candidate_events[]` 粗候选 | 实现 04 输入契约;不把粗候选当最终类型 | +| 业务根 | 五个固定顶层字段 | 按 `message_events[]` 逐项路由;`unhandled_current_intents[]` 只展示 | +| Event | `manual_review: object|null` | 按是否为 null 派生 normal/manual mode;保留业务 subtype | +| Manual review | `missing_fields[]` | 仅接受 RFC 6901 pointer,映射到业务卡编辑控件 | +| Review state | `review_status/review_resolution` | 由信息系统持有,不改写 Agent payload | +| 房型 | `room_type_raw` / `pms_room_type_code` | 分开原文与 PMS code;歧义时 raw 保留、code 为 null | +| 事件关系 | `source_event_index/related_*` | 仅为本次输出内引用,不是 TaskCard/PMS ID | +| Parent split | `child_group_codes[]` + `related_source_event_indices[]` | 强制数量、顺序一一对应,Parent 仅一个 | +| Trace | `trace_items[]` | 同一目标多条补充合并一张 Trace,不按 item 拆卡 | +| Rooming List | 派生 TA Recorder | 每个已确认 target 产生对应关联事件 | +| Update | 多修改项 | subtype 固定 `update_booking_amendment`,修改动作/前后值另存明细 | + +### 7.2 Legacy/禁止新生成 + +| 旧值或旧行为 | 当前处理 | +| --- | --- | +| `manual_review + 各业务 task_type + manual_review` 旧 subtype | 能稳定推导业务 subtype 时归一到本文对应 type-known 复核三元组;不能推导时才走 Fallback | +| `Note` | 仅历史兼容;当前补充信息统一 Trace | +| `Allotment Maintenance` | 仅历史兼容;当前不新生成 | +| `update_allotment_control_block` | 禁止当前生成;不支持部分配额维护 | +| Parent split 当 Update/Allotment Maintenance/Cancel Allotment | 禁止;固定 Child Group New + Parent Group Cancel | +| Parent generic `SUITE` normal 候选例外 | 删除;保留 New Group 卡型但走该卡 manual-review mode | +| “一行一事件”绝对计数 | 删除;改为行级隔离 + 明确派生事件 | + +## 8. Adapter 顺序和 fail-closed + +```text +0. Gateway 先验 source_message_id;缺失时只返基础设施错误。 +1. 根含 route_code 时,严格校验完整 S10 或 S99 结构。 +2. 否则严格校验五字段业务根。 +3. 遍历 message_events[]:先由 event_type/subtype 得到业务卡型,再由 manual_review 是否为 null 得到 mode。 +4. 遍历 unhandled_current_intents[],只产生展示块。 +5. 将候选卡送入现有 Case / Preflight / 去重 / 锁 / 状态校验。 +``` + +下列情况必须 fail closed: + +- candidate 输入结构非法:内部输入契约错误,不进业务路由。 +- S10/S99 联合值、S10 带复核对象、S99 空复核对象:根契约错误。 +- 已知业务路由缺少 subtype 判别字段,或未知/当前禁用 `event_type`:`adapter_contract_error`,该 event 0 卡,siblings 继续。 +- `manual_review` 九字段不完整,或 `missing_fields[]` 不是有效/可映射 JSON Pointer:`adapter_contract_error`。 +- Parent relationship 字段不完整或一一对应失败:拒绝 Parent event,不猜关系。 +- 业务字段未确认、`review_status!=resolved` 或 Preflight 未通过:禁止写 PMS。 + +契约错误不等于业务人工复核,不得用 Fallback 吞掉接口问题。 + +## 9. P0 最低验收 + +| 场景 | 必须结果 | +| --- | --- | +| S10/S99 合法样例 | 各自通过;三种联合/空复核非法结构被拒绝 | +| 图片付款凭证 | Main 粗候选可包含 Voucher/Payment;Skill 最终判型,并记录 candidate ID | +| Skill 否定全部粗候选 | 内部 `no_supported_event` → Main S10,不是 Fallback/契约错误 | +| 1 Parent → 2 Children | 2 Child New Group + 1 Parent Group Cancel;关系数组一一对应 | +| 两个 split clusters | 每个 cluster 恰好一个 Parent,关系不串组 | +| 一个 Child 房型 `SUITE` 不唯一 | 该 Child:`manual_review + New Booking + new_group_block`;sibling Child 仍 normal;Parent 候选仍在 | +| Update 房型歧义 | `manual_review + Update Booking + update_booking_amendment`,保留安全已知字段 | +| Update 只改日期,附带无关房型文字 | 不因无关文字进人工复核 | +| 业务类型/subtype 未知 | `manual_review + Fallback + business_event_review` | +| 部分配额维护单独出现 | S10 | +| 部分配额维护 + 支持事件 | 支持事件照常输出 + `unhandled_current_intents[]` | +| 独立新建/明确整块取消配额 | New Allotment / Cancel Allotment 仍正常 | +| 同卡解阻 | 目录值、覆盖审计、状态转换通过;Preflight 前 0 次 PMS 写入 | +| 缺 source ID,正文为 Cancel/Thanks/see attached | 全部得到同一 infrastructure error;Skill/附件/历史查询调用数都为 0 | + +## 10. 生产源与部署必读 + +权威顺序: + +1. `prompts/main_agent_prompt.md` +2. `skills/booking-desk-event/SKILL.md` +3. `skills/booking-desk-event/references/00-output-contract.md` +4. `skills/booking-desk-event/references/04-main-skill-input-contract.md` +5. `skills/booking-desk-event/references/01-current-history-boundary.md` +6. `skills/booking-desk-event/references/03-current-content-completeness.md` +7. `skills/booking-desk-event/references/02-event-routing-map.md` +8. 各业务 reference,特别是 `10`–`18`、`30`、`31`、`50`–`54` 和 `90` + +`task_plan.md`、`findings.md`、`progress.md`、审计报告和本文都是过程/派生交付件,不是可以反向覆盖生产契约的规则源。 + +部署时必须同步发布 Main Prompt 和 `.skill` 包,并使用本文页首 SHA-256 校验 skill 包。本仓库没有 adapter/frontend 代码;实际实现必须以上述契约和 42 条路由为验收基线。 diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更说明_3.0_to_当前版_2026-07-10.xlsx b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更说明_3.0_to_当前版_2026-07-10.xlsx new file mode 100644 index 0000000..fcaa760 Binary files /dev/null and b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更说明_3.0_to_当前版_2026-07-10.xlsx differ diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/booking-desk-event.skill b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/booking-desk-event.skill new file mode 100644 index 0000000..3734e66 Binary files /dev/null and b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/booking-desk-event.skill differ diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/main_agent_prompt.md b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/main_agent_prompt.md new file mode 100644 index 0000000..7f204a5 --- /dev/null +++ b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/main_agent_prompt.md @@ -0,0 +1,320 @@ +# 预订邮件 Main Agent Prompt + +你是酒店预订邮件 Main Agent。你负责完成入口校验,判断一封新邮件是否存在可交给预订部业务处理的粗粒度 active 信号,并把当前邮件、附件、历史证据和系统上下文整理成可交给 `booking-desk-event` skill 的素材包。 + +你不是最终业务裁判。你可以取得附件、执行 OCR、读取原始表格字段并形成多个粗候选,但不得把这些粗候选当作最终文件 subtype、event type、目标拆分或业务字段裁决。不要判断房型映射、Rate Code、最终 Case 是否存在、任务是否可执行、Payment 是否确认、Opera/PMS 写入、Invoice 或 Receipt。 + +你只输出结构化 JSON 参数对象,不输出解释性自然语言,不把最终结果生成为文件。 + +## 1. 职责 + +你负责: + +- 判断当前邮件是否包含新的业务动作、与具体预订对象相关的当前补充业务信息、当前附件、图片、PDF、表格、OCR、文件链接,或明确继续处理指令,并按 `04-main-skill-input-contract.md` 形成 current evidence 支持的粗候选。补充信息即使只是告知,也可以形成 Trace 粗候选。 +- 判断本次邮件提供的信息是否足够绑定目标对象,在信息不足时,查询结果的历史邮件补证。 +- 在获得目标 key 后,按需查询信息系统上下文。 +- 整理素材包并调用 `booking-desk-event`。 +- 对输入可理解但未匹配当前 Agent 支持业务事件的邮件输出入口通知结果 `S10`。 +- 对输入不足、无法判断是否匹配支持业务事件的入口问题输出源邮件查看通知结果 `S99`。 + +你不得: + +- 用历史邮件里的旧动作触发当前业务。 +- 为只有感谢、裸 FYI、noted、received、confirmed receipt 且没有具体预订业务信息的邮件查询历史。 +- 编造 Case、Group Block、Reservation、pending task、workflow lock、房型或 Rate Code。 +- 创建真实 TaskCard 或写任何外部系统。 + +## 2. Step 0 与当前邮件优先 + +任何正文、附件、OCR、历史或系统处理前,必须先校验 `source_message.source_message_id`。该值必须由上游提供,且为非空、非空白字符串;不得猜测、生成或从 group code、confirmation number、reservation number 等其他编号推导。 + +如果 `source_message_id` 缺失、为 `null`、空字符串或只包含空白,立即直接返回: + +```json +{ + "result_type": "infrastructure_input_error", + "error_code": "missing_source_message_id", + "retryable": true, + "missing_fields": ["source_message.source_message_id"] +} +``` + +这是独立的基础设施输入错误,不是 S10、S99、业务事件或人工复核。不得继续读取正文、下载附件、执行 OCR、查询历史或系统、调用 `booking-desk-event`、展示源邮件通知或创建任何业务结果。 + +只有当前新邮件可以触发业务事件。 + +通过 Step 0 后先保存完整 `source_message` identity。对于合规的 `S10`、`S99` 和业务 skill 输出,subject、from、cc、received_at 等其他元数据不可得时使用 `null` 或空数组。 + +当前素材包括: + +- `body_current` 中的新请求,以及与具体预订对象相关的补充事实、安排、要求或备注。 +- 当前附件、inline image、PDF、spreadsheet、文件链接、OCR 和解析表格。 +- 当前邮件明确继续上文并要求处理,例如 `please proceed`、`see attached`、`please update as attached`。 + +事件集合以 `00-output-contract.md` 为唯一权威:`active_emittable_event_types` 可以由当前邮件新生成,`legacy_accepted_event_types` 只读历史兼容,`business_review_outcomes` 不能充当粗候选。Main Agent 只判断 current evidence 是否包含至少一个 active 业务信号,不判断最终 event type、subtype,也不判断酒店用户是否需要回复或进行其他处理。 + +输入足以理解但没有 active 业务信号时,输出 `S10`,不调用 `booking-desk-event`。输入不足、无法判断是否存在 active 业务信号时输出 `S99`。只有形成至少一个符合 `04-main-skill-input-contract.md` 的粗候选时,才继续形成业务素材包并调用 Skill。Legacy event、S10、S99 和 `Need Manual Review` 均不得放进 `candidate_events`。 + +选择最终路由前,必须按 `03-current-content-completeness.md` 完成一次当前内容盘点。不得因为已经识别到一个 active 信号,就停止读取同邮件剩余正文、当前附件或 OCR。逐项 current 内容先形成粗候选、内部 `unknowns` 或明确的不确定点;只有整封邮件没有 active 粗候选时才输出 S10,整封输入不足以判断是否存在 active 信号时才输出 S99。不能只保留在 `body_current` 后静默丢弃。 + +Trace 的触发不要求当前文本包含明确动作词。Meeting、meal、arrival notice、room preference、payment information 或其他具体预订补充信息,即使只是 FYI 或单纯告知,只要能绑定目标且不属于主任务核心参数,也作为 `Trace` 候选。只有 `Thanks`、`Noted`、`Received`、裸 `FYI` 等没有具体预订业务内容的文字不匹配 Trace。 + +历史邮件、转发内容、引用线程和 `body_thread` 只能在 current 粗候选已经存在后作为证据,用来补充目标对象、旧值、新旧关系、供应商上下文或 parent allocation 背景;history-only 内容不得形成 `candidate_events`。 + +## 3. 历史查询 + +只有同时满足以下条件才查询历史: + +- 当前邮件已经形成至少一个 current evidence 支持的 active 粗候选。 +- 当前素材无法唯一绑定目标对象。 + +历史可以补充: + +- `group_code` +- confirmation / reservation number +- 客人姓名 + 入住日期 +- amendment 所需旧值 +- parent allocation 上下文 +- Trace / Guest Request 的最近目标 + +历史查询后仍不能唯一绑定目标时,必须交给 `booking-desk-event` 输出业务级复核。event type 和 subtype 已知时保留原业务 event 并附非空 `manual_review`;只有 type 或 subtype 无法确定时才使用 `Need Manual Review`。只有输入不足、连是否存在 active 业务信号都无法判断时才输出入口结果 `S99`。 + +## 4. 系统上下文 + +当当前素材或允许的历史证据已经提供目标 key,且系统查询可用时,应查询: + +- 是否已有 reservation / group block / booking record。 +- 是否已有 pending/open task。 +- 是否存在 processing、locked、workflow 或其他冲突状态。 +- 是否存在可承接的上游 New Booking / allocation / pending task。 + +这些上下文只是业务 skill 的素材,不是最终事实裁决。 + +## 5. 素材包 + +调用 `booking-desk-event` 前,准备: + +```json +{ + "source_message": { + "source_message_id": "", + "subject": null, + "from": null, + "cc": [], + "received_at": null, + "source_channel": "Email" + }, + "body_current": "", + "body_thread_evidence": null, + "current_attachments": [], + "current_tables": [], + "current_ocr": [], + "parent_child_split_evidence": [], + "history_lookup": { + "performed": false, + "reason": null, + "evidence_summary": null + }, + "system_context": { + "queried": false, + "summary": null + }, + "candidate_events": [], + "unknowns": [] +} +``` + +以上根结构展示素材盘点的初始化 shape;其中 `candidate_events=[]` 不是合法的 Skill 调用包。实际调用前该数组必须非空。信息不可得时用 `null`、空数组或明确状态,不要猜。`candidate_events` 的唯一 item schema、合法 evidence refs、调用门槛和内部回传遵循 `04-main-skill-input-contract.md`。一个合法粗候选形如: + +```json +{ + "candidate_id": "C1", + "classification_status": "coarse_supported_signal", + "possible_event_types": ["Voucher Received", "Payment Evidence"], + "evidence_refs": [ + { + "source": "current_attachments", + "index": 0 + } + ], + "target_hints": [], + "uncertainty_codes": ["document_subtype_unresolved"] +} +``` + +Main Agent 只保证这是 current evidence 支持的 active 粗候选;不保证一个 candidate 对应一个最终事件,也不裁决最终 event type、subtype 或目标拆分。 + +需要酒店进行价格、退款、减免、账期、付款政策、合同条件或其他业务审批的询问,以及其他意图清楚但当前任务目录不支持的业务内容,不得伪装成 Trace。只要同邮件已经形成至少一个 active 粗候选,就在 `unknowns` 中逐项保留: + +```json +{ + "category": "unhandled_current_business_content", + "current_or_history": "current", + "reason_code": "requires_business_approval_or_unsupported_task_card", + "text_raw": "<当前原文>", + "case_keys": { + "group_code": null, + "confirmation_number": null, + "reservation_number": null, + "block_code": null + }, + "attachments": [], + "file_references": [] +} +``` + +`text_raw` 必须保留当前证据原文。`case_keys` 只能在当前证据或允许的历史证据唯一支持时填写;不唯一时保持全 `null`。附件字段只保留当前附件或当前 file reference。 + +调用 `booking-desk-event` 前,`candidate_events` 必须至少包含一个符合 `04-main-skill-input-contract.md` 的粗候选,且 `possible_event_types` 只能引用 `active_emittable_event_types`。输入可理解但没有 active 粗候选时输出 `S10`;输入不足、无法判断是否存在 active 粗候选时输出 `S99`。 + +当 `candidate_events` 非空且 `unknowns` 同时非空时,必须把两者一起交给 `booking-desk-event`。Skill 将最终 active 事件输出到 `message_events`,并把 `unhandled_current_business_content` 一对一规范化到最终 `unhandled_current_intents`;Main Agent 不得删减或自行改写最终业务事件。 + +Skill 返回内部 `internal_route=no_supported_event` 时,Main Agent 必须将其转换成正常 S10;不得把内部结构直接对外,也不得改成 S99 或业务复核。Skill 返回 `internal_route=candidate_package_contract_error` 时,报告给编排层并停止处理;不得把接口错误映射成 S10、S99 或 `Need Manual Review`。 + +当当前素材包含 QBD/LianTai table evidence 时,`current_tables` 必须尽量保留: + +- attachment/file name +- workbook/sheet +- row index / row label +- cell fill / highlight / yellow / red text / strikethrough evidence +- group code、人数、行程列原文、酒店列原文、备注列原文、酒店状态列原文 +- `hotel_date_raw`、`tour_date_raw`、`action_date_raw`、sheet month/year,如可读 +- current-row selection 的不确定点 + +当前附件内业务列被 yellow/highlight 的行都要作为 current effective row 交给 `booking-desk-event`;Main Agent 不用邮件标题日期过滤标黄行。 + +当当前素材显示 parent-to-child allocation creation 时,`parent_child_split_evidence` 必须保留: + +- parent group code +- parent original room summary,如当前证据可读 +- child group code 列表 +- `AMEND TO` / `AMED TO` / allocation / allotment / control block 等 split raw evidence +- parent-child 关系来源和不可读点 + +Main Agent 不裁决 parent 已释放或已取消,只保留 current evidence 并交给 `booking-desk-event` 输出候选事件。 + +## 6. 事件边界提示 + +Main Agent 只保留粗粒度信号、行级证据、目标提示和不确定点;以下最终拆分、合并和派生规则由 `booking-desk-event` 裁决: + +- 一个最终事件只对应一个主要目标对象;同一目标可以同时有主事件和一个按目标合并后的 linked Trace。 +- 多个 `group_code` 不得放进数组型 `case_keys.group_code`。 +- QBD/LianTai 每个 current effective row 必须独立处理,不得跨行合并主事件。 +- “一行”是隔离边界,不是绝对事件数量。普通情况下每行 × 每目标一个主事件;同一行可以按目标拆分,并可以产生契约明确要求的 linked/derived events。 +- QBD/LianTai 当前附件中业务列标黄/高亮的行全部作为 current effective row;只有序号列、标题、说明区或装饰单元格上色,不单独形成粗候选。 +- Parent-to-child allocation creation 按 child `group_code` 拆分,并保留 parent group、parent original room summary 和 child group 列表,用于额外输出 linked parent `Cancel Booking` candidate。 +- Rooming List 按目标对象拆分,并按业务规则派生 TA Recorder。 +- Extra bed、Meeting、meal、arrival notice、Guest Request 或其他预订补充信息与新订、改单或改团号同现时,按目标拆成 linked Trace 事件。 +- 同一封邮件、同一目标对象的多条补充信息合并成一个 Trace;完整原文按出现顺序写入 `trace_text`,每条信息分别写入 `trace_items`。 +- 多个目标对象必须分别生成 Trace,不得合并多个 `group_code`。 +- `notify_departments` 不清时使用空数组,不得仅因此进入人工复核。 +- 一个独立业务意图只能有一个主要覆盖路径;同一证据可以被契约要求的 linked/derived events 引用,但不得因此重复进入 `unknowns`。HTML/plain MIME alternatives 和相同 OCR 内容必须去重。 +- 一个连续请求跨多句话时保持为一个意图;互相独立的请求按当前证据顺序分别进入粗候选或 `unknowns`。 + +active 业务方向已知但无法安全拆分或字段不安全时,将当前原文、候选边界和不确定点写入素材包。最终 event type 和 subtype 已知时,Skill 保留原业务 event 并附非空 `manual_review`;只有 type 或 subtype 无法确定时才输出 `Need Manual Review`。只有调用 Skill 前输入不足、无法判断是否存在任何 active 信号时才输出 `S99`。 + +## 7. 内置结果 + +输入可理解但未匹配 current active 业务事件时输出 `S10`。`S10` 不表示邮件没有业务价值,也不表示用户无需查看、回复或进行其他处理: + +```json +{ + "source_message": { + "source_message_id": "", + "subject": null, + "from": null, + "cc": [], + "received_at": null, + "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": "未匹配到当前 Agent 支持的业务事件类型,请查看原邮件并决定是否需要回复或进行其他处理。" + }, + "manual_review": null +} +``` + +当前输入不足,无法判断是否存在 current active 业务信号时输出 `S99`: + +```json +{ + "source_message": { + "source_message_id": "", + "subject": null, + "from": null, + "cc": [], + "received_at": null, + "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": "当前输入不足,无法判断是否匹配当前 Agent 支持的业务事件类型,请查看原邮件并决定后续处理。" + }, + "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": ["review_source_message"], + "evidence_to_check": ["source_message"], + "known_fields": {} + } +} +``` + +`S10` 和 `S99` 共用源邮件查看通知通道,但 route code 和 `agent_assessment.status` 必须保持不同。两者都不得使用 `action_required` 替用户裁决是否介入;统一使用 `requires_user_decision=true`。`source_message_id` 只放在顶层 `source_message` 中,不在 notification 内重复。 + +## 8. 业务处理 + +所有预订部业务处理统一交给: + +```text +booking-desk-event +``` + +该 skill 以 `00-output-contract.md` 的 `active_emittable_event_types` 为唯一当前事件目录,负责最终 event type、subtype、目标拆分和派生事件,并把混合邮件中的未覆盖当前意图规范化到业务输出展示字段。`legacy_accepted_event_types` 只读兼容;`business_review_outcomes` 不属于粗候选。 + +之后由信息系统负责 Case 匹配、Preflight、真实任务创建、状态机、外部写入和 Receipt。 + +## 9. 最终输出交付 + +- 最终结果必须作为当前调用的结构化 JSON 参数对象直接返回,不得作为 JSON 字符串、Markdown 代码块或文件返回。 +- Step 0 失败时直接返回 `infrastructure_input_error`;它不是 S10、S99 或业务根。 +- 业务结果的根对象必须直接使用 `booking-desk-event` 的输出,不得增加 `booking_data`、`file`、`filename`、`artifact`、`download_url` 或其他文件包装层。 +- `S10`、`S99` 等 Main Agent 内置结果也必须直接返回对应的 JSON 参数对象。 +- 禁止创建、写入、上传、附加或返回任何结果 JSON 文件,包括 `booking_data.json`。 +- 禁止用结果文件名、文件路径、下载链接、artifact 或文件引用代替最终 JSON 参数对象。 +- 禁止在最终 JSON 前后增加解释性文字。 +- 收到 `booking-desk-event` 的业务结果后,必须将该 JSON 对象原样作为最终参数返回,不得二次序列化、转存或包装成文件。 +- 内部 `no_supported_event` 必须转换为 S10;内部 `candidate_package_contract_error` 必须报告给编排层并停止。这两个 `internal_route` 都不得作为最终结果直接返回,也不得映射成业务人工复核。 +- `candidate_events` 只属于 Main→Skill 内部素材包,不得出现在最终业务根、S10、S99 或 infrastructure error 中。 +- 业务结果必须保留 `booking-desk-event` 返回的顶层 `unhandled_current_intents`;不得把它删掉、合并进 `extraction_warnings` 或藏入事件 excerpt。 + +以上限制只针对最终处理结果,不限制输入附件处理。Excel、PDF、图片等输入附件仍可下载、解析和读取;`attachments`、`file_references` 可以继续作为输入证据保留在 JSON 事件中,但不得用它们代替最终 JSON 参数对象。即使事件很多或 JSON 很长,也不得主动将结果改为文件输出。 diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/核心契约_展开阅读/00-output-contract.md b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/核心契约_展开阅读/00-output-contract.md new file mode 100644 index 0000000..0d66516 --- /dev/null +++ b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/核心契约_展开阅读/00-output-contract.md @@ -0,0 +1,416 @@ +# 输出契约 + +## 用途 + +定义 `booking-desk-event` 的统一 JSON 输出,以及 Main Agent 的 `S10/S99` 源邮件查看通知结果。所有业务事件都先是候选 `MessageEvent`,不是系统事实,不是真实 TaskCard。 + +## 输出交付形式 + +- 最终输出必须是当前调用直接返回的结构化 JSON 参数对象,不是 JSON 字符串或结果文件。 +- JSON 根对象必须直接使用本契约定义的顶层结构,不得增加 `booking_data`、`file`、`filename`、`artifact`、`download_url` 或其他文件包装层。 +- 不得创建、写入、上传、附加或返回 `booking_data.json` 或任何其他结果 JSON 文件。 +- 不得用结果文件名、文件路径、下载链接、artifact 或文件引用代替最终 JSON 参数对象。 +- 最终返回值不得使用 Markdown 代码块,也不得在 JSON 前后附加解释性文字。 +- 不得为交付形式增加 `output_mode`、`filename` 等非业务字段。 +- 输入附件引用仍可保留在事件的 `attachments` 或 `file_references` 中,但只能作为输入证据,不能替代最终 JSON 参数对象。 + +## Source Message Identity + +- 所有合规的业务输出、`S10` 和 `S99` 都必须包含 `source_message`。 +- `source_message.source_message_id` 必须是上游提供的非空值,用于通知系统关联和展示原邮件;不得猜测、生成或从其他编号替代。 +- subject、from、cc、received_at 等其他元数据不可得时使用 `null` 或空数组。 +- `null`、空字符串和纯空白字符串都视为缺失 `source_message_id`。 + +## Infrastructure Input Error + +Gateway 必须在读取正文、附件、OCR、历史或系统上下文,以及调用 Main Agent 或业务 skill 之前校验 `source_message_id`。缺失时只返回: + +```json +{ + "result_type": "infrastructure_input_error", + "error_code": "missing_source_message_id", + "retryable": true, + "missing_fields": [ + "source_message.source_message_id" + ] +} +``` + +该结果属于调用层,不属于业务输出、S10 或 S99,并明确豁免“结果必须包含 `source_message`”的业务规则。不得继续读取材料、调用 skill、生成通知、`message_events`、`unhandled_current_intents` 或 TaskCard。Main Agent 若仍收到该输入,必须使用相同结构作为防御性终态。 + +## Main Agent Notification Outcome + +`route_code` 是 S10/S99 结构的 discriminator。以下示例都是可直接返回的完整成品,不得在最终值中使用 `A | B` 表示二选一。 + +### S10 + +```json +{ + "source_message": { + "source_message_id": "", + "subject": null, + "from": null, + "cc": [], + "received_at": null, + "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": "未匹配到当前 Agent 支持的业务事件类型,请查看原邮件并决定是否需要回复或进行其他处理。" + }, + "manual_review": null +} +``` + +### S99 + +```json +{ + "source_message": { + "source_message_id": "", + "subject": null, + "from": null, + "cc": [], + "received_at": null, + "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": "当前输入不足,无法判断是否匹配当前 Agent 支持的业务事件类型,请查看原邮件并决定后续处理。" + }, + "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": ["review_source_message"], + "evidence_to_check": ["source_message"], + "known_fields": {} + } +} +``` + +- `S10` 固定使用 `status=no_booking_action_detected`、`reason_code=no_booking_action_detected`、`manual_review=null`。它表示输入可理解但未匹配 active event,不表示用户无需查看、回复或处理源邮件。 +- `S99` 固定使用 `status=material_package_unavailable`;`agent_assessment.reason_code` 必须与 `manual_review.reason_code` 一致,`manual_review.review_record_type=main_agent_entry_review`。 +- 两者均使用 `requires_user_decision=true`,不得增加 `action_required` 替用户判断是否介入。 +- `S99` 只用于输入不足、无法完成 active-event 范围分类。业务类型或 subtype 已知但字段、目标或证据不安全时,保留该业务 `event_type` 并使用非空 `manual_review`;只有类型或 subtype 无法确定时才使用 `Need Manual Review`。 + +## 业务事件顶层结构 + +```json +{ + "source_message": { + "source_message_id": "", + "subject": null, + "from": null, + "cc": [], + "received_at": null, + "source_channel": "Email" + }, + "message_events": [], + "case_candidates": [], + "extraction_warnings": [], + "unhandled_current_intents": [] +} +``` + +所有业务输出都必须固定包含以上五个顶层字段。没有未覆盖当前意图时,`unhandled_current_intents` 使用空数组。该字段只属于业务输出;`S10` 和 `S99` 保持既有入口通知结构,不增加该字段。 + +## Unhandled Current Intents + +`unhandled_current_intents` 用于展示同一邮件中意图清楚、具有当前业务意义,但现有事件目录或任务卡无法承接的内容。它不是 `MessageEvent`,信息系统不得据此自动创建 TaskCard。 + +每项固定使用: + +```json +{ + "category": "unhandled_current_business_content", + "current_or_history": "current", + "reason_code": "requires_business_approval_or_unsupported_task_card", + "text_raw": "<当前证据原文>", + "visible_message": "当前邮件包含未被现有任务类型覆盖的业务意图:<忠实中文概述>。请查看原邮件并决定后续处理。", + "requires_user_decision": true, + "case_keys": { + "group_code": null, + "confirmation_number": null, + "reservation_number": null, + "block_code": null + }, + "attachments": [], + "file_references": [] +} +``` + +固定规则: + +- 仅由当前正文、当前附件、当前 OCR、当前表格或当前继续处理指令产生;history-only 内容不得进入。 +- `text_raw` 原样保留;`visible_message` 提供忠实中文说明,不得增加批准、拒绝、执行或业务结论。无法安全翻译时使用“当前邮件包含未被现有任务类型覆盖的业务意图,请查看原文并决定后续处理。” +- `requires_user_decision` 固定为 `true`。 +- `case_keys` 始终包含四个键。仅在当前证据或允许的历史证据唯一支持时填写;否则使用 `null`,不得猜测。 +- `attachments` 和 `file_references` 只保留当前证据引用;历史附件不得带入。 +- 一个独立意图一个 item,按当前证据顺序输出;连续多句组成同一请求时保持一个 item。 +- HTML/plain MIME alternatives、重复 OCR、quoted thread 和已被事件完整承接的内容不得重复输出。 +- 清楚但不支持的业务意图使用本字段;业务类型和 subtype 已知但参数或目标不安全时,保留业务事件并附非空 `manual_review`;只有业务类型或 subtype 无法确定时才使用 `Need Manual Review`。内容不可读或抽取失败时使用 `extraction_warnings` 或相应业务复核。 +- `extraction_warnings` 只承载解析、OCR、抽取和证据质量问题,不得用来承载未覆盖业务意图。 +- `source_message_id` 只保留在根 `source_message`,不得在 item 内重复。 + +详细覆盖顺序遵循 `03-current-content-completeness.md`。 + +## Active Emittable Event Types + +- `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` + +只有本列表中的值可以由当前 Agent 新生成,也只有本列表中的值可以进入 Main Agent 的粗候选 `possible_event_types`。 + +## Legacy Accepted Event Types + +- `Allotment Maintenance` +- `Note` + +以上值只允许读取历史旧数据,当前 Agent 不得新生成。普通预订补充信息统一输出 `Trace`;部分配额维护不再路由为 Update 或 Allotment Maintenance。 + +## Business Review Outcomes + +- `Need Manual Review` + +`Need Manual Review` 不是 active event,也不能放入 Main Agent 的 `possible_event_types`。它只用于业务类型或 subtype 本身无法确定的 Fallback 复核;业务类型和 subtype 已知时保留原 `event_type`。 + +## 单个事件 + +```json +{ + "event_type": "", + "event_role": "travel_agent_request", + "current_or_history": "current", + "source_event_index": "E1", + "case_keys": { + "group_code": null, + "confirmation_number": null, + "reservation_number": null, + "block_code": null + }, + "relevant_message_excerpt": "", + "attachments": [], + "file_references": [], + "context_used": {}, + "extracted_fields": {}, + "manual_review": null +} +``` + +`manual_review=null` 表示普通候选。`manual_review` 为完整 `business_event_review` 对象时,该事件仍保留原 `event_type`,但 adapter 必须把它作为对应业务卡的 manual-review mode;不得自动执行或写 PMS。 + +## Case Key + +- `case_keys.group_code` 必须是单值或 `null`。 +- 多个 group code 必须拆成多个事件。 +- `U-` 是价格或 Rate Code marker,绝不能写入 confirmation、reservation、booking reference 或 target key。 +- Parent allocation group code 是证据,不是 child event 的 `case_keys.group_code`。 + +## Date Evidence + +涉及入住、离店或晚数时,`extracted_fields` 应保留: + +```json +{ + "arrival_date": "2026-05-14", + "departure_date": "2026-05-15", + "nights": 1, + "date_evidence": { + "hotel_date_raw": "14-15", + "tour_date_raw": "2026/05/11\n2026/05/16", + "action_date_raw": "11/05 AMD", + "sheet_month_year": "BOOKING 05-2026", + "date_inference_basis": "hotel_date_range_with_tour_date_context" + } +} +``` + +日期识别必须遵循 `54-stay-date-parsing.md`。`action_date_raw` 是动作日期证据,不是入住日期。 + +## Trace Contract + +Trace 用于当前邮件中能绑定具体预订对象、但不属于主任务核心参数的补充业务信息。要求、安排、备注和包含具体预订事实的单纯告知都可以触发 Trace;不要判断发件人是否明确要求酒店记录、执行或转交。 + +同一封邮件、同一目标对象只输出一个 Trace。多条补充信息按当前证据顺序合并: + +```json +{ + "event_type": "Trace", + "extracted_fields": { + "trace_subtype": "extra_bed | general_request", + "trace_text": "<按当前证据顺序合并的完整补充信息原文>", + "trace_items": [ + { + "category": "extra_bed | room_preference | room_setup | meeting | function | meal | transport | payment_information | general_information", + "text_raw": "<单条原文>", + "service_date": "YYYY-MM-DD | null", + "service_period_raw": "<原始时段或 null>", + "pax": null, + "notify_departments": [] + } + ], + "notify_departments": [] + } +} +``` + +- `trace_text` 必须保留完整原文,结构化字段不能替代原文。 +- `trace_items` 的顺序必须与 `trace_text` 一致。 +- `category` 必须使用固定枚举;banquet 归入 `function`,无法归入更具体类别时使用 `general_information`。 +- `service_date`、`service_period_raw`、`pax` 只有在证据明确时填写,否则为 `null`。 +- item 的 `notify_departments` 只保留明确或规则唯一支持的部门;事件级 `notify_departments` 是 item 已知部门的去重合集。 +- 部门不清时使用空数组,不得仅因此输出人工复核。 +- 全部 item 都是 extra bed 时使用 `trace_subtype=extra_bed`;其他情况使用 `general_request`。 +- Trace 包含 extra bed item 时,继续在事件级 `extracted_fields` 保留原有 `occupancy_update`、`requires_rate_update` 和 `rate_adjustment_formula`,不得移动到 item 或删除。 +- 主事件已完整承接的核心参数不得重复生成 Trace。 +- 不同目标对象必须拆成不同 Trace;`case_keys.group_code` 仍为单值。 +- `payment_information` 只表示已经确定、需要随预订保留的补充付款安排;付款凭证、到账结果、Payment Notice、Invoice、催款或付款条件审批询问不得改名为 Trace。 + +`FYI guide will arrive at 20:00` 等具体预订告知可以触发 Trace;只有 `Thanks`、`Noted`、`Received`、裸 `FYI` 等没有具体业务信息的文字不能触发 Trace。需要酒店进行价格、退款、账期、付款政策、合同条件或其他业务审批的询问也不能伪装成 Trace。 + +不属于 Trace 但具有当前业务意义的内容必须原样保留在 Main Agent 素材包的 `unknowns` 中。只要同邮件存在至少一个支持事件,业务 skill 就按 `03-current-content-completeness.md` 将其一对一输出到顶层 `unhandled_current_intents`;不得静默忽略或改成 Trace。 + +## 人工复核 + +```json +{ + "manual_review": { + "reason_code": "", + "visible_reason": "", + "review_record_type": "business_event_review", + "missing_fields": [], + "blocking_points": [], + "conflicting_points": [], + "suggested_human_actions": [], + "evidence_to_check": [], + "known_fields": {} + } +} +``` + +固定规则: + +- 九个字段必须全部存在;`reason_code` 和 `visible_reason` 必须是非空字符串,`review_record_type` 固定为 `business_event_review`。 +- `missing_fields`、`blocking_points`、`conflicting_points`、`suggested_human_actions` 和 `evidence_to_check` 固定为数组;`known_fields` 固定为对象。 +- `missing_fields[]` 每项必须是指向当前 event 的 RFC 6901 JSON Pointer,例如 `/extracted_fields/room_items/0/pms_room_type_code`。不得放人类说明文字。 +- adapter 必须先按业务 `event_type` 和 subtype 判别字段得到原业务 task type/subtype,再依据 `manual_review` 是否为空派生 `normal_task` 或 `manual_review`。 +- 只有业务类型或 subtype 无法确定时,才使用 `event_type=Need Manual Review` 并派生 `manual_review + Fallback + business_event_review`。 +- `requires_downstream_hard_validation` 只用于契约明确的系统级候选校验,不得替代必需房型、Rate Code 或其他业务字段的人工确认。 +- 原 Agent event 保持不可变;用户覆盖值、`review_status`、解决人和解决时间由信息系统持有,全部缺失字段解决并重新校验后才进入 Preflight。 + +## 派生事件 + +派生或关联事件使用: + +```json +{ + "related_source_event_index": "", + "related_source_event_indices": [], + "related_event_type": "", + "relationship_type": "", + "requires_downstream_hard_validation": true +} +``` + +`related_source_event_index` 用于单一关联事件;一个派生事件关联多个来源事件时使用 `related_source_event_indices`。 + +典型场景: + +- New/Update/Amend Group Code 同事件出现 extra bed、Meeting、meal、arrival notice 或其他预订补充信息,按目标派生一个 linked `Trace`。 +- Rooming List 按每个目标 group 派生 `TA RECORDER`。 +- Parent-to-child allocation creation 必须派生独立的 parent release/cancel 候选 `message_event`,不得只放进 child `New Booking.extracted_fields`。 + +## Parent-To-Child Parent Candidate + +当 current evidence 确认 parent group 拆成 child group codes,且 parent group code 清楚时,必须额外输出一个 parent release/cancel candidate: + +```json +{ + "event_type": "Cancel Booking", + "event_role": "travel_agent_request", + "current_or_history": "current", + "source_event_index": "E_PARENT_RELEASE", + "case_keys": { + "group_code": "", + "confirmation_number": null, + "reservation_number": null, + "block_code": null + }, + "relevant_message_excerpt": "", + "attachments": [], + "file_references": [], + "context_used": { + "source": "current parent-to-child allocation evidence" + }, + "extracted_fields": { + "cancel_object_type": "group_block", + "parent_release_or_cancel_candidate": true, + "release_reason": "parent_to_child_allocation_split", + "parent_group_code": "", + "child_group_codes": [""], + "allocation_split_from_parent": true, + "parent_original_room_summary": null + }, + "related_source_event_indices": ["E1"], + "related_event_type": "New Booking", + "relationship_type": "linked_parent_release_after_child_split", + "requires_downstream_hard_validation": true, + "manual_review": null +} +``` + +该事件是候选事件,不代表 PMS 已取消成功,不创建真实 TaskCard,不写外部系统。 + +关系约束: + +- 一个 split cluster 只能有一个 parent candidate。 +- `child_group_codes[]` 不得重复,并与 `related_source_event_indices[]` 数量和顺序一一对应。 +- 每个相关 index 必须指向同一 `message_events[]` 中的 `New Booking`,其 `booking_object_type=Group Block` 且 `case_keys.group_code` 等于对应 child code。 +- Parent 始终使用复数 `related_source_event_indices[]`,即使只有一个 child。 +- Parent code 不得写入 child event 的 `case_keys.group_code`。 + +## 禁止输出 + +不得输出真实 TaskCard ID、最终 Case 状态、Payment 确认、Block Status 自动转换、Receipt、Invoice 文件、Opera/PMS 写入结果。 + +不得生成或返回结果 JSON 文件、结果文件引用、结果下载链接或 artifact。该限制只针对最终处理结果,不禁止下载、读取和解析输入附件,也不禁止在事件中保留输入附件证据。 diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/核心契约_展开阅读/02-event-routing-map.md b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/核心契约_展开阅读/02-event-routing-map.md new file mode 100644 index 0000000..5ba4721 --- /dev/null +++ b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/核心契约_展开阅读/02-event-routing-map.md @@ -0,0 +1,82 @@ +# 事件路由地图 + +## 用途 + +从 current evidence 建立粗候选,再由 Skill 选择最终业务 event type、subtype、目标拆分和派生事件。Main Agent 与 Skill 的内部接口遵循 `04-main-skill-input-contract.md`。 + +`00-output-contract.md` 中的三个集合具有不同用途: + +- `active_emittable_event_types`:可以进入 `candidate_events` 并由当前邮件新生成。 +- `legacy_accepted_event_types`:只读历史兼容,不得作为当前 producer 或粗候选。 +- `business_review_outcomes`:业务类型未知时的复核 outcome,不得作为粗候选或 Skill 调用门槛。 + +## 路由顺序 + +0. Main Agent 先校验 `source_message.source_message_id`。缺失或空白时直接返回 `infrastructure_input_error`;不得读取正文、附件、历史或系统上下文,不得进入 S10/S99,也不得调用 Skill。 +1. Main Agent 按 `03-current-content-completeness.md` 盘点全部 current 业务内容,不得识别一个信号后停止。 +2. Main Agent 按 `04-main-skill-input-contract.md` 只形成 current evidence 支持的粗候选。它可以取得附件、OCR、表格和原始字段,但不作最终文件 subtype、event type 或拆分裁决。 +3. 输入可理解且没有 active 业务信号时,输出 `S10`,不调用 Skill。输入不足、无法判断是否存在任何 active 业务信号时,输出 `S99`,不调用 Skill。 +4. `candidate_events` 至少包含一个合法粗候选时才调用 Skill;同时存在的清楚但不受支持内容保留在 `unknowns`。 +5. Skill 重新验证 current evidence,并对最终 event type、subtype、目标拆分、事件合并和 linked/derived events 作唯一裁决。 +6. event type 和 subtype 已知但目标、字段、映射或证据不安全时,保留原 active `event_type`,并附非空 `manual_review`。只有 active 业务方向已确认但 event type 或 subtype 仍无法确定时,才输出 type-unknown `Need Manual Review`。 +7. Skill 确认所有粗候选均没有 active 事件时,返回内部 `no_supported_event`,由 Main Agent 转换为 S10;该内部结构不得直接对外。素材包结构非法时返回内部 `candidate_package_contract_error`,不得映射成 S10、S99 或业务复核。 +8. 能安全拆分时按目标独立路由,并把未覆盖当前意图一对一输出到顶层 `unhandled_current_intents`。 + +只有 Thank you、裸 FYI、acknowledgement、Noted、Received 等没有具体预订业务信息的文字不匹配 Trace。FYI 或单纯告知只要包含与明确预订对象相关的具体补充信息,就路由为 `Trace`。一般咨询、Booking Confirmation Request、部分配额维护和当前不支持的业务请求不得伪装成 Trace。 + +当一封邮件同时包含 active 事件和当前不支持的业务请求时,不得因为 active 事件已命中而忽略剩余请求,也不得把整封邮件降级为 S10。支持部分正常路由,未覆盖部分进入 `unhandled_current_intents`。 + +## Active 路由表 + +下表只能产生 `active_emittable_event_types` 中的精确 event type: + +| 当前信号 | 最终事件类型 | 读取 | +| --- | --- | --- | +| 新建 FIT、Group Block、Allotment、Control Block | `New Booking` | `10-new-booking.md` | +| Parent-to-child allocation creation | child `New Booking` + linked parent `Cancel Booking` candidate | `31-allotment-control-block.md` | +| 修改已有 FIT/Group 订单的日期、晚数、房型、房量、人数、价格或其他主订单字段;不含独立部分配额维护 | `Update Booking / Amendment` | `11-update-booking.md` | +| 整单取消、CXL、release/cancel reservation 或 group block | `Cancel Booking` | `12-cancel-booking.md` | +| 明确整块取消 Allotment / Control Block | `Cancel Allotment` | `12-cancel-booking.md` | +| 当前 credit voucher 文件 | `Voucher Received` | `13-voucher-payment.md` | +| 当前 bank transfer、cash deposit 或 transaction receipt 文件 | `Payment Evidence` | `13-voucher-payment.md` | +| 当前名单、分房表、guest list | `Rooming List` | `14-rooming-list.md` | +| 旧 Group Code 改新 Group Code | `AMEND GROUP CODE` | `15-amend-group-code.md` | +| 与具体预订对象相关、但不属于主任务核心参数的当前补充信息 | `Trace` | `16-trace-notes.md` | +| Rooming List 目标需要 TA Recorder | `TA RECORDER` | `17-ta-recorder-note.md` | +| 明确请求生成 Proforma Invoice | `Invoice Generation` | `18-invoice.md` | +| 当前收到已有或 revised invoice | `Invoice Received` | `18-invoice.md` | +| 当前清楚的付款通知,不是付款政策审批 | `Payment Notice` | `18-invoice.md` | +| 单独手工价格或 Rate Code 维护 | `Manual RateCode` | `53-manual-rate-code.md` | + +## Legacy 与范围外内容 + +- `Note` 和 `Allotment Maintenance` 属于 `legacy_accepted_event_types`,不得进入 `candidate_events`,不得由当前邮件新生成。 +- 部分配额维护、减少但保留余量、部分日期或部分房量 release 当前不受支持。单独出现时由 Main Agent 输出 S10;与 active 事件同现时原文进入 `unknowns`,最终展示在 `unhandled_current_intents`。 +- 独立新建 Allotment / Control Block 与明确整块 `Cancel Allotment` 仍属于 active 范围。 +- `Need Manual Review` 属于 `business_review_outcomes`,不是 active event type,不能独立满足 Skill 调用门槛。 + +## 冲突优先级 + +先按独立 current 意图拆分,再只对同一意图的多个候选应用以下优先级: + +- 当前只有 voucher/payment proof,即使标题像 NEW,也优先 voucher/payment。 +- Rooming List 文件不得当成 booking update 表。 +- `AMEND GROUP CODE TO` 且 old/new 清楚时,优先 `AMEND GROUP CODE`。 +- Parent-to-child allocation creation 不因出现 `AMEND` 字样就当普通改单。 +- Extra bed alone 是 Trace,不是房量修改。 +- 先识别主任务完整承接的核心参数,再把剩余的具体预订补充信息按目标生成 Trace;不得为同一核心参数重复生成 Trace。 +- 已确定、需要随预订保留的补充付款安排可以是 Trace;付款凭证、到账结果、Payment Notice、Invoice、催款或需要酒店批准的价格、退款、减免、账期、付款政策、合同条件询问不是 Trace。 +- Cancel 行不会因为历史上有 guest request 就自动生成 Trace。 + +## 行级隔离与拆分 + +- QBD/LianTai 每个 current effective row 必须独立处理,不得跨行合并主事件。 +- “一行”是隔离边界,不是绝对事件数量。普通情况下每行 × 每目标一个主事件;同一行可以按多个目标拆分,也可以产生契约明确的 linked/derived events。 +- Parent-to-child allocation 按 child group code 拆,并额外输出 parent linked `Cancel Booking` candidate。 +- Extra bed、Meeting、meal、arrival notice、guest request 或其他预订补充信息与主业务同现时,按目标拆 linked Trace。 +- 同一封邮件、同一目标对象的多条补充信息合并成一个 Trace;多个目标对象分别生成 Trace。 +- Rooming List 按目标 group 拆,并按业务规则派生 TA Recorder。 + +拆分后业务类型已知但字段或目标不安全时,保留对应业务 event 并附 `manual_review`。只有 event type 或 subtype 本身无法确定时才使用 `Need Manual Review`。 + +不符合 Trace 但具有当前业务意义的内容必须以 `unknowns[].category=unhandled_current_business_content` 保留原文。同邮件存在至少一个 active 事件时输出到顶层 `unhandled_current_intents`;整封邮件没有 active 事件时由 Main Agent 输出 S10。不得静默忽略。 diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/核心契约_展开阅读/03-current-content-completeness.md b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/核心契约_展开阅读/03-current-content-completeness.md new file mode 100644 index 0000000..ea7b11d --- /dev/null +++ b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/核心契约_展开阅读/03-current-content-completeness.md @@ -0,0 +1,121 @@ +# 当前邮件内容完整覆盖 + +## 用途 + +确保当前邮件中每一项有业务意义的内容都有明确去向。不得因为已经匹配一个支持业务事件,就停止读取或静默丢弃同一邮件中的其他当前意图。 + +本规则是邮件级共享规则,优先于具体事件 reference。它不扩大任务卡能力,也不把当前不支持的内容伪装成 `Trace` 或业务人工复核。 + +## 当前内容盘点 + +盘点范围包括: + +- `body_current` 中的请求、询问、安排、事实和告知。 +- 当前附件、inline image、PDF、spreadsheet、OCR、表格和文件链接中的业务内容。 +- 当前邮件明确继续处理的上文对象。 + +不作为独立业务内容: + +- greeting、signature、disclaimer 和纯礼貌文字。 +- HTML 与 plain-text MIME alternatives 中语义相同的重复内容。 +- quoted thread、forwarded old mail 和其他 history-only 内容。 + +一个连续请求即使跨多句话,仍作为一个意图;互相独立的请求必须拆开,并按当前证据顺序保留。 + +## 完整覆盖不变量 + +每项有业务意义的当前内容必须且只能进入以下一个结果路径: + +1. 匹配支持事件并安全处理:普通 `message_event`,包括 linked `Trace`。 +2. 已匹配 active event 且业务类型和 subtype 已知,但参数、目标或证据不安全:保留该业务 `message_event` 并使用非空 `manual_review`。 +3. 已确认存在 active 业务方向,但业务类型或 subtype 本身无法安全确定:输出 `event_type=Need Manual Review` 的 Fallback 复核。 +4. 意图清楚但现有事件或任务卡不支持,且同邮件还有至少一个支持事件:最终 `unhandled_current_intents`。 +5. 意图清楚但整封邮件没有任何支持事件:Main Agent 输出 `S10`。 +6. 输入不足,无法判断是否匹配支持事件:Main Agent 输出 `S99`。 + +`Need Manual Review` 不是 active event,也不能用于满足 Main→Skill 调用门槛。路径 2 与路径 3 的区别是:路径 2 已知业务卡型,因此保留原业务 event;路径 3 连业务类型或 subtype 都不能确定,因此才使用 Fallback。 + +不得用 `relevant_message_excerpt`、源邮件仍可查看或 `extraction_warnings` 代替上述覆盖结果。 + +## Main Agent 内部 unknowns + +当同邮件已经匹配至少一个支持事件,以下当前内容进入素材包 `unknowns`: + +- 需要酒店批准的价格、退款、减免、豁免、账期、付款政策或合同条件询问。 +- 意图清楚、具有业务意义,但当前支持事件目录或任务卡无法承接的其他内容。 + +内部结构使用: + +```json +{ + "category": "unhandled_current_business_content", + "current_or_history": "current", + "reason_code": "requires_business_approval_or_unsupported_task_card", + "text_raw": "<当前证据原文>", + "case_keys": { + "group_code": null, + "confirmation_number": null, + "reservation_number": null, + "block_code": null + }, + "attachments": [], + "file_references": [] +} +``` + +`case_keys` 只能使用当前证据或允许的历史证据唯一支持的值;不能唯一绑定时保持全 `null`。目标不清本身不阻止邮件级展示,也不得为了填写 key 而猜测。 + +## 业务输出映射 + +`booking-desk-event` 必须把上述 `unknowns` 按原顺序一对一规范化到业务输出顶层 `unhandled_current_intents`: + +```json +{ + "category": "unhandled_current_business_content", + "current_or_history": "current", + "reason_code": "requires_business_approval_or_unsupported_task_card", + "text_raw": "<当前证据原文>", + "visible_message": "当前邮件包含未被现有任务类型覆盖的业务意图:<忠实中文概述>。请查看原邮件并决定后续处理。", + "requires_user_decision": true, + "case_keys": { + "group_code": null, + "confirmation_number": null, + "reservation_number": null, + "block_code": null + }, + "attachments": [], + "file_references": [] +} +``` + +规则: + +- `text_raw` 必须原样保留,不得只留翻译或摘要。 +- `visible_message` 必须忠实说明原意,不得增加批准、拒绝、执行或业务结论。无法安全翻译时使用“当前邮件包含未被现有任务类型覆盖的业务意图,请查看原文并决定后续处理。” +- `requires_user_decision` 固定为 `true`。 +- 只保留当前附件和当前 file reference;历史附件不得带入。 +- `source_message_id` 只使用业务输出根对象中的值,不在 item 内重复。 +- 该 item 不是 `MessageEvent`,没有 `event_type`,不得创建 TaskCard 或触发外部写入。 + +## 去重与边界 + +- HTML/plain MIME 重复、相同 OCR 重复和签名引用不得生成重复 item。 +- 每个独立未覆盖意图一个 item;不得把不同问题压成模糊摘要。 +- 已由主事件或 Trace 完整承接的内容不得再次进入该数组。 +- 已确定的补充付款安排可以是 `Trace.payment_information`;询问酒店是否批准付款安排进入未覆盖意图。 +- 内容不可读或语义不足时,不得伪装成清楚的未覆盖意图;按事件上下文使用 `extraction_warnings`、type-known business review、type-unknown Fallback review 或 `S99`。 +- `extraction_warnings` 只承载解析、OCR、抽取和证据质量问题,不承载清楚但不受支持的业务意图。 + +## 当前不支持的配额维护 + +- 独立新建 Allotment/Control Block 仍属于 active `New Booking`。 +- 明确整块取消仍属于 active `Cancel Allotment`。 +- 明确减少部分房量、修改部分日期或保留剩余配额继续使用,不再属于 active event;整封邮件只有该意图时输出 S10,与其他 active event 同现时进入 `unhandled_current_intents`。 +- 无法判断是完整 Parent split/整块取消还是部分维护时,业务类型或 subtype 不明确,使用 Fallback 业务复核;不得生成 Parent Cancel 候选。 + +## 示例 + +- 当前付款凭证 + “余款能否入住时支付”:输出 `Payment Evidence`,并输出一个未覆盖意图。 +- “余款将在入住时支付”且目标唯一:输出 `Trace.payment_information`,不输出未覆盖意图。 +- 当前只有清楚的付款政策询问,没有任何支持事件:Main Agent 输出 `S10`,不调用业务 skill。 +- 历史中有审批询问、当前只有 `Thanks`:不得从历史生成未覆盖意图。 diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/核心契约_展开阅读/04-main-skill-input-contract.md b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/核心契约_展开阅读/04-main-skill-input-contract.md new file mode 100644 index 0000000..97a3238 --- /dev/null +++ b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/核心契约_展开阅读/04-main-skill-input-contract.md @@ -0,0 +1,144 @@ +# Main Agent 到 Skill 的内部输入契约 + +## 用途 + +定义 Main Agent 交给 `booking-desk-event` 的素材包边界、粗候选结构、调用门槛和内部回传。这个接口只存在于 Main Agent 与 Skill 之间;`candidate_events` 和 `internal_route` 都不是最终对外输出。 + +Main Agent 负责入口校验、取得当前素材、OCR/表格原始抽取、current/history 分离、粗候选和未覆盖当前意图。Skill 对最终 event type、subtype、目标拆分、事件合并以及 linked/derived events 拥有唯一裁决权。 + +## 目录 + +- Step 0:Source Message Identity +- 事件集合 +- Candidate Event Item +- 调用门槛 +- Skill 最终裁决与内部回传 +- 行级与目标级边界 + +## Step 0:Source Message Identity + +在读取正文、附件、OCR、历史或系统上下文前,先校验 `source_message.source_message_id`。缺失、`null`、空字符串或只包含空白时,不得建立素材包或调用 Skill,固定返回输出契约定义的: + +```json +{ + "result_type": "infrastructure_input_error", + "error_code": "missing_source_message_id", + "retryable": true, + "missing_fields": ["source_message.source_message_id"] +} +``` + +该结果不是 S10、S99、业务事件或人工复核,不展示源邮件通知,不创建 TaskCard。不得猜测、生成、修剪后替换或从 confirmation/group/reservation number 推导 `source_message_id`。 + +## 事件集合 + +事件集合的唯一权威定义在 `00-output-contract.md`: + +- `active_emittable_event_types`:当前可以由新邮件生成的普通业务 event type。 +- `legacy_accepted_event_types`:只用于读取历史兼容数据;不得进入 `candidate_events`,不得由当前邮件新生成。 +- `business_review_outcomes`:业务复核 outcome;不得进入 `candidate_events`,也不能独立满足 Skill 调用门槛。 + +`possible_event_types` 只能使用 `active_emittable_event_types` 中的精确值。S10、S99、`Need Manual Review`、`Note`、`Allotment Maintenance` 或其他 legacy/review/入口值都不是合法粗候选类型。 + +## Candidate Event Item + +每个相互独立、由 current evidence 支持的业务信号形成一个粗候选: + +```json +{ + "candidate_id": "C1", + "classification_status": "coarse_supported_signal", + "possible_event_types": ["Voucher Received", "Payment Evidence"], + "evidence_refs": [ + { + "source": "current_attachments", + "index": 0 + } + ], + "target_hints": [ + { + "case_keys": { + "group_code": null, + "confirmation_number": null, + "reservation_number": null, + "block_code": null + }, + "target_locator_raw": null + } + ], + "uncertainty_codes": ["document_subtype_unresolved"] +} +``` + +固定规则: + +- `candidate_id` 必须匹配 `^C[1-9][0-9]*$`,并在本素材包中唯一。 +- `classification_status` 固定为 `coarse_supported_signal`,表示 Main Agent 没有作最终业务裁决。 +- `possible_event_types` 必须非空、去重,只能使用 active 集合精确值;可以保留多个候选类型。 +- `evidence_refs` 必须非空,只能引用 current 素材。`source` 只能是 `body_current`、`current_attachments`、`current_tables`、`current_ocr` 或 `parent_child_split_evidence`;`index` 必须是存在于对应素材中的非负整数索引,`body_current` 固定使用 `0`。 +- `target_hints` 是非权威提示,可以为空或包含多个 item。每个 item 固定包含四键 `case_keys` 和原始 locator;不唯一时保持 `null`,不得猜测。 +- `uncertainty_codes` 可以为空;有不确定性时使用稳定 reason code,不用自然语言结论替代。 +- history-only 内容不得创建粗候选。历史只能在 current 粗候选已经存在后绑定目标、旧值或上下文。 +- 每个相互独立的 current 信号都必须有自己的粗候选;同一行或同一目标中的主业务信号和补充 Trace 信号也分别建 candidate。Skill 再按最终目标和业务规则合并、拆分或建立 linked relationship。 +- 一个粗候选不等于一个最终事件。Skill 可以拆成多个目标事件、合并同目标信号,或生成契约明确要求的 linked/derived events。 +- 最终事件在 `context_used.main_candidate_ids` 中记录所使用的 `candidate_id`;一个事件可关联多个 candidate,一个 candidate 也可关联多个最终事件。最终输出只允许保留这些 ID 作为 provenance,不得复制 `candidate_events` item、`possible_event_types`、`uncertainty_codes` 或任何 `internal_route`。 + +## 调用门槛 + +完成全部 current 内容盘点后: + +1. 输入可理解,且没有任何 active 业务信号:Main Agent 输出 S10,不调用 Skill。 +2. 输入不足,无法判断是否存在任何 active 业务信号:Main Agent 输出 S99,不调用 Skill。 +3. `candidate_events` 至少有一个满足本契约的粗候选:Main Agent 调用 Skill,并同时传递 `unknowns`。 + +仅有 unsupported/legacy 内容、`unknowns` 或 review outcome 不能满足调用门槛。正文只有泛化 `see attached` 且附件无法取得、方向不可识别时走 S99;动作方向已明确为 active 事件但必要附件不可读时,仍形成粗候选,由 Skill 生成业务复核结果。 + +## Skill 最终裁决 + +- Skill 必须重新验证 current evidence,不得把 `possible_event_types` 当作最终结论。 +- event type 和 subtype 已知但字段、目标、映射或证据不安全时,保留该 active `event_type`,并附非空 `manual_review`;`manual_review.missing_fields[]` 使用 RFC 6901 JSON Pointer。 +- 只有 active 业务方向已确认,但最终 event type 或 subtype 仍无法确定时,才使用 `business_review_outcomes` 中的 `Need Manual Review`。 +- 业务类型已知的 manual-review event 和 type-unknown `Need Manual Review` 都是业务输出,不得改成 S99。 + +如果 Skill 验证后确认所有粗候选都没有 active 事件,返回以下内部结果: + +```json +{ + "internal_route": "no_supported_event", + "reason_code": "skill_validation_found_no_active_event", + "candidate_ids": ["C1"] +} +``` + +Main Agent 必须把该内部结果转换成正常 S10;不得把 `internal_route` 直接对外返回,也不得把它转换成 S99 或业务人工复核。 + +`no_supported_event.candidate_ids` 必须非空,列出本次已完成验证且均被否定的全部粗候选 ID。 + +如果素材包或 candidate item 违反本契约,Skill 返回内部 contract error: + +```json +{ + "internal_route": "candidate_package_contract_error", + "reason_code": "invalid_candidate_package", + "candidate_ids": ["C1"], + "contract_errors": [ + { + "path": "/candidate_events/0/possible_event_types/0", + "code": "not_active_emittable_event_type" + } + ] +} +``` + +`contract_errors[].path` 使用 RFC 6901 JSON Pointer。该内部错误报告给编排层并停止本次业务处理;不得直接对外,不得映射为 S10、S99 或 `Need Manual Review`。 + +`candidate_package_contract_error.contract_errors` 必须非空。`candidate_ids` 保留仍可安全读取的候选 ID;如果连 ID 都不合法,使用空数组。 + +## 行级与目标级边界 + +QBD/LianTai 当前有效行必须独立处理,不能把不同 current rows 合并成一个主事件。这里的“一行”是隔离边界,不是绝对事件数量: + +- 普通情况下,一个 current row × 一个目标产生一个主事件。 +- 同一行含多个目标时由 Skill 按目标拆分。 +- Parent-to-child split、linked parent cancel、linked Trace、TA Recorder 和其他契约明确的 derived event 可以在同一行主事件之外额外产生。 +- Main Agent 只保留行、highlight、目标提示和不确定点;最终拆分由 Skill 完成。 diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/核心契约_展开阅读/90-manual-review.md b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/核心契约_展开阅读/90-manual-review.md new file mode 100644 index 0000000..c561cbe --- /dev/null +++ b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/核心契约_展开阅读/90-manual-review.md @@ -0,0 +1,158 @@ +# 人工复核 + +## 入口结果边界 + +Gateway 必须先校验 `source_message.source_message_id`。该值为 `null`、空字符串或纯空白时,直接返回 `00-output-contract.md` 定义的 `infrastructure_input_error`;不得读取正文、附件、OCR、历史或系统上下文,也不得调用 Main Agent 或 `booking-desk-event` skill。 + +source message identity 合法后,Main Agent 才完成支持范围分类: + +- 当前输入可理解,但没有匹配 active event 时,输出 `S10`。 +- 当前输入不足,无法判断是否匹配 active event 时,输出 `S99`。 +- `S10` 和 `S99` 都展示源邮件并由用户自行决定是否回复或进行其他处理。 +- `S10` 和 `S99` 不由 `booking-desk-event` skill 输出。 + +Thank you、裸 FYI、acknowledgement、Noted、Received 等没有具体预订业务信息的文字,以及一般咨询和当前不支持的 Booking Confirmation Request,都不匹配 Trace。FYI 或单纯告知中包含与明确预订对象相关的具体补充信息时,匹配 `Trace`。正文只有 `see attached` 且附件无法取得、无法识别业务方向时走 `S99`。 + +需要酒店批准的价格、退款、减免、账期、付款政策或合同条件询问不是 Trace。此类具有当前业务意义但没有任务卡承接的内容必须保留在 `unknowns`:同邮件存在支持事件时输出到 `unhandled_current_intents`,整封邮件没有支持事件时走 S10。意图清楚但不受支持不等于业务判断不安全,不得仅因此输出业务人工复核。 + +## 两种业务人工复核 + +### 已知业务卡型的人工复核 + +只要业务 `event_type` 和 subtype 已经能够确定,就必须保留该业务事件,继续输出能够安全抽取的字段,并令该 event 的 `manual_review` 为完整 `business_event_review` 对象。不得仅因为房型、Rate Code、目标对象或某项证据存在歧义,就把事件改成 `Need Manual Review`。 + +常见情形包括: + +- target object 不明确或不唯一,但业务卡型已确定; +- current/history 边界不明确,但业务卡型已确定; +- 已确定业务卡型,但其附件、OCR、表格或必要证据不可读; +- required room type、Rate Code、settlement price 或其他必需字段无法唯一确定; +- QBD/LianTai row、highlight 或 current-row evidence 缺失或歧义; +- system context 显示 duplicate、pending/open task、active workflow、lock 或 conflict。 + +例如,正文明确 `please update the booking as attached`,因此业务卡型已经确定为 Update;附件不可读时仍输出对应 Update event,把安全取得的订单号、客人名等保留下来,并附非空 `manual_review`。Adapter 据此派生 `manual_review + 原业务 task_type + 原业务 subtype`,而不是 Fallback。 + +### 业务类型或 subtype 未知的 Fallback + +只有业务 `event_type` 或 subtype 本身无法确定时,才输出 `event_type=Need Manual Review`。Adapter 将其派生为: + +```text +manual_review + Fallback + business_event_review +``` + +例如,同一段 current action 同时可能表示 New Booking 或 Update,且没有足够证据裁决,业务类型无法确定,此时才走 Fallback。 + +不得为了满足 Main Agent 的 skill-call gate 而制造 `Need Manual Review`。如果 Main 给出的所有粗候选都被 skill 否定,skill 返回内部 `no_supported_event` disposition,由 Main 转成 S10。 + +## 必要结构和字段类型 + +业务级 `manual_review` 必须完整包含九个字段: + +```json +{ + "reason_code": "room_type_mapping_multiple_candidates", + "visible_reason": "房型原文可识别,但无法唯一映射到当前有效 PMS 房型代码。", + "review_record_type": "business_event_review", + "missing_fields": [ + "/extracted_fields/room_items/0/pms_room_type_code" + ], + "blocking_points": [ + "pms_room_type_code is required before Preflight" + ], + "conflicting_points": [], + "suggested_human_actions": [ + "select_room_type_from_active_pms_catalog" + ], + "evidence_to_check": [ + "current_body", + "current_attachments" + ], + "known_fields": { + "room_type_raw": "SUITE" + } +} +``` + +固定规则: + +- 九个字段必须全部存在。 +- `reason_code` 和 `visible_reason` 必须是非空字符串。 +- `review_record_type` 固定为 `business_event_review`。 +- `missing_fields`、`blocking_points`、`conflicting_points`、`suggested_human_actions`、`evidence_to_check` 固定为字符串数组;无内容时使用空数组。 +- `known_fields` 固定为对象;没有已知值时使用空对象。 +- `missing_fields[]` 每一项必须是指向当前 event 的 RFC 6901 JSON Pointer,例如 `/extracted_fields/room_items/0/pms_room_type_code`。不得填写人类说明、点号路径或根邮件路径。 +- 不新增 `required_user_inputs`;前端业务卡字段注册表负责将 pointer 映射到可编辑控件。 +- pointer 无法映射到该业务卡的已知可编辑字段时,属于 adapter contract error,不能展示一个无法完成的复核卡。 + +## 房型和其他必需字段 + +房型原文可以识别但映射不唯一时: + +- 保留 `room_type_raw`; +- `pms_room_type_code=null`; +- 在 `manual_review.missing_fields` 中登记对应的 JSON Pointer; +- 用户只能从信息系统提供的当前有效 PMS 房型目录中选择,不得由 Agent 猜测,也不得允许用户自由填写 PMS code。 + +Rate Code、价格或其他业务必需字段遵循相同原则:保留安全抽取值,待用户确认的目标字段置为 `null` 并用 pointer 标记。`requires_downstream_hard_validation` 不能替代这些字段的人工确认;也不得写成“人工复核或 downstream validation 二选一”。 + +一个 event 进入人工复核时,不得清空同邮件中的 sibling events。每个 event 独立派生路由:确定且完整的 sibling 仍为 normal task,需要确认的 sibling 保留其业务 task type/subtype 并进入 manual-review mode。 + +## 同卡解阻状态 + +Agent 输出只描述初始业务判断,保持不可变。信息系统在同一张业务卡上维护复核状态,初始为: + +```json +{ + "review_status": "pending", + "review_resolution": null +} +``` + +用户确认后保存: + +```json +{ + "review_status": "resolved", + "review_resolution": { + "field_overrides": [ + { + "field_pointer": "/extracted_fields/room_items/0/pms_room_type_code", + "value": "SU1" + } + ], + "resolved_by": "", + "resolved_at": "" + } +} +``` + +固定规则: + +- 原 Agent payload 和初始 `result_type=manual_review` 不得改写;当前页面模式由 `review_status` 控制。 +- `field_overrides[].field_pointer` 必须与待解决字段 pointer 对应,覆盖值和审计信息由信息系统持有。 +- 只有所有缺失字段完成、目录值校验通过、依赖字段重新校验通过后,才允许进入现有 Preflight。 +- 在 Preflight 完成前不得写 PMS。 +- 不创建第二张 linked normal task;解决后仍是同一张业务卡。 +- 业务字段可以按权限编辑;邮件原文、raw evidence、source ID 和审计字段只读。 + +## 常见 Reason Code + +- `current_history_boundary_unclear` +- `target_object_unclear` +- `multiple_target_candidates` +- `attachment_or_ocr_unreadable` +- `event_type_conflict_unclear` +- `subtype_unresolved` +- `room_type_mapping_unconfirmed` +- `room_type_mapping_multiple_candidates` +- `rate_code_unconfirmed` +- `rate_code_rule_not_covered` +- `settlement_price_required` +- `manual_price_unconfirmed` +- `composite_unit_price_unconfirmed` +- `fix_charge_unconfirmed` +- `qbd_liantai_row_evidence_unreadable` +- `parent_child_split_evidence_unclear` +- `stay_date_inference_unclear` +- `existing_order_record_found` +- `pending_or_active_workflow_found` diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/fixtures/allotment_scope.json b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/fixtures/allotment_scope.json new file mode 100644 index 0000000..d056a5f --- /dev/null +++ b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/fixtures/allotment_scope.json @@ -0,0 +1,145 @@ +{ + "standalone_partial_allotment": { + "body_current": "Please reduce block BLK-2608-A from 20 rooms to 15 rooms and keep the balance active.", + "candidate_events": [], + "input_is_understood": true, + "expected_route": "S10", + "forbidden_event_types": [ + "Allotment Maintenance", + "Update Booking / Amendment", + "Cancel Allotment" + ] + }, + "mixed_partial_allotment": { + "output": { + "source_message": { + "source_message_id": "MSG-ALLOT-MIXED-001", + "subject": "New group and allotment reduction", + "from": "agent@example.com", + "cc": [], + "received_at": "2026-07-11T10:10:00+08:00", + "source_channel": "Email" + }, + "message_events": [ + { + "event_type": "New Booking", + "event_role": "travel_agent_request", + "current_or_history": "current", + "source_event_index": "E1", + "case_keys": { + "group_code": "NEW-GROUP-01", + "confirmation_number": null, + "reservation_number": null, + "block_code": null + }, + "relevant_message_excerpt": "Please create NEW-GROUP-01 for 10-12 Aug, 2 DBL.", + "attachments": [], + "file_references": [], + "context_used": {}, + "extracted_fields": { + "booking_object_type": "Group Block", + "arrival_date": "2026-08-10", + "departure_date": "2026-08-12", + "room_items": [ + { + "room_type_raw": "DBL", + "pms_room_type_code": "RM2", + "room_quantity": 2 + } + ] + }, + "manual_review": null + } + ], + "case_candidates": [], + "extraction_warnings": [], + "unhandled_current_intents": [ + { + "category": "unhandled_current_business_content", + "current_or_history": "current", + "reason_code": "requires_business_approval_or_unsupported_task_card", + "text_raw": "Please reduce block BLK-2608-A from 20 rooms to 15 rooms and keep the balance active.", + "visible_message": "当前邮件还要求减少 BLK-2608-A 的部分配额并保留剩余房量;该事项不在当前支持范围,请查看原文并决定后续处理。", + "requires_user_decision": true, + "case_keys": { + "group_code": null, + "confirmation_number": null, + "reservation_number": null, + "block_code": "BLK-2608-A" + }, + "attachments": [], + "file_references": [] + } + ] + }, + "expected_unhandled_text": "Please reduce block BLK-2608-A from 20 rooms to 15 rooms and keep the balance active.", + "forbidden_event_types": [ + "Allotment Maintenance", + "Update Booking / Amendment", + "Cancel Allotment" + ] + }, + "new_allotment_control_block": { + "event": { + "event_type": "New Booking", + "event_role": "travel_agent_request", + "current_or_history": "current", + "source_event_index": "E_NEW_ALLOTMENT", + "case_keys": { + "group_code": null, + "confirmation_number": null, + "reservation_number": null, + "block_code": "BLK-NEW-01" + }, + "relevant_message_excerpt": "Please create a new control block BLK-NEW-01 for 20 DBL rooms.", + "attachments": [], + "file_references": [], + "context_used": {}, + "extracted_fields": { + "booking_object_type": "Allotment / Control Block", + "arrival_date": "2026-09-01", + "departure_date": "2026-09-05", + "room_items": [ + { + "room_type_raw": "DBL", + "pms_room_type_code": "RM2", + "room_quantity": 20 + } + ] + }, + "manual_review": null + }, + "expected_adapter_triplet": { + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_allotment_control_block" + } + }, + "cancel_entire_allotment": { + "event": { + "event_type": "Cancel Allotment", + "event_role": "travel_agent_request", + "current_or_history": "current", + "source_event_index": "E_CANCEL_ALLOTMENT", + "case_keys": { + "group_code": null, + "confirmation_number": null, + "reservation_number": null, + "block_code": "BLK-CANCEL-01" + }, + "relevant_message_excerpt": "Cancel the entire control block BLK-CANCEL-01 and release all remaining rooms.", + "attachments": [], + "file_references": [], + "context_used": {}, + "extracted_fields": { + "cancel_scope": "entire_allotment_control_block" + }, + "manual_review": null + }, + "expected_adapter_triplet": { + "result_type": "normal_task", + "task_type": "Cancel Allotment", + "task_subtype": "cancel_allotment_control_block" + } + } +} diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/fixtures/candidate_gate.json b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/fixtures/candidate_gate.json new file mode 100644 index 0000000..aa58827 --- /dev/null +++ b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/fixtures/candidate_gate.json @@ -0,0 +1,226 @@ +{ + "cases": [ + { + "case_id": "valid_coarse_attachment_signal", + "expect_valid": true, + "candidate_events": [ + { + "candidate_id": "C1", + "classification_status": "coarse_supported_signal", + "possible_event_types": [ + "Voucher Received", + "Payment Evidence" + ], + "evidence_refs": [ + { + "source": "current_attachments", + "index": 0 + } + ], + "target_hints": [], + "uncertainty_codes": [ + "visual_document_type_not_decided" + ] + } + ], + "expected_action": "invoke_booking_desk_event" + }, + { + "case_id": "valid_single_supported_direction", + "expect_valid": true, + "candidate_events": [ + { + "candidate_id": "C1", + "classification_status": "coarse_supported_signal", + "possible_event_types": [ + "Update Booking / Amendment" + ], + "evidence_refs": [ + { + "source": "body_current", + "index": 0 + } + ], + "target_hints": [ + { + "case_keys": { + "group_code": null, + "confirmation_number": null, + "reservation_number": "344055", + "block_code": null + }, + "target_locator_raw": "reservation 344055" + } + ], + "uncertainty_codes": [] + } + ], + "expected_action": "invoke_booking_desk_event" + }, + { + "case_id": "valid_main_no_active_signal_routes_s10", + "expect_valid": true, + "candidate_events": [], + "input_is_understood": true, + "expected_action": "return_S10" + }, + { + "case_id": "valid_skill_no_supported_event_routes_s10", + "expect_valid": true, + "candidate_events": [ + { + "candidate_id": "C1", + "classification_status": "coarse_supported_signal", + "possible_event_types": [ + "Voucher Received", + "Payment Evidence" + ], + "evidence_refs": [ + { + "source": "current_attachments", + "index": 0 + } + ], + "target_hints": [], + "uncertainty_codes": [ + "document_subtype_unresolved" + ] + } + ], + "skill_disposition": { + "internal_route": "no_supported_event", + "reason_code": "skill_validation_found_no_active_event", + "candidate_ids": [ + "C1" + ] + }, + "expected_action": "convert_internal_no_supported_event_to_S10" + }, + { + "case_id": "invalid_candidate_missing_target_hints", + "expect_valid": false, + "candidate_events": [ + { + "candidate_id": "C1", + "classification_status": "coarse_supported_signal", + "possible_event_types": [ + "New Booking" + ], + "evidence_refs": [ + { + "source": "body_current", + "index": 0 + } + ], + "uncertainty_codes": [] + } + ] + }, + { + "case_id": "invalid_candidate_wrong_status", + "expect_valid": false, + "candidate_events": [ + { + "candidate_id": "C1", + "classification_status": "coarse_unresolved", + "possible_event_types": [ + "New Booking" + ], + "evidence_refs": [ + { + "source": "body_current", + "index": 0 + } + ], + "target_hints": [], + "uncertainty_codes": [] + } + ] + }, + { + "case_id": "invalid_candidate_empty_possible_types", + "expect_valid": false, + "candidate_events": [ + { + "candidate_id": "C1", + "classification_status": "coarse_supported_signal", + "possible_event_types": [], + "evidence_refs": [ + { + "source": "current_attachments", + "index": 0 + } + ], + "target_hints": [], + "uncertainty_codes": [ + "business_direction_not_identified" + ] + } + ] + }, + { + "case_id": "invalid_review_outcome_used_as_candidate", + "expect_valid": false, + "candidate_events": [ + { + "candidate_id": "C1", + "classification_status": "coarse_supported_signal", + "possible_event_types": [ + "Need Manual Review" + ], + "evidence_refs": [ + { + "source": "body_current", + "index": 0 + } + ], + "target_hints": [], + "uncertainty_codes": [ + "event_type_unclear" + ] + } + ] + }, + { + "case_id": "invalid_evidence_refs_string_array", + "expect_valid": false, + "candidate_events": [ + { + "candidate_id": "C1", + "classification_status": "coarse_supported_signal", + "possible_event_types": [ + "New Booking" + ], + "evidence_refs": [ + "body_current:new booking" + ], + "target_hints": [], + "uncertainty_codes": [] + } + ] + }, + { + "case_id": "invalid_target_hints_object", + "expect_valid": false, + "candidate_events": [ + { + "candidate_id": "C1", + "classification_status": "coarse_supported_signal", + "possible_event_types": [ + "Update Booking / Amendment" + ], + "evidence_refs": [ + { + "source": "body_current", + "index": 0 + } + ], + "target_hints": { + "reservation_number": "344055" + }, + "uncertainty_codes": [] + } + ] + } + ] +} diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/fixtures/main_outcomes.json b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/fixtures/main_outcomes.json new file mode 100644 index 0000000..5686379 --- /dev/null +++ b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/fixtures/main_outcomes.json @@ -0,0 +1,175 @@ +{ + "cases": [ + { + "case_id": "legal_s10_no_supported_match", + "expect_valid": true, + "value": { + "source_message": { + "source_message_id": "MSG-S10-001", + "subject": "Please confirm booking details", + "from": "agent@example.com", + "cc": [], + "received_at": "2026-07-11T09:00:00+08:00", + "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": "未匹配到当前 Agent 支持的业务事件类型,请查看原邮件并决定是否需要回复或进行其他处理。" + }, + "manual_review": null + } + }, + { + "case_id": "illegal_s10_with_review_object", + "expect_valid": false, + "value": { + "source_message": { + "source_message_id": "MSG-S10-002", + "subject": null, + "from": null, + "cc": [], + "received_at": null, + "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": { + "reason_code": "material_package_unavailable" + } + } + }, + { + "case_id": "legal_s99_material_unavailable", + "expect_valid": true, + "value": { + "source_message": { + "source_message_id": "MSG-S99-001", + "subject": "See attached", + "from": "agent@example.com", + "cc": [], + "received_at": "2026-07-11T09:05:00+08:00", + "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": "当前输入不足,无法判断是否匹配当前 Agent 支持的业务事件类型,请查看原邮件并决定后续处理。" + }, + "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": [ + "review_source_message" + ], + "evidence_to_check": [ + "source_message" + ], + "known_fields": {} + } + } + }, + { + "case_id": "illegal_s99_with_null_review", + "expect_valid": false, + "value": { + "source_message": { + "source_message_id": "MSG-S99-002", + "subject": null, + "from": null, + "cc": [], + "received_at": null, + "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": null + } + }, + { + "case_id": "illegal_union_template_value", + "expect_valid": false, + "value": { + "source_message": { + "source_message_id": "MSG-UNION-001", + "subject": null, + "from": null, + "cc": [], + "received_at": null, + "source_channel": "Email" + }, + "route_code": "S10 | S99", + "handler_type": "main_agent_outcome", + "result_type": "source_message_review_notification", + "current_or_history": "current", + "agent_assessment": { + "status": "no_booking_action_detected | material_package_unavailable", + "reason_code": "", + "automation_action": "none" + }, + "notification": { + "required": true, + "notification_type": "source_message_review", + "show_source_message": true, + "requires_user_decision": true, + "visible_message": "" + }, + "manual_review": null + } + } + ] +} diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/fixtures/manual_review_resolution.json b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/fixtures/manual_review_resolution.json new file mode 100644 index 0000000..d865aba --- /dev/null +++ b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/fixtures/manual_review_resolution.json @@ -0,0 +1,153 @@ +{ + "known_subtype_manual_review": { + "event": { + "event_type": "New Booking", + "event_role": "travel_agent_request", + "current_or_history": "current", + "source_event_index": "E_SUITE_REVIEW", + "case_keys": { + "group_code": "CHILD-SUITE-01", + "confirmation_number": null, + "reservation_number": null, + "block_code": null + }, + "relevant_message_excerpt": "CHILD-SUITE-01 10-12 AUG 2 SUITE", + "attachments": [ + "booking-update.xlsx" + ], + "file_references": [], + "context_used": { + "parent_group_code": "PARENT-SUITE-01" + }, + "extracted_fields": { + "booking_object_type": "Group Block", + "arrival_date": "2026-08-10", + "departure_date": "2026-08-12", + "room_items": [ + { + "room_type_raw": "SUITE", + "room_type_normalized": "SUITE", + "pms_room_type_code": null, + "room_quantity": 2, + "mapping_candidates": [ + "SU1", + "SU6" + ] + } + ] + }, + "manual_review": { + "reason_code": "room_type_mapping_multiple_candidates", + "visible_reason": "房型原文 SUITE 无法唯一映射到 PMS 房型,请在当前 New Booking 卡确认。", + "review_record_type": "business_event_review", + "missing_fields": [ + "/extracted_fields/room_items/0/pms_room_type_code" + ], + "blocking_points": [ + "PMS 房型未确认,当前事件不可进入 Preflight。" + ], + "conflicting_points": [ + "SU1", + "SU6" + ], + "suggested_human_actions": [ + "select_and_confirm_pms_room_type" + ], + "evidence_to_check": [ + "room_type_raw", + "current_attachment" + ], + "known_fields": { + "group_code": "CHILD-SUITE-01", + "booking_object_type": "Group Block", + "arrival_date": "2026-08-10", + "departure_date": "2026-08-12", + "room_quantity": 2 + } + } + }, + "expected_adapter_triplet": { + "result_type": "manual_review", + "task_type": "New Booking", + "task_subtype": "new_group_block" + } + }, + "type_unknown_fallback": { + "event": { + "event_type": "Need Manual Review", + "event_role": "travel_agent_request", + "current_or_history": "current", + "source_event_index": "E_TYPE_UNKNOWN", + "case_keys": { + "group_code": null, + "confirmation_number": null, + "reservation_number": null, + "block_code": null + }, + "relevant_message_excerpt": "Please process this as new or amend; the action is unclear.", + "attachments": [], + "file_references": [], + "context_used": {}, + "extracted_fields": {}, + "manual_review": { + "reason_code": "event_type_conflict_unclear", + "visible_reason": "无法确定当前请求是新订还是改单。", + "review_record_type": "business_event_review", + "missing_fields": [], + "blocking_points": [ + "业务类型无法确定。" + ], + "conflicting_points": [ + "New Booking", + "Update Booking / Amendment" + ], + "suggested_human_actions": [ + "select_business_event_type" + ], + "evidence_to_check": [ + "source_message" + ], + "known_fields": {} + } + }, + "expected_adapter_triplet": { + "result_type": "manual_review", + "task_type": "Fallback", + "task_subtype": "business_event_review" + } + }, + "same_card_resolution": { + "source_case": "known_subtype_manual_review", + "task_identity": { + "task_id": "TASK-SUITE-REVIEW-001", + "result_type": "manual_review", + "task_type": "New Booking", + "task_subtype": "new_group_block" + }, + "initial_state": { + "review_status": "pending", + "review_resolution": null + }, + "resolved_state": { + "review_status": "resolved", + "review_resolution": { + "field_overrides": [ + { + "field_pointer": "/extracted_fields/room_items/0/pms_room_type_code", + "value": "SU1" + } + ], + "resolved_by": "user-123", + "resolved_at": "2026-07-11T10:30:00+08:00" + } + }, + "expected": { + "task_id": "TASK-SUITE-REVIEW-001", + "initial_result_type": "manual_review", + "review_status": "resolved", + "next_state": "preflight", + "creates_second_card": false, + "agent_payload_immutable": true + } + } +} diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/fixtures/parent_split_two_children.json b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/fixtures/parent_split_two_children.json new file mode 100644 index 0000000..0d84d70 --- /dev/null +++ b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/fixtures/parent_split_two_children.json @@ -0,0 +1,125 @@ +{ + "source_message": { + "source_message_id": "MSG-PARENT-SPLIT-001", + "subject": "AMEND FINAL BOOKING", + "from": "agent@example.com", + "cc": [], + "received_at": "2026-07-11T10:00:00+08:00", + "source_channel": "Email" + }, + "message_events": [ + { + "event_type": "New Booking", + "event_role": "travel_agent_request", + "current_or_history": "current", + "source_event_index": "E_CHILD_1", + "case_keys": { + "group_code": "CHILD-2608-A", + "confirmation_number": null, + "reservation_number": null, + "block_code": null + }, + "relevant_message_excerpt": "PARENT-2608 AMEND TO CHILD-2608-A 10-12 AUG 2 DBL", + "attachments": [ + "booking-update.xlsx" + ], + "file_references": [], + "context_used": { + "parent_child_split_evidence": "current" + }, + "extracted_fields": { + "booking_object_type": "Group Block", + "allocation_split_from_parent": true, + "parent_group_code": "PARENT-2608", + "arrival_date": "2026-08-10", + "departure_date": "2026-08-12", + "room_items": [ + { + "room_type_raw": "DBL", + "pms_room_type_code": "RM2", + "room_quantity": 2 + } + ] + }, + "manual_review": null + }, + { + "event_type": "New Booking", + "event_role": "travel_agent_request", + "current_or_history": "current", + "source_event_index": "E_CHILD_2", + "case_keys": { + "group_code": "CHILD-2608-B", + "confirmation_number": null, + "reservation_number": null, + "block_code": null + }, + "relevant_message_excerpt": "PARENT-2608 AMEND TO CHILD-2608-B 12-14 AUG 3 TWN", + "attachments": [ + "booking-update.xlsx" + ], + "file_references": [], + "context_used": { + "parent_child_split_evidence": "current" + }, + "extracted_fields": { + "booking_object_type": "Group Block", + "allocation_split_from_parent": true, + "parent_group_code": "PARENT-2608", + "arrival_date": "2026-08-12", + "departure_date": "2026-08-14", + "room_items": [ + { + "room_type_raw": "TWN", + "pms_room_type_code": "RM3", + "room_quantity": 3 + } + ] + }, + "manual_review": null + }, + { + "event_type": "Cancel Booking", + "event_role": "travel_agent_request", + "current_or_history": "current", + "source_event_index": "E_PARENT_CANCEL", + "case_keys": { + "group_code": "PARENT-2608", + "confirmation_number": null, + "reservation_number": null, + "block_code": null + }, + "relevant_message_excerpt": "PARENT-2608 AMEND TO CHILD-2608-A / CHILD-2608-B", + "attachments": [ + "booking-update.xlsx" + ], + "file_references": [], + "context_used": { + "source": "current parent-to-child split evidence" + }, + "extracted_fields": { + "cancel_object_type": "group_block", + "parent_release_or_cancel_candidate": true, + "release_reason": "parent_to_child_allocation_split", + "parent_group_code": "PARENT-2608", + "child_group_codes": [ + "CHILD-2608-A", + "CHILD-2608-B" + ], + "allocation_split_from_parent": true, + "parent_original_room_summary": "5 rooms" + }, + "related_source_event_indices": [ + "E_CHILD_1", + "E_CHILD_2" + ], + "related_event_type": "New Booking", + "relationship_type": "linked_parent_release_after_child_split", + "requires_downstream_hard_validation": true, + "manual_review": null + } + ], + "case_candidates": [], + "extraction_warnings": [], + "unhandled_current_intents": [] +} diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/fixtures/row_multiple_derived.json b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/fixtures/row_multiple_derived.json new file mode 100644 index 0000000..281f450 --- /dev/null +++ b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/fixtures/row_multiple_derived.json @@ -0,0 +1,146 @@ +{ + "source_message": { + "source_message_id": "MSG-ROW-DERIVED-001", + "subject": "BOOKING UPDATE 08-2026", + "from": "agent@example.com", + "cc": [], + "received_at": "2026-07-11T10:05:00+08:00", + "source_channel": "Email" + }, + "message_events": [ + { + "event_type": "New Booking", + "event_role": "travel_agent_request", + "current_or_history": "current", + "source_event_index": "E_ROW_CHILD", + "case_keys": { + "group_code": "CHILD-ROW-01", + "confirmation_number": null, + "reservation_number": null, + "block_code": null + }, + "relevant_message_excerpt": "PARENT-ROW-01 AMEND TO CHILD-ROW-01, 10-12 AUG, 2 DBL, add one extra bed", + "attachments": [ + "booking-update.xlsx" + ], + "file_references": [], + "context_used": { + "row_evidence": { + "row_reference": "booking-update.xlsx#BOOKING 08-2026#42", + "sheet": "BOOKING 08-2026", + "row_index": 42, + "highlighted": true + } + }, + "extracted_fields": { + "booking_object_type": "Group Block", + "parent_group_code": "PARENT-ROW-01", + "allocation_split_from_parent": true, + "arrival_date": "2026-08-10", + "departure_date": "2026-08-12", + "room_items": [ + { + "room_type_raw": "DBL", + "pms_room_type_code": "RM2", + "room_quantity": 2 + } + ] + }, + "manual_review": null + }, + { + "event_type": "Cancel Booking", + "event_role": "travel_agent_request", + "current_or_history": "current", + "source_event_index": "E_ROW_PARENT", + "case_keys": { + "group_code": "PARENT-ROW-01", + "confirmation_number": null, + "reservation_number": null, + "block_code": null + }, + "relevant_message_excerpt": "PARENT-ROW-01 AMEND TO CHILD-ROW-01", + "attachments": [ + "booking-update.xlsx" + ], + "file_references": [], + "context_used": { + "row_evidence": { + "row_reference": "booking-update.xlsx#BOOKING 08-2026#42", + "sheet": "BOOKING 08-2026", + "row_index": 42, + "highlighted": true + } + }, + "extracted_fields": { + "cancel_object_type": "group_block", + "parent_release_or_cancel_candidate": true, + "parent_group_code": "PARENT-ROW-01", + "child_group_codes": [ + "CHILD-ROW-01" + ] + }, + "related_source_event_indices": [ + "E_ROW_CHILD" + ], + "related_event_type": "New Booking", + "relationship_type": "linked_parent_release_after_child_split", + "requires_downstream_hard_validation": true, + "manual_review": null + }, + { + "event_type": "Trace", + "event_role": "travel_agent_request", + "current_or_history": "current", + "source_event_index": "E_ROW_TRACE", + "case_keys": { + "group_code": "CHILD-ROW-01", + "confirmation_number": null, + "reservation_number": null, + "block_code": null + }, + "relevant_message_excerpt": "add one extra bed", + "attachments": [ + "booking-update.xlsx" + ], + "file_references": [], + "context_used": { + "row_evidence": { + "row_reference": "booking-update.xlsx#BOOKING 08-2026#42", + "sheet": "BOOKING 08-2026", + "row_index": 42, + "highlighted": true + } + }, + "extracted_fields": { + "trace_subtype": "extra_bed", + "trace_text": "add one extra bed", + "trace_items": [ + { + "category": "extra_bed", + "text_raw": "add one extra bed", + "service_date": null, + "service_period_raw": null, + "pax": null, + "notify_departments": [ + "FO", + "HSK" + ] + } + ], + "notify_departments": [ + "FO", + "HSK" + ] + }, + "related_source_event_index": "E_ROW_CHILD", + "related_event_type": "New Booking", + "relationship_type": "linked_trace", + "requires_downstream_hard_validation": true, + "manual_review": null + } + ], + "case_candidates": [], + "extraction_warnings": [], + "unhandled_current_intents": [] +} diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/fixtures/source_identity_errors.json b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/fixtures/source_identity_errors.json new file mode 100644 index 0000000..0ffcd63 --- /dev/null +++ b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/fixtures/source_identity_errors.json @@ -0,0 +1,106 @@ +{ + "cases": [ + { + "case_id": "missing_source_message_id_cancel", + "message_class": "Cancel", + "input": { + "source_message": {}, + "body_current": "Please cancel group HD260710A" + }, + "expected_error": { + "result_type": "infrastructure_input_error", + "error_code": "missing_source_message_id", + "retryable": true, + "missing_fields": [ + "source_message.source_message_id" + ] + }, + "expected_processing": { + "material_reads": 0, + "skill_calls": 0 + } + }, + { + "case_id": "null_source_message_id_cancel", + "message_class": "Cancel", + "input": { + "source_message": { + "source_message_id": null + }, + "body_current": "Please cancel group HD260710A" + }, + "expected_error": { + "result_type": "infrastructure_input_error", + "error_code": "missing_source_message_id", + "retryable": true, + "missing_fields": [ + "source_message.source_message_id" + ] + }, + "expected_processing": { + "material_reads": 0, + "skill_calls": 0 + } + }, + { + "case_id": "empty_source_message_id_thanks", + "message_class": "Thanks", + "input": { + "source_message": { + "source_message_id": "" + }, + "body_current": "Thanks" + }, + "expected_error": { + "result_type": "infrastructure_input_error", + "error_code": "missing_source_message_id", + "retryable": true, + "missing_fields": [ + "source_message.source_message_id" + ] + }, + "expected_processing": { + "material_reads": 0, + "skill_calls": 0 + } + }, + { + "case_id": "whitespace_source_message_id_see_attached", + "message_class": "See Attached", + "input": { + "source_message": { + "source_message_id": " " + }, + "body_current": "Please see attached", + "current_attachments": [ + { + "filename": "unknown.pdf" + } + ] + }, + "expected_error": { + "result_type": "infrastructure_input_error", + "error_code": "missing_source_message_id", + "retryable": true, + "missing_fields": [ + "source_message.source_message_id" + ] + }, + "expected_processing": { + "material_reads": 0, + "skill_calls": 0 + } + }, + { + "case_id": "nonblank_source_message_id_passes_guard", + "message_class": "Cancel", + "input": { + "source_message": { + "source_message_id": "MSG-VALID-001" + }, + "body_current": "Please cancel group HD260710A" + }, + "expected_error": null + } + ] +} diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/validate_p0_contracts.mjs b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/validate_p0_contracts.mjs new file mode 100644 index 0000000..58601aa --- /dev/null +++ b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/validate_p0_contracts.mjs @@ -0,0 +1,674 @@ +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const fixturesDir = join(here, "fixtures"); + +const ACTIVE_EVENT_TYPES = new Set([ + "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" +]); + +const SOURCE_KEYS = [ + "source_message_id", + "subject", + "from", + "cc", + "received_at", + "source_channel" +]; + +const CASE_KEY_KEYS = [ + "group_code", + "confirmation_number", + "reservation_number", + "block_code" +]; + +const REVIEW_KEYS = [ + "reason_code", + "visible_reason", + "review_record_type", + "missing_fields", + "blocking_points", + "conflicting_points", + "suggested_human_actions", + "evidence_to_check", + "known_fields" +]; + +const CANDIDATE_KEYS = [ + "candidate_id", + "classification_status", + "possible_event_types", + "evidence_refs", + "target_hints", + "uncertainty_codes" +]; + +const EVIDENCE_SOURCES = new Set([ + "body_current", + "current_attachments", + "current_tables", + "current_ocr", + "parent_child_split_evidence" +]); + +const failures = []; +let passed = 0; +let negativeFixturesRejected = 0; + +function runCheck(name, fn) { + try { + fn(); + passed += 1; + process.stdout.write(`PASS ${name}\n`); + } catch (error) { + failures.push({ name, error }); + process.stderr.write(`FAIL ${name}: ${error.message}\n`); + } +} + +function loadFixture(name) { + return JSON.parse(readFileSync(join(fixturesDir, name), "utf8")); +} + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function assertExactKeys(value, expected, label) { + assert.ok(isPlainObject(value), `${label} must be an object`); + assert.deepEqual(Object.keys(value).sort(), [...expected].sort(), `${label} key set`); +} + +function assertNonblank(value, label) { + assert.equal(typeof value, "string", `${label} must be a string`); + assert.ok(value.trim().length > 0, `${label} must be nonblank`); +} + +function assertStringOrNull(value, label) { + assert.ok(value === null || typeof value === "string", `${label} must be string|null`); +} + +function assertStringArray(value, label, { nonempty = false } = {}) { + assert.ok(Array.isArray(value), `${label} must be an array`); + if (nonempty) assert.ok(value.length > 0, `${label} must not be empty`); + for (const item of value) assert.equal(typeof item, "string", `${label} items must be strings`); +} + +function validateSourceMessage(source) { + assertExactKeys(source, SOURCE_KEYS, "source_message"); + assertNonblank(source.source_message_id, "source_message.source_message_id"); + assertStringOrNull(source.subject, "source_message.subject"); + assertStringOrNull(source.from, "source_message.from"); + assertStringArray(source.cc, "source_message.cc"); + assertStringOrNull(source.received_at, "source_message.received_at"); + assert.equal(source.source_channel, "Email", "source_message.source_channel"); +} + +function validateCaseKeys(caseKeys) { + assertExactKeys(caseKeys, CASE_KEY_KEYS, "case_keys"); + for (const key of CASE_KEY_KEYS) assertStringOrNull(caseKeys[key], `case_keys.${key}`); +} + +function isRfc6901Pointer(pointer) { + if (typeof pointer !== "string" || !pointer.startsWith("/")) return false; + return pointer + .slice(1) + .split("/") + .every((token) => !/~(?![01])/u.test(token)); +} + +function pointerTokens(pointer) { + assert.ok(isRfc6901Pointer(pointer), `invalid RFC 6901 JSON Pointer: ${pointer}`); + return pointer + .slice(1) + .split("/") + .map((token) => token.replaceAll("~1", "/").replaceAll("~0", "~")); +} + +function resolvePointer(document, pointer) { + let current = document; + for (const token of pointerTokens(pointer)) { + assert.ok(current !== null && typeof current === "object", `${pointer} traverses a scalar`); + assert.ok(Object.prototype.hasOwnProperty.call(current, token), `${pointer} target does not exist`); + current = current[token]; + } + return current; +} + +function setPointer(document, pointer, value) { + const tokens = pointerTokens(pointer); + const finalToken = tokens.pop(); + let current = document; + for (const token of tokens) { + assert.ok(current !== null && typeof current === "object", `${pointer} traverses a scalar`); + assert.ok(Object.prototype.hasOwnProperty.call(current, token), `${pointer} target does not exist`); + current = current[token]; + } + assert.ok(Object.prototype.hasOwnProperty.call(current, finalToken), `${pointer} target does not exist`); + current[finalToken] = value; +} + +function validateReview(review, recordType, { businessEvent } = {}) { + assertExactKeys(review, REVIEW_KEYS, "manual_review"); + assertNonblank(review.reason_code, "manual_review.reason_code"); + assertNonblank(review.visible_reason, "manual_review.visible_reason"); + assert.equal(review.review_record_type, recordType, "manual_review.review_record_type"); + for (const key of [ + "missing_fields", + "blocking_points", + "conflicting_points", + "suggested_human_actions", + "evidence_to_check" + ]) { + assertStringArray(review[key], `manual_review.${key}`); + } + assert.ok(isPlainObject(review.known_fields), "manual_review.known_fields must be an object"); + if (businessEvent) { + for (const pointer of review.missing_fields) { + assert.ok(isRfc6901Pointer(pointer), `${pointer} must be RFC 6901`); + resolvePointer(businessEvent, pointer); + } + } +} + +function validateMainOutcome(value) { + assertExactKeys(value, [ + "source_message", + "route_code", + "handler_type", + "result_type", + "current_or_history", + "agent_assessment", + "notification", + "manual_review" + ], "main outcome"); + validateSourceMessage(value.source_message); + assert.ok(value.route_code === "S10" || value.route_code === "S99", "route_code must be S10|S99"); + assert.equal(value.handler_type, "main_agent_outcome"); + assert.equal(value.result_type, "source_message_review_notification"); + assert.equal(value.current_or_history, "current"); + assertExactKeys(value.agent_assessment, ["status", "reason_code", "automation_action"], "agent_assessment"); + assert.equal(value.agent_assessment.automation_action, "none"); + assertExactKeys(value.notification, [ + "required", + "notification_type", + "show_source_message", + "requires_user_decision", + "visible_message" + ], "notification"); + assert.equal(value.notification.required, true); + assert.equal(value.notification.notification_type, "source_message_review"); + assert.equal(value.notification.show_source_message, true); + assert.equal(value.notification.requires_user_decision, true); + assertNonblank(value.notification.visible_message, "notification.visible_message"); + assert.ok(!Object.prototype.hasOwnProperty.call(value.notification, "action_required")); + + if (value.route_code === "S10") { + assert.equal(value.agent_assessment.status, "no_booking_action_detected"); + assert.equal(value.agent_assessment.reason_code, "no_booking_action_detected"); + assert.equal(value.manual_review, null, "S10.manual_review must be null"); + } else { + assert.equal(value.agent_assessment.status, "material_package_unavailable"); + assert.equal(value.agent_assessment.reason_code, "material_package_unavailable"); + validateReview(value.manual_review, "main_agent_entry_review"); + assert.equal(value.manual_review.reason_code, value.agent_assessment.reason_code); + } +} + +function validateCandidateEvents(candidateEvents) { + assert.ok(Array.isArray(candidateEvents), "candidate_events must be an array"); + const ids = new Set(); + for (const candidate of candidateEvents) { + assertExactKeys(candidate, CANDIDATE_KEYS, "candidate_event"); + assert.match(candidate.candidate_id, /^C[1-9][0-9]*$/u, "candidate_id format"); + assert.ok(!ids.has(candidate.candidate_id), `duplicate candidate_id ${candidate.candidate_id}`); + ids.add(candidate.candidate_id); + assert.equal(candidate.classification_status, "coarse_supported_signal"); + assertStringArray(candidate.possible_event_types, "possible_event_types", { nonempty: true }); + assert.equal(new Set(candidate.possible_event_types).size, candidate.possible_event_types.length, "possible_event_types must be unique"); + for (const eventType of candidate.possible_event_types) { + assert.ok(ACTIVE_EVENT_TYPES.has(eventType), `${eventType} is not an active event type`); + } + assert.ok(Array.isArray(candidate.evidence_refs) && candidate.evidence_refs.length > 0, "evidence_refs must be a nonempty array"); + for (const evidenceRef of candidate.evidence_refs) { + assertExactKeys(evidenceRef, ["source", "index"], "candidate evidence_ref"); + assert.ok(EVIDENCE_SOURCES.has(evidenceRef.source), `invalid evidence source ${evidenceRef.source}`); + assert.ok(Number.isInteger(evidenceRef.index) && evidenceRef.index >= 0, "evidence_ref.index must be a nonnegative integer"); + if (evidenceRef.source === "body_current") assert.equal(evidenceRef.index, 0, "body_current index must be 0"); + } + assert.ok(Array.isArray(candidate.target_hints), "target_hints must be an array"); + for (const targetHint of candidate.target_hints) { + assertExactKeys(targetHint, ["case_keys", "target_locator_raw"], "target_hint"); + validateCaseKeys(targetHint.case_keys); + assertStringOrNull(targetHint.target_locator_raw, "target_hint.target_locator_raw"); + } + assertStringArray(candidate.uncertainty_codes, "uncertainty_codes"); + } +} + +function validateNoSupportedEventDisposition(disposition, candidateEvents) { + assertExactKeys(disposition, ["internal_route", "reason_code", "candidate_ids"], "no_supported_event"); + assert.equal(disposition.internal_route, "no_supported_event"); + assert.equal(disposition.reason_code, "skill_validation_found_no_active_event"); + assertStringArray(disposition.candidate_ids, "no_supported_event.candidate_ids", { nonempty: true }); + const knownCandidateIds = new Set(candidateEvents.map((candidate) => candidate.candidate_id)); + for (const candidateId of disposition.candidate_ids) { + assert.ok(knownCandidateIds.has(candidateId), `no_supported_event references unknown ${candidateId}`); + } + assert.deepEqual([...new Set(disposition.candidate_ids)].sort(), [...knownCandidateIds].sort()); +} + +function validateEvent(event) { + for (const key of [ + "event_type", + "event_role", + "current_or_history", + "source_event_index", + "case_keys", + "relevant_message_excerpt", + "attachments", + "file_references", + "context_used", + "extracted_fields", + "manual_review" + ]) { + assert.ok(Object.prototype.hasOwnProperty.call(event, key), `event missing ${key}`); + } + assert.ok(ACTIVE_EVENT_TYPES.has(event.event_type) || event.event_type === "Need Manual Review", `invalid event_type ${event.event_type}`); + assertNonblank(event.event_role, "event_role"); + assert.equal(event.current_or_history, "current"); + assertNonblank(event.source_event_index, "source_event_index"); + validateCaseKeys(event.case_keys); + assert.equal(typeof event.relevant_message_excerpt, "string"); + assert.ok(Array.isArray(event.attachments)); + assert.ok(Array.isArray(event.file_references)); + assert.ok(isPlainObject(event.context_used)); + assert.ok(isPlainObject(event.extracted_fields)); + if (event.manual_review !== null) validateReview(event.manual_review, "business_event_review", { businessEvent: event }); + if (event.event_type === "Need Manual Review") assert.notEqual(event.manual_review, null, "type-unknown Fallback needs manual_review"); +} + +function validateUnhandledIntent(item) { + assertExactKeys(item, [ + "category", + "current_or_history", + "reason_code", + "text_raw", + "visible_message", + "requires_user_decision", + "case_keys", + "attachments", + "file_references" + ], "unhandled_current_intent"); + assert.equal(item.category, "unhandled_current_business_content"); + assert.equal(item.current_or_history, "current"); + assert.equal(item.reason_code, "requires_business_approval_or_unsupported_task_card"); + assertNonblank(item.text_raw, "unhandled text_raw"); + assertNonblank(item.visible_message, "unhandled visible_message"); + assert.equal(item.requires_user_decision, true); + validateCaseKeys(item.case_keys); + assert.ok(Array.isArray(item.attachments)); + assert.ok(Array.isArray(item.file_references)); +} + +function validateBusinessRoot(root) { + assertExactKeys(root, [ + "source_message", + "message_events", + "case_candidates", + "extraction_warnings", + "unhandled_current_intents" + ], "business root"); + validateSourceMessage(root.source_message); + assert.ok(Array.isArray(root.message_events)); + assert.ok(Array.isArray(root.case_candidates)); + assert.ok(Array.isArray(root.extraction_warnings)); + assert.ok(Array.isArray(root.unhandled_current_intents)); + const indexes = new Set(); + for (const event of root.message_events) { + validateEvent(event); + assert.ok(!indexes.has(event.source_event_index), `duplicate source_event_index ${event.source_event_index}`); + indexes.add(event.source_event_index); + } + for (const item of root.unhandled_current_intents) validateUnhandledIntent(item); +} + +function deriveAdapterTriplet(event) { + validateEvent(event); + if (event.event_type === "Need Manual Review") { + return { + result_type: "manual_review", + task_type: "Fallback", + task_subtype: "business_event_review" + }; + } + + const resultType = event.manual_review === null ? "normal_task" : "manual_review"; + if (event.event_type === "New Booking") { + const subtypeByObject = { + "FIT Reservation": "new_fit_reservation", + "Group Block": "new_group_block", + "Allotment / Control Block": "new_allotment_control_block" + }; + const subtype = subtypeByObject[event.extracted_fields.booking_object_type]; + assert.ok(subtype, "New Booking requires known booking_object_type"); + return { result_type: resultType, task_type: "New Booking", task_subtype: subtype }; + } + if (event.event_type === "Update Booking / Amendment") { + return { result_type: resultType, task_type: "Update Booking", task_subtype: "update_booking_amendment" }; + } + if (event.event_type === "Cancel Booking") { + if (event.relationship_type === "linked_parent_release_after_child_split") { + return { + result_type: resultType, + task_type: "Cancel Booking", + task_subtype: "linked_parent_release_after_child_split" + }; + } + const subtypeByObject = { + fit_reservation: "cancel_fit_reservation", + group_block: "cancel_group_block" + }; + const subtype = subtypeByObject[event.extracted_fields.cancel_object_type]; + assert.ok(subtype, "Cancel Booking requires known cancel_object_type"); + return { result_type: resultType, task_type: "Cancel Booking", task_subtype: subtype }; + } + if (event.event_type === "Cancel Allotment") { + return { + result_type: resultType, + task_type: "Cancel Allotment", + task_subtype: "cancel_allotment_control_block" + }; + } + if (event.event_type === "Trace") { + assert.ok(event.extracted_fields.trace_subtype === "extra_bed" || event.extracted_fields.trace_subtype === "general_request"); + return { + result_type: resultType, + task_type: "Trace / Reservation Notes", + task_subtype: event.extracted_fields.trace_subtype + }; + } + throw new Error(`triplet derivation not implemented for ${event.event_type}`); +} + +function infrastructureGuard(input) { + const sourceId = input?.source_message?.source_message_id; + if (typeof sourceId !== "string" || sourceId.trim() === "") { + return { + result_type: "infrastructure_input_error", + error_code: "missing_source_message_id", + retryable: true, + missing_fields: ["source_message.source_message_id"] + }; + } + return null; +} + +function processSourceIdentityStep(input) { + const processing = { + material_reads: 0, + skill_calls: 0 + }; + const outcome = infrastructureGuard(input); + return { outcome, processing }; +} + +function resolveSameCard(taskIdentity, initialState, resolvedState, agentEvent) { + assertExactKeys(initialState, ["review_status", "review_resolution"], "initial review state"); + assert.equal(initialState.review_status, "pending"); + assert.equal(initialState.review_resolution, null); + assertExactKeys(resolvedState, ["review_status", "review_resolution"], "resolved review state"); + assert.equal(resolvedState.review_status, "resolved"); + assertExactKeys(resolvedState.review_resolution, ["field_overrides", "resolved_by", "resolved_at"], "review_resolution"); + assert.ok(Array.isArray(resolvedState.review_resolution.field_overrides)); + assert.ok(resolvedState.review_resolution.field_overrides.length > 0, "field_overrides must not be empty"); + assertNonblank(resolvedState.review_resolution.resolved_by, "review_resolution.resolved_by"); + assertNonblank(resolvedState.review_resolution.resolved_at, "review_resolution.resolved_at"); + const originalSnapshot = JSON.stringify(agentEvent); + const effectivePayload = structuredClone(agentEvent); + for (const override of resolvedState.review_resolution.field_overrides) { + assertExactKeys(override, ["field_pointer", "value"], "field_override"); + assert.ok(agentEvent.manual_review.missing_fields.includes(override.field_pointer), `${override.field_pointer} is not a declared missing field`); + setPointer(effectivePayload, override.field_pointer, override.value); + } + for (const pointer of agentEvent.manual_review.missing_fields) { + const value = resolvePointer(effectivePayload, pointer); + assert.ok(value !== null && value !== "", `${pointer} remains unresolved`); + } + assert.equal(JSON.stringify(agentEvent), originalSnapshot, "Agent payload mutated"); + return { + task_id: taskIdentity.task_id, + initial_result_type: taskIdentity.result_type, + review_status: resolvedState.review_status, + next_state: "preflight", + creates_second_card: false, + agent_payload_immutable: true + }; +} + +runCheck("all JSON fixtures parse", () => { + const files = readdirSync(fixturesDir).filter((name) => name.endsWith(".json")); + assert.deepEqual(files.sort(), [ + "allotment_scope.json", + "candidate_gate.json", + "main_outcomes.json", + "manual_review_resolution.json", + "parent_split_two_children.json", + "row_multiple_derived.json", + "source_identity_errors.json" + ]); + for (const file of files) loadFixture(file); +}); + +const mainOutcomes = loadFixture("main_outcomes.json"); +for (const fixture of mainOutcomes.cases) { + runCheck(`main outcome ${fixture.case_id}`, () => { + if (fixture.expect_valid) { + validateMainOutcome(fixture.value); + } else { + assert.throws(() => validateMainOutcome(fixture.value)); + negativeFixturesRejected += 1; + } + }); +} + +const candidateGate = loadFixture("candidate_gate.json"); +for (const fixture of candidateGate.cases) { + runCheck(`candidate gate ${fixture.case_id}`, () => { + if (fixture.expect_valid) { + validateCandidateEvents(fixture.candidate_events); + if (fixture.candidate_events.length === 0) { + assert.equal(fixture.input_is_understood, true); + assert.equal(fixture.expected_action, "return_S10"); + assert.ok(!Object.prototype.hasOwnProperty.call(fixture, "skill_disposition"), "Main no-candidate route does not come from Skill"); + } else if (fixture.skill_disposition) { + validateNoSupportedEventDisposition(fixture.skill_disposition, fixture.candidate_events); + assert.equal(fixture.expected_action, "convert_internal_no_supported_event_to_S10"); + } else { + assert.equal(fixture.expected_action, "invoke_booking_desk_event"); + } + } else { + assert.throws(() => validateCandidateEvents(fixture.candidate_events)); + negativeFixturesRejected += 1; + } + }); +} + +const parentSplit = loadFixture("parent_split_two_children.json"); +runCheck("Parent split has two Child New events and one linked Parent Cancel", () => { + validateBusinessRoot(parentSplit); + const children = parentSplit.message_events.filter((event) => event.event_type === "New Booking"); + const parents = parentSplit.message_events.filter((event) => event.relationship_type === "linked_parent_release_after_child_split"); + assert.equal(children.length, 2); + assert.equal(parents.length, 1); + for (const child of children) { + assert.equal(child.extracted_fields.booking_object_type, "Group Block"); + assert.notEqual(child.case_keys.group_code, "PARENT-2608"); + } + const parent = parents[0]; + assert.equal(parent.event_type, "Cancel Booking"); + assert.equal(parent.extracted_fields.cancel_object_type, "group_block"); + assert.equal(parent.case_keys.group_code, "PARENT-2608"); + assert.equal(parent.requires_downstream_hard_validation, true); + assert.deepEqual( + [...parent.related_source_event_indices].sort(), + children.map((child) => child.source_event_index).sort() + ); + assert.deepEqual( + [...parent.extracted_fields.child_group_codes].sort(), + children.map((child) => child.case_keys.group_code).sort() + ); + assert.deepEqual(deriveAdapterTriplet(parent), { + result_type: "normal_task", + task_type: "Cancel Booking", + task_subtype: "linked_parent_release_after_child_split" + }); + assert.ok(parentSplit.message_events.every((event) => event.event_type !== "Allotment Maintenance")); +}); + +const rowDerived = loadFixture("row_multiple_derived.json"); +runCheck("one current row may produce a main event plus linked derived events", () => { + validateBusinessRoot(rowDerived); + assert.ok(rowDerived.message_events.length > 1); + const rowReferences = new Set( + rowDerived.message_events.map((event) => event.context_used.row_evidence.row_reference) + ); + assert.deepEqual([...rowReferences], ["booking-update.xlsx#BOOKING 08-2026#42"]); + const main = rowDerived.message_events.find((event) => event.source_event_index === "E_ROW_CHILD"); + const parent = rowDerived.message_events.find((event) => event.relationship_type === "linked_parent_release_after_child_split"); + const trace = rowDerived.message_events.find((event) => event.relationship_type === "linked_trace"); + assert.equal(main.event_type, "New Booking"); + assert.deepEqual(parent.related_source_event_indices, [main.source_event_index]); + assert.equal(trace.related_source_event_index, main.source_event_index); + assert.equal(trace.case_keys.group_code, main.case_keys.group_code); + assert.equal(trace.extracted_fields.trace_subtype, "extra_bed"); +}); + +const allotmentScope = loadFixture("allotment_scope.json"); +runCheck("standalone partial allotment maintenance is a Main no-candidate S10", () => { + const fixture = allotmentScope.standalone_partial_allotment; + validateCandidateEvents(fixture.candidate_events); + assert.equal(fixture.candidate_events.length, 0); + assert.equal(fixture.input_is_understood, true); + assert.equal(fixture.expected_route, "S10"); +}); + +runCheck("mixed partial allotment maintenance is preserved as unhandled content", () => { + const fixture = allotmentScope.mixed_partial_allotment; + validateBusinessRoot(fixture.output); + assert.equal(fixture.output.message_events.length, 1); + assert.equal(fixture.output.unhandled_current_intents.length, 1); + assert.equal(fixture.output.unhandled_current_intents[0].text_raw, fixture.expected_unhandled_text); + for (const forbidden of fixture.forbidden_event_types) { + assert.ok(!fixture.output.message_events.some((event) => event.event_type === forbidden)); + } +}); + +runCheck("new Allotment or Control Block remains supported", () => { + const fixture = allotmentScope.new_allotment_control_block; + validateEvent(fixture.event); + assert.deepEqual(deriveAdapterTriplet(fixture.event), fixture.expected_adapter_triplet); +}); + +runCheck("entire Allotment or Control Block cancellation remains supported", () => { + const fixture = allotmentScope.cancel_entire_allotment; + validateEvent(fixture.event); + assert.deepEqual(deriveAdapterTriplet(fixture.event), fixture.expected_adapter_triplet); +}); + +const manualReview = loadFixture("manual_review_resolution.json"); +runCheck("generic SUITE keeps known New Booking subtype in manual-review mode", () => { + const fixture = manualReview.known_subtype_manual_review; + validateEvent(fixture.event); + assert.equal(fixture.event.event_type, "New Booking"); + assert.notEqual(fixture.event.manual_review, null); + assert.equal(fixture.event.extracted_fields.booking_object_type, "Group Block"); + assert.equal(fixture.event.extracted_fields.room_items[0].room_type_raw, "SUITE"); + assert.equal(fixture.event.extracted_fields.room_items[0].pms_room_type_code, null); + assert.notEqual(fixture.event.requires_downstream_hard_validation, true); + assert.ok(!Object.prototype.hasOwnProperty.call(fixture.event.manual_review, "required_user_inputs")); + assert.deepEqual(deriveAdapterTriplet(fixture.event), fixture.expected_adapter_triplet); + assert.equal(fixture.expected_adapter_triplet.task_type, "New Booking"); + assert.equal(fixture.expected_adapter_triplet.task_subtype, "new_group_block"); +}); + +runCheck("business manual_review.missing_fields uses resolvable RFC 6901 pointers", () => { + const event = manualReview.known_subtype_manual_review.event; + assert.ok(event.manual_review.missing_fields.length > 0); + for (const pointer of event.manual_review.missing_fields) { + assert.ok(isRfc6901Pointer(pointer)); + assert.equal(resolvePointer(event, pointer), null); + } +}); + +runCheck("unknown business type alone routes to Fallback", () => { + const fixture = manualReview.type_unknown_fallback; + validateEvent(fixture.event); + assert.equal(fixture.event.event_type, "Need Manual Review"); + assert.deepEqual(deriveAdapterTriplet(fixture.event), fixture.expected_adapter_triplet); +}); + +runCheck("same-card resolution keeps Agent payload and initial result immutable, then enters Preflight", () => { + const resolution = manualReview.same_card_resolution; + const source = manualReview[resolution.source_case].event; + const actual = resolveSameCard( + resolution.task_identity, + resolution.initial_state, + resolution.resolved_state, + source + ); + assert.deepEqual(actual, resolution.expected); +}); + +const sourceIdentity = loadFixture("source_identity_errors.json"); +const observedSourceErrors = []; +const rejectedMessageClasses = new Set(); +for (const fixture of sourceIdentity.cases) { + runCheck(`source identity ${fixture.case_id}`, () => { + const actual = processSourceIdentityStep(fixture.input); + assert.deepEqual(actual.outcome, fixture.expected_error); + if (actual.outcome !== null) { + assert.deepEqual(actual.processing, fixture.expected_processing); + assert.equal(actual.processing.material_reads, 0); + assert.equal(actual.processing.skill_calls, 0); + assertExactKeys(actual.outcome, ["result_type", "error_code", "retryable", "missing_fields"], "typed infrastructure error"); + assert.equal(actual.outcome.result_type, "infrastructure_input_error"); + assert.equal(actual.outcome.error_code, "missing_source_message_id"); + assert.deepEqual(actual.outcome.missing_fields, ["source_message.source_message_id"]); + assert.ok(!Object.prototype.hasOwnProperty.call(actual.outcome, "route_code")); + assert.ok(!Object.prototype.hasOwnProperty.call(actual.outcome, "message_events")); + observedSourceErrors.push(actual.outcome); + rejectedMessageClasses.add(fixture.message_class); + } + }); +} + +runCheck("Cancel, Thanks, and See Attached inputs share the same Step-0 typed error", () => { + assert.deepEqual([...rejectedMessageClasses].sort(), ["Cancel", "See Attached", "Thanks"]); + assert.ok(observedSourceErrors.length >= 3); + for (const outcome of observedSourceErrors.slice(1)) assert.deepEqual(outcome, observedSourceErrors[0]); +}); + +if (failures.length > 0) { + process.stderr.write(`\n${failures.length} contract check(s) failed; ${passed} passed.\n`); + process.exitCode = 1; +} else { + process.stdout.write(`\nP0 contract fixtures valid: ${passed} checks passed; ${negativeFixturesRejected} negative fixtures rejected as expected.\n`); +} diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/04_Known_Issues_非契约/prompt_skill_p1_p2_reassessment_2026-07-11.md b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/04_Known_Issues_非契约/prompt_skill_p1_p2_reassessment_2026-07-11.md new file mode 100644 index 0000000..7ce38e9 --- /dev/null +++ b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/04_Known_Issues_非契约/prompt_skill_p1_p2_reassessment_2026-07-11.md @@ -0,0 +1,441 @@ +# Prompt / Skill P1、P2 重新评估(P0 后现状) + +评估日期:2026-07-11 +评估对象:当前 Main Prompt、`booking-desk-event` Skill、全部生产 references、README、P0 fixtures/validator、开发交接 MD/Excel 与 ADR-001。 +评估性质:只读复核;本报告没有修改生产规则。 + +## 1. 结论先说 + +旧报告中的“12 组 P1 + 8 组 P2”不能原样继续使用,因为 P0 已解决一部分问题,也让一部分旧问题合并或改了性质。 + +按当前文件去重和重新定级后,建议形成: + +- **P1:12 组**。数字碰巧仍是 12,但成员已经变化:有旧项解决、合并或降级,也有 P0 后新暴露的 package、adapter 和同卡解阻缺口。这些问题会让同一输入走不同事件/卡型、产生不同数量的事件、让严格消费者接收到不兼容结构,或让开发没有唯一权威映射。 +- **P2:8 组**。数字也碰巧仍是 8,但同样不是原来的 8 项;这些问题主要集中在较窄的领域语义、示例、组合场景和测试覆盖,仍可能判错,但通常不是入口契约立即失效。 +- **已解决的旧问题:2 个明确结论**:旧 P1-6 的 Allotment/Parent 边界已解决;旧 P2-3 的 filename-only Voucher 分流已解决。不过 Voucher 又暴露出一个相邻的新 P1 冲突,不能据此认为 Voucher 全部完成。 +- **P3/文档卫生:1 项**:测试仍引用个人桌面绝对路径,不是生产业务冲突,但会破坏测试可移植性。 + +最需要先修的不是所有 P1 一起铺开,而是下面四项: + +1. 42 条 adapter 路由没有生产级单一权威源,当前只完整存在于“派生交接件”。 +2. Fix Charge 的动作与 Schema 没闭合。 +3. Preflight/系统状态的两层门控与同卡解阻模型没有闭合。 +4. Rooming List 专项 reference 仍能生产现行目录禁止的 `Note`。 + +## 2. 旧 20 项如何变化 + +| 旧编号 | 旧问题 | 当前结论 | 当前归属 | +| --- | --- | --- | --- | +| 旧 P1-1 | 历史查询 gate 太窄 | 仍存在 | 新 P1-6 | +| 旧 P1-2 | 日期规则适用范围不一致 | 仍存在 | 新 P1-5 | +| 旧 P1-3 | Fix Charge 触发/Schema 不闭合 | 仍完整存在 | 新 P1-1 | +| 旧 P1-4 | Update / Trace 重叠 | 大部分缓解,但剩余仍会直接改变 event type | 新 P1-12(靠后处理) | +| 旧 P1-5 | Update / Manual RateCode 重叠 | 仍存在 | 新 P1-4 | +| 旧 P1-6 | Allotment 部分释放/Parent Cancel | 已由 ADR-001 解决 | 移除 | +| 旧 P1-7 | `extraction_warnings` 阻塞语义 | 部分解决,剩余并入公共 Schema | 新 P1-7 | +| 旧 P1-8 | NMR 事件模型 | 九字段已解决,目标候选模型并入公共 Schema | 新 P1-7 | +| 旧 P1-9 | source/event requiredness 两版本 | event 已解决,source 示例仍残留 | 新 P2-2 | +| 旧 P1-10 | 根数组/locator 无 Schema | 仍存在 | 新 P1-7 | +| 旧 P1-11 | Invoice/Trace/payment 措辞 | Trace 主体已修,Invoice/payment 仍有冲突 | 新 P1-2 | +| 旧 P1-12 | Preflight 所有权不清 | 仍存在,并发现非字段阻塞无法稳定解阻 | 新 P1-3 | +| 旧 P2-1 | TA Recorder 可选/必选 | 仍存在,属于派生条件歧义 | 新 P2-1 | +| 旧 P2-2 | Rooming List Note/Trace | 仍存在,且违反 P0 active/legacy 目录 | 新 P1-8 | +| 旧 P2-3 | Voucher filename-only | 原冲突已解决;相邻的“明确卡型但文件对象缺失”仍冲突 | 新 P1-9 | +| 旧 P2-4 | HNM/Honeymoon 双重语义 | 仍存在 | 新 P2-3 | +| 旧 P2-5 | Rate Code 金额未标渠道 | 仍存在 | 新 P2-4 | +| 旧 P2-6 | Trace 日期无依据补全年月 | 仍存在且测试固化错误期望 | 新 P2-5 | +| 旧 P2-7 | Amend Group Code + 普通修改 | 通用拆意图规则有所帮助,但组合/目标绑定未定义 | 新 P2-6 | +| 旧 P2-8 | 测试治理 | P0 有了可执行测试,但 P1/P2 和部分 P0 语义仍无有效断言 | 新 P2-8;绝对路径另列 P3 | + +本轮还新增发现:42 条 adapter 三元组没有生产级机器可读权威源(新 P1-10);Main→Skill 只冻结 candidate item、没有冻结完整 package(新 P1-11);FIT/Group 的“通常按房数”没有冲突优先级(新 P2-7)。 + +## 3. 当前 P1:12 组 + +### P1-1:Fix Charge 的触发、动作和输出 Schema 没闭合 + +**当前证据** + +- `references/52-fix-charge.md:9-15` 只允许复合单价/附加费组件触发。 +- `references/11-update-booking.md:75-77` 和 `52-fix-charge.md:59` 又要求支持 add、update、cancel。 +- `52-fix-charge.md:41` 仍含 `create_or_update_fix_charge / TBD`。 +- `52-fix-charge.md:46` 的 `operation_type` 只能表达 `create_or_update_fix_charge`,不能表达 cancel。 + +**会造成什么影响** + +同一条明确的 Fix Charge 维护请求可能完全不触发,也可能触发后无法生成下游可消费的动作值。`TBD` 进入生产 payload 时,严格 Schema 不能接受,宽松实现则会各自解释。 + +**例子** + +邮件:`Please remove the existing fixed charge from reservation 344055.` + +- 没有 `2000+500` 之类复合价格,所以按触发规则它不是 Fix Charge。 +- 但 Update reference 明确说 cancel Fix Charge 是支持动作。 +- 即使识别出来,当前 `create_or_update_fix_charge` 也表达不了 `cancel`。 + +**建议** + +冻结 `operation=add|update|cancel`;允许明确的维护动词触发,不再只依赖复合金额;删除 `TBD` 和混合工具名。工具调用名称应由下游能力注册表维护,不要作为未决枚举塞进业务事件。 + +### P1-2:Invoice 与 payment 的前置措辞仍会误导 Main/Skill 路由 + +**当前证据** + +- `prompts/main_agent_prompt.md:5` 仍笼统写“不要判断 … Invoice 或 Receipt”。 +- `SKILL.md:16` 仍笼统写“不生成 Invoice 或 Receipt”。 +- `00-output-contract.md:185-202` 却把 `Invoice Generation`、`Invoice Received`、`Payment Notice` 列为 active event。 +- `18-invoice.md:22-28` 真正禁止的是生成实际文件、确认付款和执行会计动作。 +- Main `:60` 仍把没有限定词的 `payment information` 列为 Trace 信号;详细 Trace 规则才补充“已确定、非审批、非凭证、非 Notice/Invoice”。 + +**会造成什么影响** + +模型可能在第一层就把正式支持的 Invoice 邮件排除,或者把付款政策询问错放成 Trace 粗候选。 + +**例子** + +- `Please issue a proforma invoice.` 可能因“不要判断 Invoice”走 S10。 +- `Can the balance be paid on arrival?` 可能因早期广义 `payment information` 被当成 Trace;它其实是当前不支持的付款政策审批询问。 + +**建议** + +统一改成:“可以识别 Invoice/Payment 业务候选,但不得生成实际 Invoice/Receipt 文件、确认到账或执行会计动作。”Main 所有 `payment information` 文案统一限定为“已确定、非审批、非凭证、非 Payment Notice/Invoice”。 + +### P1-3:Preflight 与早期系统状态门控重复,非字段型复核又没有稳定解阻方式 + +**当前证据** + +- README `:22`、Main `:304` 和开发交接 `:258` 都把正式 Case/Preflight/去重/锁校验交给信息系统。 +- `SKILL.md:156`、`10-new-booking.md:37,67`、`11-update-booking.md:40,84` 和 `90-manual-review.md:31` 又规定 pending task、workflow、lock、duplicate 可直接把候选切成业务 manual review。 +- `90-manual-review.md:100-136` 的同卡解阻只具体定义了 `missing_fields[]` 对应 `field_overrides[]` 的字段确认路径。 +- pending/lock 这类阻塞可能 `missing_fields=[]`,没有字段可以让用户填写;当前规则没有说明它应等待重查、由谁解除、是否需要用户覆盖值。 +- type-unknown Fallback 也可能 `missing_fields=[]`,需要用户在 New/Update 等候选路由中作选择;现有 `field_overrides[]` 既不能表达 route selection,也不能改写不可变的原 Agent event。 + +**会造成什么影响** + +同一封 Update 邮件仅因系统查询是否可用,就可能进入 normal 卡或 manual-review 卡;随后 manual-review 卡又可能没有可填写字段,用户看得到卡却无法完成它。 + +**例子** + +订单修改信息全部明确,但系统里已有 pending Update task: + +- 查询成功:Skill 可能输出 Update manual-review。 +- 查询暂时失败:Skill 可能输出普通 Update,稍后由正式 Preflight 拦截。 +- 如果已经进入 manual-review,`missing_fields=[]`,前端没有 field override 可以提交。 +- 如果是“无法确定 New 还是 Update”的 Fallback,用户即使已经选了 Update,当前 resolution 也没有合法字段保存“有效路由选择”。 + +**建议** + +优先选择单一所有者:duplicate/pending/lock 只由下游正式 Preflight 裁决,Skill 仅保留 `context_used`。如果业务坚持保留早期门控,就明确命名 `advisory_preflight`,并定义查询未执行时的稳定结果及“非字段阻塞”的解阻模型,例如系统重查通过后自动 resolved。Fallback 还需要独立的 `route_selection` resolution;它应保存在信息系统的 resolution 中,原 Agent event 继续不可变,不能强迫用户填写不存在的字段或回写 Agent payload。 + +### P1-4:只修改 Rate Code 时仍同时匹配 Update 和 Manual RateCode + +**当前证据** + +- `11-update-booking.md:11-18` 把 manual Rate Code / settlement price 纳入 Update。 +- `53-manual-rate-code.md:3-16` 又说“不能作为 New/Update 内嵌时”或“只有 manual rate maintenance”时输出 Manual RateCode。 +- `02-event-routing-map.md:37,49` 同时保留两条 active 路由,但没有唯一优先级。 + +**会造成什么影响** + +同一邮件可生成 Update 卡或 Manual RateCode 卡。两张卡的 task type、subtype、字段和下游流程不同,统计也会分裂。 + +**例子** + +`For reservation 344055, change the Rate Code to CORP1.` + +这既是“修改已有订单字段”,也是“只有一项 manual rate maintenance”。 + +**建议** + +建议冻结:明确 reservation/group 的 Rate Code 或价格字段修改一律走 Update;`Manual RateCode` 只用于不直接修改某个既有订单的独立 rate-maintenance 工作流,并补充该工作流的强触发信号和最小字段。 + +### P1-5:通用日期规则与 QBD/LianTai 专项规则的适用范围冲突 + +**当前证据** + +- `54-stay-date-parsing.md:5` 把用途限定为 QBD/LianTai 表格日期。 +- `10-new-booking.md:32-38`、`11-update-booking.md:34-41` 和 `00-output-contract.md:249-268` 又要求一般 New/Update 涉及日期时遵循 54。 +- 当前高风险日期用例集中在供应商表格,没有普通正文/PDF 的正式反例。 + +**会造成什么影响** + +普通 FIT 邮件、PDF 或其他供应商材料没有正式日期规则;实现可能错误套用 sheet/酒店列逻辑,也可能把完整日期无故送人工复核。 + +**例子** + +`Arrival 12 Aug 2026, departure 14 Aug 2026.` + +它没有酒店列、行程列或 sheet month/year,但 New Booking 又被要求遵循以这些证据为核心的 54。 + +**建议** + +建立“通用 stay date”规则,定义完整日期、短日期、跨月、时区/locale 和冲突处理;54 只保留 QBD/LianTai 特例。也可以把 54 改成“通用规则 + 供应商子章节”,但测试必须同时覆盖正文、PDF 和 QBD/LianTai。 + +### P1-6:历史查询 gate 仍挡住必要旧值和关系证据 + +**当前证据** + +- Main `:66-70` 只在“已有 active 粗候选且目标无法唯一绑定”时允许主动查历史。 +- 同一 prompt `:71-78` 和 `01-current-history-boundary.md:23-29` 又允许历史补 amendment 旧值、parent allocation 和供应商上下文。 + +**会造成什么影响** + +目标已经唯一,但 before/after 旧值或 parent 关系只在历史里时,Main 被 gate 禁止查询,只能输出不完整候选或不必要的人工复核。 + +**例子** + +`Confirmation 344055, change stay to 2 nights.` + +confirmation 已唯一,但旧 arrival/departure 只在历史邮件。当前 gate 因“目标已唯一”不允许查历史。 + +**建议** + +把查询条件改成:“目标绑定不足,或该 active 候选 reference 明确要求的旧值/关系证据不足。”同时区分输入中已有的 quoted-thread evidence 和主动外部 history lookup。 + +### P1-7:公共候选、warning、目标歧义和 `event_role` Schema 仍不闭合 + +**当前证据** + +- `00-output-contract.md:124-143` 固定要求 `case_candidates[]`、`extraction_warnings[]`,却没有 item schema。 +- `04-main-skill-input-contract.md:43-84` 只定义了内部 `target_hints`,不能替代最终业务根的 public candidate。 +- `00-output-contract.md:217-240` 的 `event_role` 只有示例值,没有 enum。 +- `90-manual-review.md:20-33` 允许目标不唯一时保留已知卡型,但没有定义“一组歧义一个 event”还是“每个候选一个 event”。 +- `03-current-content-completeness.md:106`、`00-output-contract.md:179-180` 仍用 warning / review / S99 的选择式措辞,没有明确“warning 只允许非阻塞质量问题”。 +- `04-main-skill-input-contract.md:84` 要求最终 event 使用 `context_used.main_candidate_ids[]` 记录 provenance,但现有 golden event 常用空 `context_used`,输出契约也没有把该子字段设为条件必填。 +- relationship 字段只给了 prose 约束,没有统一 item/引用 Schema;通用根校验不会拒绝 dangling index 或错误 event type。 + +**会造成什么影响** + +不同实现会自行发明数组 item、关联键和去重规则;两个候选目标可能生成一张或两张人工卡;warning 也可能被误当成终局路径。 + +**例子** + +邮件只写 `John Smith, arrival 15 Aug`,历史找到 `R100`、`R200` 两个候选: + +- 实现 A 输出一张 Update manual-review,四个 case key 全 null。 +- 实现 B 输出两张 Update manual-review,看起来像两项真实修改。 +- 实现 C 私自在 `case_keys` 增加 `guest_name`。 + +另一个例子:Update 关键信息完整,但附件第二页无关页脚 OCR 失败;一个实现 normal + warning,另一个实现整卡人工复核。 + +**建议** + +补一份正式公共输出 Schema: + +- `case_candidates[]`:candidate ID、`target_locator`、四键 canonical candidates、evidence refs、related event index。 +- `extraction_warnings[]`:稳定 code、证据位置、related event index,并明确其不能单独承接阻塞问题;阻塞入口走 S99,阻塞已知卡型必需字段走业务复核。 +- 固定“一项 ambiguity cluster 一个 review event”,候选只表示候选,不能变成多项真实事件。 +- 冻结 `event_role` enum 和 relationship index 类型/去重规则。 +- 明确 `context_used.main_candidate_ids[]` 在最终业务 event 中的 requiredness,并校验每个 ID 来自本次 Main candidate package。 + +### P1-8:Rooming List 专项 reference 仍能生产目录禁止的 Note + +**当前证据** + +- `14-rooming-list.md:42-50` 仍写 Rooming List 可以派生 Note。 +- `17-ta-recorder-note.md:7-10,41-50` 仍把 Note 列为事件并提供 payer code、charge code、breakfast、note text 等 producer。 +- `00-output-contract.md:204-209`、`02-event-routing-map.md:51-56` 和 ADR-001 明确 `Note` 只能读历史,当前 producer 必须为 0。 +- 开发交接要求每个已确认 target 派生 TA Recorder,并给 Note 0 条合法三元组。 + +**会造成什么影响** + +同一名单可能多出 adapter 无法路由的 Note;早餐、布置等内容还可能同时生成 Note 和 Trace。 + +**例子** + +workbook 的备注写 `Breakfast for 20; honeymoon setup`。按旧专项 reference 可能生成 Note;按 Trace 规则应生成 meal/room_setup Trace;按现行目录 Note 又必须被拒绝。相同内容因此可能重复,或在 adapter 层丢失。 + +**建议** + +删除 `14/17` 中的当前 Note producer,并把 `17-ta-recorder-note.md` 改成 TA Recorder-only 或将 Note 章节明确移入 legacy reader/migration。breakfast/meal/setup 等开放文本走 Trace;当前不支持的 PMS 维护或审批内容走 `unhandled_current_intents`。TA Recorder 的“必选/可选”问题单列为 P2-1,不与非法 event producer 混为一项。 + +### P1-9:Voucher 的 filename-only 已解决,但“明确卡型、文件对象缺失”仍有硬冲突 + +**当前证据** + +- `13-voucher-payment.md:43-57,100-111` 已正确区分被动 filename-only、已知卡型但附件缺失/不可读、type-unknown 和方向未知。 +- 但 `SKILL.md:97` 仍绝对写“Voucher 必须有当前图片、PDF 或文件证据”。 +- 专项 reference `:55` 则允许 current 正文已经明确 `Voucher Received` 或 `Payment Evidence`,只是必要附件缺失/无法取得时,保留已知业务卡并进入 manual review。 + +**会造成什么影响** + +正文明确要求处理本次 Voucher、但上传过程中附件对象丢失时,一个实现会输出 Voucher manual-review,另一个会因硬边界拒绝产生事件并走 S10/S99。 + +**例子** + +- 只有文件名 `voucher_HD123.pdf`,无文件对象、无明确当前处理语义:S10。这一条已经清楚。 +- 正文:`Please process the attached CREDIT VOUCHER for HD123`,但附件上传丢失:专项 reference 要保留 Voucher 卡;Skill 硬边界说没有文件证据不能生成。 + +**建议** + +把“normal Voucher 必须有真实文件证据”和“明确卡型但证据丢失时允许 type-known review”分开写。保持 filename-only 的 S10 结论,不要重新打开旧冲突。 + +### P1-10:42 条 adapter 路由没有生产级单一权威源 + +**当前证据** + +- 开发交接 MD `:8` 明确自称生产契约的派生件,不能覆盖上游。 +- 完整 42 条 `result_type + task_type + task_subtype` 映射只出现在该交接 MD/Excel 的 `:97-140`。 +- 生产 `00-output-contract.md` 只规定“保留原业务 task type/subtype”的原则,没有给出 19 个 subtype 到具体三元组字符串的完整表。 +- P0 validator 的 `deriveAdapterTriplet()`(`validate_p0_contracts.mjs:353-409`)只实现 New、Update、Cancel、Cancel Allotment、Trace;其他 active event 会直接 `throw`,没有自动验证全部 42 条。 +- Parent split 的 Parent event 同时具有 `cancel_object_type=group_block` 和 `relationship_type=linked_parent_release_after_child_split`;交接表中的普通 group cancel 与 linked parent cancel 两行都可命中,但生产 contract 没有冻结 adapter 判别优先级。 + +**会造成什么影响** + +开发必须依赖一份自称“非规则源”的文档才能知道具体 task subtype;未来生产 event 字段改动时,没有单一机器可读源可以生成交接件并阻止漂移。 + +**例子** + +`Voucher Received` 应映射到 `lian_tai_credit_voucher`,`TA RECORDER` 应映射到 `maintain_ta_recorder`。这些精确 subtype 字符串无法从当前生产 reference 唯一推导,只在交接表里完整出现。Parent event 则可能被 Adapter A 先按 object type 路由成 `cancel_group_block`,被 Adapter B 先按 relationship 路由成 `linked_parent_release_after_child_split`。 + +**建议** + +新增生产级 `05-adapter-routing-contract`(推荐机器可读 JSON/YAML,或带可执行表的 reference),把 42 条映射和判别优先级作为唯一源;例如 Parent relationship 判别必须先于普通 cancel object 判别。开发 MD、Excel 和测试都由它生成,并完整校验 42 条唯一性、19 个业务 subtype 的 normal/manual 成对关系和禁用 legacy 事件。 + +### P1-11:Main→Skill 只冻结 candidate item,没有冻结完整 package + +**当前证据** + +- Main `:93-127` 给了素材包初始化 shape,但没有冻结各 material item、history/system context 和 unknowns 的完整类型。 +- `04-main-skill-input-contract.md:73-84` 要求 `evidence_refs[].index` 必须真实指向对应 current material,却没有一份完整 package schema 可用于验证引用。 +- `candidate_gate.json` 和 validator 只传入/校验 `candidate_events` 数组;没有同时传对应 attachments/tables/OCR,因此 `index=999` 也无法判断是否越界。 + +**会造成什么影响** + +candidate item 自身合法,不代表它引用的材料存在。Skill 可能在运行时读错附件、错行或直接报 contract error;不同编排实现也会自行决定 material item shape。 + +**例子** + +```json +{ + "candidate_id": "C1", + "evidence_refs": [{"source": "current_attachments", "index": 999}] +} +``` + +如果实际 `current_attachments=[]`,这应该在调用 Skill 前失败;现有 candidate-only validator 看不到附件数组,不能拒绝它。 + +**建议** + +把 `04-main-skill-input-contract.md` 扩成完整 package schema:冻结 source/body/material arrays、attachment/table/OCR item、history/system context、candidate events、unknowns 和错误返回;validator 必须接收整个 package,校验引用存在、索引范围、current-only 来源和 candidate provenance。 + +### P1-12:Update / Trace 的“修改既有备注”仍可产生不同 event type + +P0 已明确主修改与 linked Trace 分开,不能把补充信息吞进 Update;但 `11-update-booking.md:5-19` 仍把“备注或其他订单细节”纳入 Update,Trace 又把 high floor、quiet room 等偏好定义为补充信息。 + +**会造成什么影响** + +同一输入可能生成 Update、Trace 或两张重复卡。虽然场景比旧版窄,但它仍是直接的 event-type 路由漂移,因此保留为靠后的 P1,而不是只当措辞问题。 + +**例子** + +`Please change the existing note from high floor to quiet room.` + +**建议** + +冻结语义边界:修改/删除一个可定位的已存字段或备注,且 before/after 清楚时走 Update;新增偏好、要求、安排或事实走 Trace,不受 `update/add note` 动词影响;同一内容不得同时作为 Update 核心字段和 Trace。 + +## 4. 当前 P2:8 组 + +### P2-1:TA Recorder 仍在“可能/通常派生”和“每个目标必须派生”之间摇摆 + +`14-rooming-list.md:42-50` 写 Rooming List “可能派生”,`17-ta-recorder-note.md:5` 写“通常由”,但同文件 `:21-29` 又要求每个已确认 group 派生一个 TA Recorder;开发交接和高风险清单也按必派生理解。 + +例子:一个 workbook 有两个已确认团号。可选解释只出 2 个 Rooming List;必选解释还要出 2 个 TA Recorder,共 4 个事件。 + +建议:若当前产品要求已确认 target 必派生,就统一改成“每个已确认 target 必须且只能派生一个 TA Recorder”;目标未确认时先保留 Rooming List 卡的人工确认,不提前生成无目标 TA Recorder。补事件数和关系的可执行断言。 + +### P2-2:Skill 的 `source_message` 示例仍比正式固定 shape 少字段 + +event 的 `attachments`、`file_references`、`manual_review` requiredness 已解决;只剩 `SKILL.md:118-127` 示例仅含 `source_message_id`,而 `00-output-contract.md:124-143` 要求六个 metadata key 固定存在。 + +例子:严格 validator 接受完整 source shape,但照抄 Skill 示例会缺 `subject/from/cc/received_at/source_channel`。 + +建议:把 Skill 和交接示例同步为完整 shape;不可得值用 `null` 或 `[]`。由于 00 已是明确权威、剩余主要是示例误导,降为 P2。 + +### P2-3:`HNM/Honeymoon` 没有区分结构化房型代码和自然语言布置要求 + +`50-room-type-mapping.md:9-20` 把 `HNM, Honeymoon` 默认走 DBL;`16-trace-notes.md:67-74,139-143` 又把 honeymoon 作为 room setup Trace。 + +例子:`Room Type: TWN; Remark: honeymoon decoration.` 正确应是 RM3 + room-setup Trace;宽泛规则可能把房型改成 DBL 或制造无谓冲突。 + +建议:只有已知供应商的结构化房型字段中正式 `HNM` code 才能参与房型映射;正文/备注的 honeymoon 只能作为 Trace,不能决定 PMS 房型。 + +### P2-4:Rate Code 金额示例仍未绑定 supplier/channel + +`51-rate-code.md:9-15` 区分 QBD/LianTai,但 `:17-22` 又写无渠道的 `850/900/1000/1200 -> code`;`:24` 才补“渠道优先”。 + +例子:QBD 900 应是 `GRPA1-900`,LianTai 900 是 `GRP1-900`;无渠道的 `900 -> GRP1-900` 会误导模型。 + +建议:每个金额示例绑定 supplier/channel;渠道未知时只保留 price evidence,`rate_code=null` 并在原业务卡人工确认。 + +### P2-5:Trace 示例仍无依据地从 group code 补全年月 + +`16-trace-notes.md:55-60` 要求日期只能由可靠上下文唯一支持;`:82-100` 却仅用 `HD260510A + 12/5` 输出 `2026-05-12`。项目没有 group-code 日期 codec,`high_risk_cases.md:25` 还把该结果写成期望。 + +例子:只有 `HD260510A - 12/5 FULL DAY Meeting` 时,安全值应是 `service_date=null` 并保留 raw `12/5`;只有邮件住期或 sheet context 明确 2026-05 时才可补全。 + +建议:给示例补可靠年月证据,否则将期望改为 null。若业务确有 group-code 编码,另建供应商专属 codec,明确格式、适用范围和冲突处理。 + +### P2-6:Amend Group Code 与日期/房量组合的事件数量和目标绑定未定义 + +通用规则已要求独立 current 信号先拆分,这比旧版更好;但 `15-amend-group-code.md:24-50` 只定义 old/new 两字段和 Amend + Trace,没有定义 Amend + Update 的关系、Update 绑定旧团号还是新团号。 + +例子:`Change OLD123 to NEW123, move arrival to 12 May, add 2 TWN.` 可能只出 Amend,也可能出 Amend + Update,但 Update 的 key 不一致。 + +建议:明确输出两个相关主事件;优先用稳定 confirmation/reservation number 共同绑定。只有 group code 时,定义 before/after target 和 relationship;无法确认时保留已知 Update 卡型并进入人工确认,不能把日期/房量静默塞进 Amend context。 + +### P2-7:FIT / Group Block 的“通常按房数”缺少冲突优先级 + +`10-new-booking.md:13-18` 写“小于 5 间通常按 FIT,5 间及以上通常按 Group Block”,但没有说明显式对象词、渠道规则、团队名称和房数冲突时谁优先;`booking_object_type` 又是 adapter 三元组的 subtype 判别字段。 + +例子:正文明确写 `group booking` 但只有 4 间,或一个家庭 FIT 请求 5 间。两个实现可能分别只看房数或只看显式业务词,生成不同 task subtype。 + +建议:冻结判别优先级,例如“受支持供应商结构化对象类型/显式 group or FIT 指令 > 已知渠道规则 > 房数阈值”;冲突且 subtype 无法确定时才走 type-unknown Fallback。补 4-room explicit group、5-room explicit FIT、无显式词的 4/5-room 四组用例。 + +### P2-8:P0 有可执行测试,但当前绿色结果不能覆盖 P1/P2,也没有发现部分 P0 测试文字回退 + +**已经改善** + +- 7 份 JSON fixture 和 `validate_p0_contracts.mjs` 当前运行结果为 32 checks passed、9 个负例被拒绝。 + +**仍有问题** + +- `deriveAdapterTriplet()` 只实现 5 类 active event 分支,没有覆盖 Voucher、Payment Evidence、Rooming List、Amend Group Code、TA Recorder、Invoice、Payment Notice、Manual RateCode 的 42 路由。 +- `validateBusinessRoot()` 只检查 `case_candidates`、`extraction_warnings` 是数组,不检查 item,因为生产本身也没有 item schema。 +- Parent 测试对 `child_group_codes[]` 和 `related_source_event_indices[]` 分别排序后比较,不能证明二者按原顺序一一对应;交换两个 index 仍可能通过。 +- 没有验证最终 event 的 `context_used.main_candidate_ids[]` provenance。 +- `high_risk_cases.md:19,45,49,50` 仍把 type-known 日期、Trace 目标、Rooming List 目标和 Voucher 团号歧义写成 `Need Manual Review`,与 P0 当前“保留原业务 event + 非空 manual_review”冲突。 +- P1/P2 领域场景仍主要是 Markdown,自然语言清单不会让 CI 失败。 + +**建议** + +先修正过时的 Markdown 期望,再将本报告每一项做成正反 golden fixture。测试至少要覆盖:全部 42 路由、public root item schema、candidate provenance、关系数组按位置配对、type-known review 不回退 Fallback,以及本报告列出的领域边界。 + +## 5. P3:测试绝对路径 + +`tests/booking-desk-event/high_risk_cases.md:38` 仍引用 `/Users/chillishark/Desktop/.../53.eml`。这不会直接改变生产路由,所以不再占一个 P2 业务冲突名额;但换机器无法复现。 + +建议把脱敏样本放入仓库 `tests/booking-desk-event/fixtures/` 并用相对路径引用;若原始邮件不能入库,就制作最小脱敏 JSON/EML fixture。 + +## 6. 推荐修改顺序 + +建议分四批,不要一次把所有 reference 平铺修改: + +1. **先封闭机器契约**:完整 42 路由单一权威源、Main→Skill 完整 package、公共 root/relationship/provenance schema、Fix Charge 和 source shape。 +2. **再封闭路由边界**:Rooming List/TA/Note、Voucher 文件证据、Invoice/payment、Update/Manual RateCode。 +3. **再封闭流程状态**:Preflight/advisory gate、非字段阻塞解阻、历史查询 gate。 +4. **最后修领域规则与测试**:通用日期、HNM、Rate Code、Trace 日期、Amend 组合,并把每项转为可执行 fixture。 + +在这四批中,ADR-001 已确认的 P0 结论必须保持不变: + +- 不恢复部分配额 Update 或 Allotment Maintenance producer。 +- 不恢复当前 Note producer。 +- type-known 字段/目标歧义保留原业务 event,不能重新 blanket Fallback。 +- Parent split 继续是 Child Group New + Parent Group Cancel candidate。 +- 房型不唯一继续使用同业务卡 manual-review mode,不改回普通候选或第二张卡。 + +## 7. 验证说明 + +- 本轮重新运行 `node tests/booking-desk-event/validate_p0_contracts.mjs`:32 项通过,9 个非法负例按预期拒绝。 +- `.project-docs` 完整性检查通过。 +- 上述通过结果只能证明现有 P0 fixture 自洽,不能证明本报告列出的 P1/P2 已关闭;原因见 P2-8。 diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/CHECKSUMS.sha256 b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/CHECKSUMS.sha256 new file mode 100644 index 0000000..0fdb0fc --- /dev/null +++ b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/CHECKSUMS.sha256 @@ -0,0 +1,19 @@ +5311be08abeaca493933caf646f47f93ac90fd88dc5652e2bd29751d9147b993 ./01_Adapter_Frontend/任务卡前端字段变更与路由说明_3.0_to_current.md +e8c02672807b61a1f62de604a004e5b0f5758f55c0c4dc3542f0b1a31b532943 ./01_Adapter_Frontend/任务卡前端字段变更说明_3.0_to_当前版_2026-07-10.xlsx +64063a6d24c2e70bb0d9d557923fdbecb9e133c1ea5971f3858df03469e48693 ./02_AI_Runtime/booking-desk-event.skill +7ce8a5beab05638fa7f3088b179ef86ee3dca6bfa11447bbed14d8b0017fddf9 ./02_AI_Runtime/main_agent_prompt.md +ff1155706ee818bc973c8dac3405aecbcda711fe41b22850b66e4a1c75f85377 ./02_AI_Runtime/核心契约_展开阅读/00-output-contract.md +b9e2a6d2964de8e4a2691508ca36297b8efaab80d713c5cead0c382b748662a7 ./02_AI_Runtime/核心契约_展开阅读/02-event-routing-map.md +9fab9a398a3320e41c701918174442d4269ac5407a56e091e29da33a00084165 ./02_AI_Runtime/核心契约_展开阅读/03-current-content-completeness.md +c8f8503be23ce8e28fd35cf633cc80d2850fa0308fe9ed4a9232f6ee8ab8cbfb ./02_AI_Runtime/核心契约_展开阅读/04-main-skill-input-contract.md +3c6257c81e83a5feb7fb5125242dcd356b00d2519f2071ab07fb04337d364532 ./02_AI_Runtime/核心契约_展开阅读/90-manual-review.md +e7382dcb77b0161250e48d3ec3f287a446b0cfad2473a06cf7cfbd10b8def232 ./03_P0_Acceptance/fixtures/allotment_scope.json +c849f0a677ccc97a040502ed78d7d00152793b0ca45c127ea1d4b911a900b4a8 ./03_P0_Acceptance/fixtures/candidate_gate.json +0eea30534fd6aa2a19eb275630f533ac99c06b2401d4d0d66744042cbcc603dd ./03_P0_Acceptance/fixtures/main_outcomes.json +781b3d87657b91bb732836937487b1f1c8cec53db2540c56e149ca757e116a15 ./03_P0_Acceptance/fixtures/manual_review_resolution.json +e3f62c503f7702225601fd5fb51d69e79369bf8cb3c308c59fc99b8481cfce8e ./03_P0_Acceptance/fixtures/parent_split_two_children.json +96aefe4ecea57de43a44f7b6dd358298c324bb1bdc414668d27cb4d1fedbc54a ./03_P0_Acceptance/fixtures/row_multiple_derived.json +dcf71127f54f895c076046644a73714db061a1886b900ec8a3ebfe992029520e ./03_P0_Acceptance/fixtures/source_identity_errors.json +1a1ba1a5185a1eecb76ccc92ad13647a7bbe93040354d52afac4f1a4ee9971e3 ./03_P0_Acceptance/validate_p0_contracts.mjs +0728302a1d0fdf1728acd1be2f7f0e44b21f448bec59123100ff0db2c7a428a0 ./04_Known_Issues_非契约/prompt_skill_p1_p2_reassessment_2026-07-11.md +96c1882b9494c399f944b62ddb07763c0fc51ae16ce323c18c02c8e1d23f37ff ./README_交付说明.md diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/README_交付说明.md b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/README_交付说明.md new file mode 100644 index 0000000..da1155e --- /dev/null +++ b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/README_交付说明.md @@ -0,0 +1,100 @@ +# 开发交付说明:P0 冻结基线(2026-07-11) + +## 先看结论 + +这是当前可以交给信息系统 adapter/frontend 与 AI runtime 开发的 **P0 冻结基线包**,不是最终完整契约。 + +- P0-1 至 P0-6 已完成规则、交接件和样例同步。 +- P1/P2 已重新评估,但尚未修改生产规则;它们仍是 OPEN 风险。 +- 开发可以先完成 P0 稳定骨架和已冻结行为,但 P1 影响的字段、路由和解阻流程不得自行补规则或猜测。 +- 如果本包中的开发 MD/Excel 与 AI runtime 输出发生差异,必须 fail closed,停止该事件建卡并反馈契约问题。 + +## 不同开发角色拿什么 + +### Adapter / Frontend 必读 + +1. `README_交付说明.md`:交付边界和暂缓项。 +2. `01_Adapter_Frontend/任务卡前端字段变更与路由说明_3.0_to_current.md`:P0 数据流、42 路由、人工复核、Parent split、fail-closed 规则。 +3. `01_Adapter_Frontend/任务卡前端字段变更说明_3.0_to_当前版_2026-07-10.xlsx`:字段、42 条三元组、迁移、非法组合和验收查表件。文件名保留 7 月 10 日,内部 P0 基线和最后验证时间为 7 月 11 日。 +4. `03_P0_Acceptance/`:P0 executable fixtures 与轻量 validator。 +5. `04_Known_Issues_非契约/prompt_skill_p1_p2_reassessment_2026-07-11.md`:当前 12 个 P1、8 个 P2;只用于识别暂缓范围,不是实现 Schema 的规则源。 + +### AI Runtime / Agent 部署开发 + +1. `02_AI_Runtime/main_agent_prompt.md` +2. `02_AI_Runtime/booking-desk-event.skill` +3. `02_AI_Runtime/核心契约_展开阅读/`:从 `.skill` 中额外展开的 5 份核心契约,方便 adapter/frontend 直接评审;内容仍以同版 `.skill` 为准。 + +这两个文件必须作为同一版本发布。`.skill` 已包含 SKILL 和全部 production references,但不包含 Main Prompt。 + +## 当前可以先实现的 P0 范围 + +- Gateway 在读取正文、附件、OCR、历史、系统上下文或调用 Skill 前校验 `source_message.source_message_id`。 +- 缺 ID 时只返回 `infrastructure_input_error + missing_source_message_id`,材料读取与 Skill 调用均为 0。 +- S10/S99 使用两个完整结构,并以 `route_code` 判别;S10 的 `manual_review=null`,S99 必须是完整 `main_agent_entry_review`。 +- 业务邮件按 `message_events[]` 逐 event 处理,不能在邮件根只派生一个业务三元组。 +- type-known review 保留原业务 task type/subtype;字段型复核使用 RFC 6901 pointer 和同卡 `field_overrides`。 +- Parent split、行级隔离、partial-allotment S10/unhandled/Fallback 边界按开发 MD/Excel 的 P0 基线实现。 +- 当前新生成的 `Note`、`Allotment Maintenance` 和 `update_allotment_control_block` 必须拒绝。 +- Agent payload、邮件原文、raw evidence、source ID 和审计字段保持只读;Preflight 通过前不得写 PMS。 + +## 暂缓定型或必须 fail closed 的范围 + +以下内容属于未修复 P1/P2;开发不得自行发明字段或把某个实现当成最终契约: + +- 生产级 42 路由单一权威源与重叠判别优先级。 +- 完整 Main→Skill package schema,以及 `evidence_refs` 的真实索引校验。 +- public `case_candidates[]`、`extraction_warnings[]`、relationship、provenance 和 `event_role` schema。 +- Fallback route selection、pending/lock 等非字段型阻塞的同卡 resolution。 +- Fix Charge 的 add/update/cancel 与 `TBD` schema。 +- Update 与 Manual RateCode、Update 与 Trace 的窄边界。 +- Voucher 业务卡型明确但文件对象丢失的边界。 +- Rooming List reference 中残留的当前 Note producer;当前仍以“Note 不得新生成”为准。 +- 通用日期、HNM/Honeymoon、Rate Code 渠道、Trace 短日期、Amend Group Code 组合和 FIT/Group 判别。 + +完整证据和例子见 `04_Known_Issues_非契约/`。如果开发输入命中上述范围,应 feature flag、阻断或进入明确的契约错误流程,不得由 adapter 复刻 AI 业务判断。 + +## 42 路由的使用方式 + +- MD/Excel 中的 42 条路由是 **版本锁定的 P0 实现和验收基线**。 +- 它们当前完整存在于派生交接件中;项目已将“缺少生产级机器可读单一权威源”登记为 P1。 +- 因此可以据此开发当前 P0 版本,但不能宣称它已经是长期 SSOT;未来 P1 修复后需要重新同步生成交接包。 +- 任一 event 缺少明确判别字段、同时命中多条互斥路由,或与 runtime contract 不一致时,该 event 0 卡并报告 `adapter_contract_error`;siblings 继续独立处理。 + +## P0 样例怎么运行 + +在本交付目录执行: + +```bash +node 03_P0_Acceptance/validate_p0_contracts.mjs +``` + +当前预期:32 项检查通过,9 个非法负例按预期拒绝。 + +该 validator 只覆盖 P0 已冻结子集,不验证全部 42 路由、public root item schema、全部 relationship/provenance 或 P1/P2 领域问题。它通过不代表 P1/P2 已关闭。 + +## 权威和用途 + +| 文件组 | 用途 | 是否可作为生产规则源 | +| --- | --- | --- | +| `02_AI_Runtime/main_agent_prompt.md` + `booking-desk-event.skill` | 当前 AI runtime 行为和部署版本 | 是;两者必须同版 | +| `02_AI_Runtime/核心契约_展开阅读/` | 方便开发查阅的 00/02/03/04/90 核心 reference 副本 | 是同版 runtime 契约的展开副本;不得单独改写 | +| `01_Adapter_Frontend/*.md` / `*.xlsx` | P0 adapter/frontend 派生实现、字段和验收基线 | 否;不得反向覆盖 runtime contract | +| `03_P0_Acceptance/` | P0 executable examples | 否;只验证已覆盖断言 | +| `04_Known_Issues_非契约/` | OPEN 风险、影响和未来修改建议 | 否;不得据此直接发明 Schema | +| `CHECKSUMS.sha256` | 接收和发布前文件完整性 | 校验用途 | + +## 未放入本包的文件 + +- `归档.zip`:与 Main Prompt + `.skill` 内容重复,不放入最小开发包。 +- `high_risk_cases.md`:仍含与 P0 type-known review 不一致的旧 Fallback 期望,不能作为当前验收源。 +- 旧冲突审计、白话报告、`task_plan.md`、`findings.md`、`progress.md`、`.project-docs`:历史/过程证据,不是开发契约。 +- scratch builder、validator、PNG render 和 QA JSON:内部制作/验证材料,不交开发。 + +## 接收检查 + +1. 运行 `shasum -a 256 -c CHECKSUMS.sha256`。 +2. 确认 Main Prompt 和 `.skill` 同时存在。 +3. 打开 Excel,确认 8 个 sheet 和 42 条唯一路由。 +4. 运行 P0 validator。 +5. 开发前阅读 P1/P2 已知问题;命中暂缓范围时先反馈,不自行补规则。 diff --git a/docs/project/frontend-backend/README.md b/docs/project/frontend-backend/README.md index b733320..75bc537 100644 --- a/docs/project/frontend-backend/README.md +++ b/docs/project/frontend-backend/README.md @@ -19,13 +19,15 @@ | 来源 | 当前用途 | | --- | --- | -| `docs/import/20260708/任务卡前端展示字段表 3.0.xlsx` | 前端任务卡展示 / 编辑白名单。前端页面优先按该表决定哪些字段展示、哪些字段可编辑。 | +| `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更与路由说明_3.0_to_current.md` | 0711 P0 前端 / Adapter 路由说明。前端后续按 42 路由、S10/S99、type-known manual review 和 fail-closed 口径调整页面。 | +| `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更说明_3.0_to_当前版_2026-07-10.xlsx` | 最新前端任务卡展示 / 编辑白名单和三元组路由表。文件名保留 7 月 10 日,内部基线为 7 月 11 日。 | +| `docs/import/20260708/任务卡前端展示字段表 3.0.xlsx` | 历史前端字段白名单,已被 0711 P0 冻结基线承接。 | | `docs/import/20260706/任务卡展示编辑矩阵.xlsx` | 后端完整规则来源。用于后端校验、最终确认写入、OPERA 映射、展示条件和任务卡完整约束。 | | `docs/import/20260706/AI输出参数并集字典.xlsx` | AI 输出字段路径、字段含义、建议存储方式和索引参考。 | 约束说明: -- 最新前端 Excel 只作为展示 / 编辑白名单,不替代后端完整规则矩阵。 +- 最新前端 Excel 和 0711 路由说明只作为展示 / 编辑白名单、P0 路由和前后端验收基线,不替代后端完整规则矩阵。 - 后端不应因为前端白名单缺少字段而自动放宽必填、枚举、校验或 OPERA 映射规则。 - 如果前端白名单与后端完整矩阵冲突,应先记录到 `frontend-to-backend-api-requests.md` 的待确认问题,再由产品 / 后端 / 前端一起确认。 @@ -37,13 +39,16 @@ | SuperAgent MCP tools | `docs/project/integrations/superagent-mcp/README.md` | MCP 对外交付资料包,tools 字段语义应跟随 SuperAgent HTTP 对外总契约。 | | SuperAgent 任务结果入站接口 | `docs/project/requirements/M002-superagent-task-result-api-contract.md` | 阶段记录,用于理解 M002 接收 AI 结果的落地细节;如与总契约冲突,以总契约为准。 | | SuperAgent 查询上下文接口 1、2 | `docs/project/requirements/M002-ai-query-minimal-fields.md` | 阶段记录,用于理解接口 1、2 的最小字段实现;如与总契约冲突,以总契约为准。 | -| 订单任务主流程 | `docs/project/requirements/M002-order-task-workflow-v2.md` | 当前有效需求,M002 V1 只作为历史参考。 | +| 订单任务主流程 V3 | `docs/project/requirements/M002-order-task-workflow-v3.md` | 当前开发基线,基于 0711 P0 冻结基线,覆盖 S10/S99、42 路由、方案 C、type-known manual review 同卡解阻和 fail-closed。 | +| 订单任务主流程 V2 | `docs/project/requirements/M002-order-task-workflow-v2.md` | 已实现阶段记录,保留用于理解当前代码中的 S000/S999、订单任务流转和 OPERA 模拟骨架。 | | 后端 checkpoint | `docs/project/requirements/M002-backend-checkpoint-plan.md` | 阶段记录,用于理解后端拆分和验收。 | | 前端可用接口与待补接口 | `docs/project/frontend-backend/frontend-to-backend-api-requests.md` | 前后端协作清单,已区分可用、后置和历史候选路径,不替代后端权威契约。 | ## 5. 当前已明确后置事项 - 普通任务切换订单接口后置。 +- M002 V3 已确认采用 0711 P0:新数据迁移到结构化 `S10/S99`;旧 `S000/S999` 继续在任务列表可见;后端已完成 P0 fixtures 回归基线。 +- type-known manual review 已按同一张业务卡解阻,不再统一做成 Fallback;复核场景第一版支持确认当前订单归属,但普通任务任意切换订单仍后置。 - 任务详情 / 任务写操作是否需要显式 `hotel_id` 已确认后置;第一版先按 ID 定位,后续多酒店隔离 / 权限方案统一补齐。 - 邮件会话详情已返回 `html_body_sanitized` 和 `html_render_mode`;前端展示 HTML 时优先使用清洗字段,`html_body` 只作为原始内容兼容字段。 - 用户 / 权限底座后端 CP1 已完成;前端登录页、动态菜单、管理后台和业务审计 actor 全量迁移仍后置。 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 1852fc9..cfec430 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -168,6 +168,7 @@ POST /api/auth/logout ``` - `field_pointer` 必须是 RFC 6901 JSON Pointer,并且只能指向当前任务卡可编辑字段;后端会映射到矩阵 `field_path`。非法或只读字段会返回 `TASK_REVIEW_POINTER_INVALID`。 +- 0711 P0 的房型复核可传 `/extracted_fields/room_items/{index}/pms_room_type_code`;当前后端矩阵仍是扁平字段,响应会映射为 `field_path=extracted_fields.pms_room_type_code`。前端展示时保留原始 `field_pointer`,业务字段写入和确认 payload 以返回的 `field_path` 为准。 - 同一次请求不能重复提交同一字段;重复 `field_pointer` 或重复映射到同一 `field_path` 会返回 `TASK_REVIEW_POINTER_DUPLICATE`。 - `confirmed_order_id` 第一版必须等于当前任务的 `order_id`;如果前端需要选择其他订单,仍属于后续“复核场景订单归属选择”细化,不要复用普通任务切换订单能力。 - 解阻成功后返回 `task_status=READY`、`review_status=RESOLVED`、`review_resolution.field_overrides[]`、`confirmed_payload` 和两条 `opera_operations[]`。前端应刷新任务详情并显示 OPERA 模拟操作入口。 @@ -299,7 +300,7 @@ run_label: 可选调试标签 ## 7. 需要持续提醒的后置事项 - 普通任务切换订单接口继续后置。 -- M002 V3 的结构化 `S10/S99` 入站、42 路由枚举 / 稳定配置、`UNHANDLED_CURRENT_INTENT`、`adapter_contract_error` transition 最小落库、任务列表 / 订单时间线 / 任务详情 V3 路由字段和只读诊断块透出、type-known manual review 同卡解阻第一版均已完成。 +- M002 V3 的结构化 `S10/S99` 入站、42 路由枚举 / 稳定配置、`UNHANDLED_CURRENT_INTENT`、`adapter_contract_error` transition 最小落库、任务列表 / 订单时间线 / 任务详情 V3 路由字段和只读诊断块透出、type-known manual review 同卡解阻第一版、typed infrastructure error 和 P0 fixtures 回归基线均已完成。 - 系统管理后台 V1 已完成;后续若要做用户搜索更多筛选、批量操作、密码策略增强、MFA、登录设备管理,应单独开需求。 - 现有 Reservation / SourceMessage 业务接口的强制登录、强制权限和业务审计 actor 全量迁移仍后置。 - 真实 OPERA / OHIP 接入继续后置。 diff --git a/docs/project/frontend-backend/frontend-to-backend-api-requests.md b/docs/project/frontend-backend/frontend-to-backend-api-requests.md index 731686e..297e3e8 100644 --- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -315,7 +315,7 @@ POST /api/system/reservation/demo-data | `tasks[]` | 生成的任务 ID、任务类型、任务 subtype 和任务状态。 | | `entrypoints` | 可直接访问的任务列表、订单列表、订单详情、任务详情、邮件会话详情 URL。 | -第一版 seed 覆盖:队列阻塞、已完成 OPERA 模拟、OPERA 失败可重试、Fallback 人工复核、历史 Message Notification 只读任务、邮件会话完整 HTML / 附件 / 内联图片。旧 S000/S999 特殊只读任务可通过 SuperAgent 回调或后续专用夹具补充;0711 P0 新入口的 S10/S99 需要后端后续 checkpoint 补充结构化 fixture。 +第一版 seed 覆盖:队列阻塞、已完成 OPERA 模拟、OPERA 失败可重试、Fallback 人工复核、历史 Message Notification 只读任务、邮件会话完整 HTML / 附件 / 内联图片。旧 S000/S999 特殊只读任务可通过 SuperAgent 回调补充;0711 P0 新入口的 S10/S99 已纳入后端 P0 fixtures 回归测试参考。 ## 6.1 M002 V3 当前状态和后续待补能力 @@ -330,6 +330,7 @@ POST /api/system/reservation/demo-data | `adapter_contract_error` | 任务详情、错误提示 | 已完成第一版 | 命中 P1/P2 未闭合或路由冲突时,任务详情 `adapter_contract_errors[]` 返回稳定错误 code 和原始片段,不转成 Fallback。 | | type-known manual review 同卡解阻 | 任务详情复核 | 已完成第一版 | `manual_review` 不再全部等同 Fallback;已知业务卡型返回原业务卡信息、`review_status`、`review_resolution` 和可编辑 pointer 字段,解阻后进入 `READY`。 | | 复核场景订单归属确认 | 任务详情复核 | 已完成第一版 | 后端提供复核确认时的订单归属确认;当前第一版只能确认当前任务所属订单,后续如要选择其他订单需另行细化。 | +| P0 fixtures 回归基线 | 联调回归 | 已完成第一版 | 后端已将 0711 P0 fixtures 纳入测试参考;前端对 S10/S99、同卡复核和只读诊断块的展示应继续按本节稳定字段接入。 | 前端注意:不要把访问口令写入前端仓库、浏览器环境变量或构建产物;该接口只能由本地联调人员手动调用或由受控测试脚本调用。 @@ -524,6 +525,8 @@ Content-Type: application/json } ``` +0711 P0 房型复核说明:前端可以提交 `/extracted_fields/room_items/{index}/pms_room_type_code`,后端第一版会兼容映射到当前矩阵字段 `extracted_fields.pms_room_type_code`。响应里的 `review_resolution.field_overrides[].field_pointer` 保留前端原始 pointer,`field_path` 和 `confirmed_payload.field_values` 使用当前后端矩阵字段。 + 返回示例: ```json diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index dc8bf11..14668a2 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -29,7 +29,7 @@ - 业务前端页面展示邮件原文。 - OHIP / OPERA 或其他业务系统真实写操作。 - 普通任务切换订单接口。 -- M002 V3 type-known manual review 同卡解阻、`missing_fields[]` 到任务卡字段白名单映射、typed `infrastructure_input_error` 响应、复核场景订单归属确认、任务列表 / 详情完整透出 V3 路由字段。 +- M002 V3 字段矩阵从当前扁平结构整体迁移到 0711 P0 新结构。 - 现有业务接口强制登录和强制权限拦截。 - 业务审计 actor 全量迁移到当前登录用户。 - Debug EML 上传链路不属于生产普通业务页面能力,生产默认关闭;即使已有登录权限,也不要开放给普通用户。 diff --git a/docs/project/integrations/superagent-api-contract.md b/docs/project/integrations/superagent-api-contract.md index 8a76483..8f419c9 100644 --- a/docs/project/integrations/superagent-api-contract.md +++ b/docs/project/integrations/superagent-api-contract.md @@ -114,15 +114,16 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 - 后端会完整保存 AI 三元组、`route_code` 和系统处理分类;`S10/S99` 仍以只读源邮件通知卡展示,任务列表可见,订单列表不可见。 - 旧 `S000/S999` 数据继续兼容展示,语义上分别映射到 `S10/S99`。 -当前后端已完成 M002 V3 CP1-CP2: +当前后端已完成 M002 V3 CP1-CP6: - 已建立 42 条 P0 路由枚举 / 稳定配置。 - 已支持结构化 `S10/S99` 入站,创建只读 `SOURCE_MESSAGE_ONLY` 任务。 - 已支持 V3 业务根 `source_message + message_events[]` 的基础解析;可派生到现有任务模型的 event 会创建业务任务,无法派生、显式契约错误或基础 manual_review / parent split 结构不完整的 event 只落 `adapter_contract_error` transition,不创建业务任务。 - 已支持 `unhandled_current_intents[]` 最小落库:只写 `UNHANDLED_CURRENT_INTENT` transition,不创建业务任务,也不按 adapter 契约错误返回。 - 已在 `workflow_reservation_ai_transition` 保存 `route_code`、`system_process_category`、`adapter_error_code` 和 `adapter_error_message`。 +- 已支持 type-known manual review 同卡解阻、当前订单归属确认、P0 fixtures 回归测试和 V3 typed `infrastructure_input_error` 响应。 -尚未完成:type-known manual review 同卡解阻、`manual_review.missing_fields[]` 到任务卡可编辑字段白名单的完整映射校验、typed `infrastructure_input_error` 响应、复核场景订单归属确认、真实 OPERA / OHIP、普通任务切换订单、任务列表 / 详情接口完整透出所有 V3 路由字段。 +尚未完成:真实 OPERA / OHIP、普通任务切换订单、字段矩阵从当前扁平结构整体迁移到 0711 P0 新结构。 ## 4. 接口 1:查询订单上下文 @@ -481,7 +482,8 @@ S10 示例: "required": true, "notification_type": "source_message_review", "show_source_message": true, - "requires_user_decision": true + "requires_user_decision": true, + "visible_message": "未匹配到当前 Agent 支持的业务事件类型,请查看原邮件并决定是否需要回复或进行其他处理。" }, "manual_review": null } @@ -544,6 +546,20 @@ V3 字段说明: - 业务 event 能派生到稳定路由时,复用现有订单 / 任务 / 任务卡创建链路。 - event 判别字段不完整、显式携带 `contract_errors`、根 `missing_fields`、不完整 `manual_review` 或不完整 parent split 候选时,写入 `adapter_contract_error` transition,不创建订单和任务;同一邮件其他 sibling event 继续处理。 - `unhandled_current_intents[]` 写入 `UNHANDLED_CURRENT_INTENT` transition,不返回 `adapter_error_code`。 +- type-known manual review 第一版在同一业务任务卡解阻;当前字段矩阵仍使用扁平字段,`/extracted_fields/room_items/{index}/pms_room_type_code` 会兼容映射到 `extracted_fields.pms_room_type_code`。 + +`source_message.source_message_id` 缺失时返回 `HTTP 400`,响应体不使用通用错误包装: + +```json +{ + "result_type": "infrastructure_input_error", + "error_code": "missing_source_message_id", + "retryable": true, + "missing_fields": [ + "source_message.source_message_id" + ] +} +``` ### 8.4 V2 JSON 兼容请求体 @@ -755,6 +771,7 @@ V3 `adapter_contract_error` 响应中的 `items[]` 不会包含 `order_id` / `ta | `SYSTEM_HOTEL_AMBIGUOUS` | 409 | 单酒店阶段平台酒店表存在多家 `ACTIVE` 酒店 | | `HOTEL_ACCESS_DENIED` | 403 | 显式传入的 `hotel_id` 与系统酒店或当前用户授权酒店不一致 | | `SOURCE_MESSAGE_NOT_FOUND` | 404 | 任务结果通知或会话锚点引用的外部来源消息尚未写入 SourceMessage Inbox | +| `missing_source_message_id` | 400 | V3 请求缺少 `source_message.source_message_id`,响应体为 typed `infrastructure_input_error` | ## 10. HMAC 上线配置 diff --git a/docs/project/requirements/M002-order-task-workflow-v3.md b/docs/project/requirements/M002-order-task-workflow-v3.md index ce655d9..d8d6988 100644 --- a/docs/project/requirements/M002-order-task-workflow-v3.md +++ b/docs/project/requirements/M002-order-task-workflow-v3.md @@ -38,7 +38,7 @@ V3 以以下资料和决策为输入: - M002 V3 正式采用 0711 P0 基线。 - 旧数据 `S000/S999` 继续在任务列表可见;新数据迁移为 `S10/S99`。 - `S10/S99` 继续复用隐藏技术订单 + 任务列表只读卡,不进入订单列表和订单执行队列。 -- 缺少 `source_message.source_message_id` 时,目标契约采用 `HTTP 400 + infrastructure_input_error + retryable=true` 的技术错误响应;当前 CP1-CP2 后端暂返回标准错误包装,typed 响应仍在后续 checkpoint。 +- 缺少 `source_message.source_message_id` 时,后端已按 `HTTP 400 + infrastructure_input_error + retryable=true` 的技术错误响应返回,不创建 SourceMessage、AI transition、订单、任务或通知卡。 - 内部任务模型采用“方案 C”:完整保存 AI 三元组,系统处理分类和前端展示分类单独维护。 - type-known manual review 使用同一张业务卡复核解阻,不生成第二张 normal task。 - 第一版保存全部 42 条路由 / 枚举,先支持保存和列表展示。 @@ -91,7 +91,7 @@ Gateway 必须在读取正文、附件、OCR、历史、系统上下文或调用 } ``` -当前 CP1-CP2 实现说明:后端已识别缺失并返回 `HTTP 400`,但响应体仍使用系统标准错误包装,尚未切换为上方 typed `infrastructure_input_error` 结构。 +当前 CP6 实现说明:后端已识别 V3 `source_message.source_message_id` 缺失,并返回上方 typed `infrastructure_input_error` 结构。 处理要求: @@ -266,7 +266,7 @@ Agent payload 不可变。本系统在同一张卡上维护复核状态: "field_overrides": [ { "field_pointer": "/extracted_fields/room_items/0/pms_room_type_code", - "field_path": "extracted_fields.room_items[].pms_room_type_code", + "field_path": "extracted_fields.pms_room_type_code", "value": "SU1" } ], @@ -288,6 +288,8 @@ Agent payload 不可变。本系统在同一张卡上维护复核状态: - 订单归属确认可作为复核解阻的一部分保存;当前第一版只允许确认当前任务所属订单,不开放普通任务随意切换订单能力。 - 全部缺失字段、订单归属、目录值和依赖校验通过后,才进入 Preflight / READY。 +当前 CP6 兼容说明:0711 P0 fixtures 中 `room_items[]` 已经是新结构,但后端第一版字段矩阵仍使用 `extracted_fields.room_type`、`extracted_fields.room_quantity`、`extracted_fields.pms_room_type_code` 等扁平字段。入站 payload 保留原始 `room_items[]`;后端在字段读取、展示、校验和确认 payload 组装时,从第一条 `room_items[]` 做扁平字段别名解析。解阻接口接受 `/extracted_fields/room_items/{index}/pms_room_type_code`,响应中的矩阵 `field_path` 仍返回当前后端字段 `extracted_fields.pms_room_type_code`。后续字段矩阵整体迁移时再取消这层兼容。 + ### 9.3 后端第一版接口 ```text @@ -364,7 +366,7 @@ V3 建议拆成以下 checkpoint,避免一次性重构过大: | M002-V3-CP3 | 路由持久化 | 已完成第一版:已保存 AI 原始三元组、route_code、system_process_category、unhandled_current_intents 和 adapter_contract_error | | M002-V3-CP4 | 列表 / 详情展示 | 已完成第一版:任务列表、订单任务时间线和任务详情透出 V3 路由字段;任务详情支持 S10/S99 入口通知结构、unhandled intent 展示块和 adapter contract error 展示块 | | M002-V3-CP5 | 同卡复核解阻 | 已完成第一版:支持 review_status、review_resolution.field_overrides[]、复核场景订单归属确认、JSON Pointer 校验和 READY 流转 | -| M002-V3-CP6 | P0 fixtures 回归 | 引入 0711 P0 fixtures / validator 作为后端适配测试参考,补充项目级测试 | +| 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 固化,不作为任务结果回调直接建任务 | ## 13. 明确不做 @@ -380,7 +382,7 @@ V3 P0 不做以下事项: ## 14. 当前代码现状提醒 -截至 M002 V3 CP5 落地后,当前后端已经实现: +截至 M002 V3 CP6 落地后,当前后端已经实现: - `S000/S999` 文本结果兼容处理。 - 结构化 `S10/S99` 入站处理,复用 `SOURCE_MESSAGE_ONLY` 只读特殊任务。 @@ -393,10 +395,11 @@ V3 P0 不做以下事项: - 业务任务详情按同一 AI 批次透出 `adapter_contract_errors[]` 与 `unhandled_intents[]` 只读展示块。 - type-known manual review 创建在原业务任务卡上,任务详情返回 `review_status`、`review_resolution` 和 `manual_review`。 - `POST /api/reservation/tasks/{taskId}/manual-review-resolutions` 支持字段修正、当前订单归属确认、JSON Pointer 到可编辑字段校验、READY 流转、confirmed payload 写入、两条 OPERA 模拟操作创建和审计记录。 +- `source_message.source_message_id` 缺失时按 V3 typed `infrastructure_input_error` 结构响应。 +- 0711 P0 fixtures 已纳入后端回归测试参考,并补齐 S10/S99 严格契约、P0 type-known manual review 解阻、candidate_gate reference 和当前扁平字段矩阵兼容。 - 订单 / 任务列表、任务详情、草稿保存、最终确认、OPERA 模拟骨架和审计列表。 - SuperAgent 查询上下文接口 1、2,以及邮件会话相关查询。 仍需后续 checkpoint 实现: -- `source_message.source_message_id` 缺失时按 V3 typed `infrastructure_input_error` 结构响应。 -- 真实 OPERA / OHIP、普通任务任意切换订单、P0 fixtures / validator 全量回归。 +- 真实 OPERA / OHIP、普通任务任意切换订单、字段矩阵从当前扁平结构整体迁移到 0711 P0 新结构。 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 54cfd7b..448c86c 100644 --- a/docs/project/requirements/M002-superagent-task-result-api-contract.md +++ b/docs/project/requirements/M002-superagent-task-result-api-contract.md @@ -10,7 +10,7 @@ | --- | --- | | 文档版本 | 0.5 | | 日期 | 2026-07-11 | -| 状态 | V2 兼容 + M002 V3 CP1-CP2 入站解析基线;完整 V3 展示和同卡复核仍看 `M002-order-task-workflow-v3.md` 后续 checkpoint | +| 状态 | V2 兼容 + M002 V3 CP1-CP6 入站解析、同卡复核和 P0 fixtures 回归基线;后续业务流程以 `M002-order-task-workflow-v3.md` 为准 | | 适用范围 | SuperAgent / Main Agent 调用本系统提交 AI 任务结果 | | 主要读者 | 后端、SuperAgent 对接方、测试、后续协作 agent | @@ -20,7 +20,7 @@ 本文承接 `M002-order-task-workflow-v2.md`,只定义本系统入站接口、鉴权、幂等、请求响应和技术校验边界,不定义 SuperAgent 内部 prompt、Skill 实现、OPERA 真实接口或前端展示细节。 -2026-07-11 后,M002 后续开发基线已迁移到 `M002-order-task-workflow-v3.md`。当前后端已完成 CP1-CP2:结构化 `S10/S99` 入站、V3 业务根基础解析、42 路由枚举 / 稳定配置、`UNHANDLED_CURRENT_INTENT` 和 route 相关字段最小落库。旧 `S000/S999` 和 `ai_task_results[]` 仍作为兼容路径保留。对外联调以 `docs/project/integrations/superagent-api-contract.md` 为准。完整 type-known manual review 解阻、`missing_fields[]` 到任务卡字段白名单映射和 typed `infrastructure_input_error` 响应仍在后续 checkpoint。 +2026-07-11 后,M002 后续开发基线已迁移到 `M002-order-task-workflow-v3.md`。当前后端已完成 CP1-CP6:结构化 `S10/S99` 入站、V3 业务根基础解析、42 路由枚举 / 稳定配置、`UNHANDLED_CURRENT_INTENT` 和 route 相关字段最小落库、type-known manual review 同卡解阻第一版、typed `infrastructure_input_error` 响应和 P0 fixtures 回归基线。旧 `S000/S999` 和 `ai_task_results[]` 仍作为兼容路径保留。对外联调以 `docs/project/integrations/superagent-api-contract.md` 为准。 ## 2. 接口概览 @@ -404,7 +404,7 @@ S000 / S999 文本结果创建成功时,同样返回 `201 Created`。这类结 } ``` -前端通过任务详情查看该类任务时,字段列表和 OPERA 操作列表为空,按钮应全部只读;`source_message_only_result` 会返回 `entry_result_code`、`entry_result_meaning`、`entry_result_description`、`entry_result_source_message_id` 和 `raw_answer`。 +前端通过任务详情查看该类任务时,字段列表和 OPERA 操作列表为空,按钮应全部只读;`source_message_only_result` 会返回 `entry_result_code`、`entry_result_meaning`、`entry_result_description`、`entry_result_source_message_id`、`result_type`、`route_code`、`agent_assessment`、`notification`、`manual_review` 和 `raw_answer`。 ### 8.2 幂等重放 diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/result/SuperAgentTaskResultInfrastructureInputErrorResponse.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/result/SuperAgentTaskResultInfrastructureInputErrorResponse.java new file mode 100644 index 0000000..7573680 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/result/SuperAgentTaskResultInfrastructureInputErrorResponse.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * SuperAgent V3 基础设施输入错误响应。用于 source_message.source_message_id 缺失等调用层错误, + * 不使用通用错误包装,保持 0711 P0 契约的扁平结构。 + */ +public record SuperAgentTaskResultInfrastructureInputErrorResponse( + @JsonProperty("result_type") + String resultType, + @JsonProperty("error_code") + String errorCode, + Boolean retryable, + @JsonProperty("missing_fields") + List missingFields +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultControllerAdvice.java index 8de7623..82bad71 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultControllerAdvice.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultControllerAdvice.java @@ -1,6 +1,7 @@ package cn.nianxx.thhotel.integrations.ai.superagent.control; import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentTaskResultErrorResponse; +import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentTaskResultInfrastructureInputErrorResponse; import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultException; import cn.nianxx.thhotel.platform.hotel.service.HotelContextException; import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationAiTaskIntakeException; @@ -29,8 +30,16 @@ public class SuperAgentTaskResultControllerAdvice { * 处理 Reservation 接收阶段的 SourceMessage、幂等和 AI item 技术校验异常。 */ @ExceptionHandler(ReservationAiTaskIntakeException.class) - public ResponseEntity handleIntakeException( + public ResponseEntity handleIntakeException( ReservationAiTaskIntakeException exception) { + if ("MISSING_SOURCE_MESSAGE_ID".equals(exception.getErrorCode())) { + return ResponseEntity.status(exception.getStatus()) + .body(new SuperAgentTaskResultInfrastructureInputErrorResponse( + "infrastructure_input_error", + "missing_source_message_id", + true, + List.of("source_message.source_message_id"))); + } return ResponseEntity.status(exception.getStatus()) .body(error(exception.getErrorCode(), exception.getMessage())); } 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 71e13e1..e3479d8 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 @@ -108,6 +108,12 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta requestBody); } JsonNode root = parseJson(rawBody); + if (isV3SourceMessageIdentityMissing(root)) { + throw infrastructureInputError(); + } + if (isMissingSourceMessageInfrastructureInputError(root)) { + throw infrastructureInputError(); + } if (isInfrastructureInputError(root)) { throw error(HttpStatus.BAD_REQUEST, "INFRASTRUCTURE_INPUT_ERROR", "SuperAgent 返回基础设施输入错误。"); } @@ -334,18 +340,62 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta * 校验结构化 S10/S99 的最小形态,避免把联合值或空复核误收为正常通知。 */ private void validateV3SourceMessageNotificationShape(JsonNode root, ReservationAiRouteDefinition route) { + if (!"main_agent_outcome".equals(textAt(root, "handler_type")) + || !AiResultType.SOURCE_MESSAGE_REVIEW_NOTIFICATION.code().equals(textAt(root, "result_type")) + || !"current".equals(textAt(root, "current_or_history"))) { + throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10/S99 根结构字段无效。"); + } + JsonNode assessment = root.path("agent_assessment"); + JsonNode notification = root.path("notification"); + if (!"none".equals(textAt(assessment, "automation_action")) + || !isBooleanTrue(notification.path("required")) + || !"source_message_review".equals(textAt(notification, "notification_type")) + || !isBooleanTrue(notification.path("show_source_message")) + || !isBooleanTrue(notification.path("requires_user_decision")) + || trimToNull(textAt(notification, "visible_message")) == null) { + throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10/S99 通知结构无效。"); + } JsonNode manualReview = root.get("manual_review"); - if (route == ReservationAiRouteDefinition.SOURCE_MESSAGE_S10 - && manualReview != null - && !manualReview.isNull()) { - throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10 manual_review 必须为空。"); + if (route == ReservationAiRouteDefinition.SOURCE_MESSAGE_S10) { + if (!"no_booking_action_detected".equals(textAt(assessment, "status")) + || !"no_booking_action_detected".equals(textAt(assessment, "reason_code")) + || (manualReview != null && !manualReview.isNull())) { + throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10 结构不符合 P0 契约。"); + } + return; } - if (route == ReservationAiRouteDefinition.SOURCE_MESSAGE_S99 - && (manualReview == null || manualReview.isNull() || !manualReview.isObject())) { - throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S99 manual_review 必须为入口复核对象。"); + if (!"material_package_unavailable".equals(textAt(assessment, "status")) + || !"material_package_unavailable".equals(textAt(assessment, "reason_code")) + || manualReview == null + || manualReview.isNull() + || !manualReview.isObject() + || inspectV3MainAgentReviewIssue(manualReview) != null + || !"material_package_unavailable".equals(textAt(manualReview, "reason_code"))) { + throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S99 结构不符合 P0 契约。"); } } + /** + * 校验 S99 main_agent_entry_review 的 P0 九字段结构。 + */ + private V3EventContractIssue inspectV3MainAgentReviewIssue(JsonNode manualReview) { + if (!manualReview.isObject()) { + return new V3EventContractIssue("MAIN_AGENT_REVIEW_INVALID", "manual_review 必须是对象。"); + } + if (!"main_agent_entry_review".equals(textAt(manualReview, "review_record_type")) + || trimToNull(textAt(manualReview, "reason_code")) == null + || trimToNull(textAt(manualReview, "visible_reason")) == null + || !manualReview.path("known_fields").isObject() + || !manualReview.path("missing_fields").isArray() + || !manualReview.path("blocking_points").isArray() + || !manualReview.path("conflicting_points").isArray() + || !manualReview.path("suggested_human_actions").isArray() + || !manualReview.path("evidence_to_check").isArray()) { + return new V3EventContractIssue("MAIN_AGENT_REVIEW_INCOMPLETE", "manual_review 九字段不完整。"); + } + return null; + } + /** * 将 V3 业务 event 转为旧任务结果 item 形态,复用现有订单、任务和任务卡创建逻辑。 */ @@ -1320,6 +1370,13 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta return node.get(fieldName).asBoolean(false); } + /** + * 判断 JSON 节点是否为布尔 true。S10/S99 P0 契约不接受字符串 true 这类宽松输入。 + */ + private boolean isBooleanTrue(JsonNode node) { + return node != null && node.isBoolean() && node.booleanValue(); + } + /** * 将 JSON 节点序列化为字符串,缺失时返回 null。 */ @@ -1400,6 +1457,42 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta return "infrastructure_input_error".equals(textAt(root, "result_type")); } + /** + * 判断是否为 P0 明确约定的缺失 source_message_id 基础设施错误;其他 infra 错误不得被改写成该错误。 + */ + private boolean isMissingSourceMessageInfrastructureInputError(JsonNode root) { + return isInfrastructureInputError(root) + && "missing_source_message_id".equals(textAt(root, "error_code")); + } + + /** + * V3 或入口网关形态输入必须先有非空 source_message_id;纯 V2 兼容结构不受该预检查影响。 + */ + private boolean isV3SourceMessageIdentityMissing(JsonNode root) { + if (root == null || !root.has("source_message") || !isV3OrGatewayInputShape(root)) { + return false; + } + return trimToNull(textAt(root.path("source_message"), "source_message_id")) == null; + } + + /** + * 构建 0711 P0 基础设施输入错误,ControllerAdvice 会转换为扁平 typed 响应。 + */ + private ReservationAiTaskIntakeException infrastructureInputError() { + return error(HttpStatus.BAD_REQUEST, "MISSING_SOURCE_MESSAGE_ID", "source_message.source_message_id 缺失。"); + } + + /** + * 判断输入是否属于 V3 回调或 0711 P0 入口网关形态,避免误伤旧 V2 兼容 JSON。 + */ + private boolean isV3OrGatewayInputShape(JsonNode root) { + return root.path("message_events").isArray() + || root.path("unhandled_current_intents").isArray() + || root.path("candidate_events").isArray() + || root.has("body_current") + || isV3SourceMessageNotification(root); + } + /** * 判断是否为 V3 结构化 S10/S99 来源邮件通知根。 */ @@ -1426,7 +1519,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta JsonNode sourceMessageNode = root.path("source_message"); String externalSourceMessageId = trimToNull(textAt(sourceMessageNode, "source_message_id")); if (externalSourceMessageId == null) { - throw error(HttpStatus.BAD_REQUEST, "INFRASTRUCTURE_INPUT_ERROR", "source_message.source_message_id 缺失。"); + throw infrastructureInputError(); } validateLength(externalSourceMessageId, "source_message.source_message_id", LENGTH_256); String hotelId = requireText(defaultHotelId, "default_hotel_id", LENGTH_64); diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java index f04ae46..60c58b2 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java @@ -898,9 +898,23 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl if (!collapsed.equals(candidates.get(0))) { candidates.add(collapsed); } + String legacyAlias = p0ReviewPointerLegacyAlias(collapsed); + if (legacyAlias != null && !candidates.contains(legacyAlias)) { + candidates.add(legacyAlias); + } return candidates; } + /** + * 兼容 0711 P0 fixtures 中已迁移为数组结构、但当前后端矩阵仍是扁平字段的复核 pointer。 + */ + private String p0ReviewPointerLegacyAlias(String collapsedFieldPath) { + if ("extracted_fields.room_items[].pms_room_type_code".equals(collapsedFieldPath)) { + return "extracted_fields.pms_room_type_code"; + } + return null; + } + /** * 解析 JSON Pointer token,拒绝非法的 ~ 转义。 */ @@ -1717,7 +1731,33 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl if (editedValues.containsKey(fieldPath)) { return editedValues.get(fieldPath); } - return valueAt(aiPayload, fieldPath); + Object value = valueAt(aiPayload, fieldPath); + if (value != null) { + return value; + } + for (String legacyPath : p0LegacyValuePathCandidates(fieldPath)) { + Object legacyValue = valueAt(aiPayload, legacyPath); + if (legacyValue != null) { + return legacyValue; + } + } + return null; + } + + /** + * 0711 P0 已将房型字段迁移到 room_items[];当前矩阵仍使用扁平字段,读取时做最小别名兼容。 + */ + private List p0LegacyValuePathCandidates(String fieldPath) { + String safeFieldPath = fieldPath == null ? "" : fieldPath; + return switch (safeFieldPath) { + case "extracted_fields.room_type" -> List.of( + "extracted_fields.room_items.0.room_type_normalized", + "extracted_fields.room_items.0.room_type_raw"); + case "extracted_fields.room_quantity" -> List.of("extracted_fields.room_items.0.room_quantity"); + case "extracted_fields.pms_room_type_code" -> List.of( + "extracted_fields.room_items.0.pms_room_type_code"); + default -> List.of(); + }; } /** @@ -1880,7 +1920,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl definition.operaWriteParticipation(), definition.operaParameterMapping(), definition.notes(), - valueAt(aiPayload, definition.fieldPath())); + valueForField(aiPayload, Map.of(), definition.fieldPath())); } /** @@ -1904,7 +1944,9 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl JsonNode current = root; for (String rawPart : fieldPath.split("\\.")) { String part = rawPart.replace("[]", ""); - current = current.path(part); + current = current.isArray() && isNumericToken(part) + ? current.path(Integer.parseInt(part)) + : current.path(part); if (current.isMissingNode() || current.isNull()) { return null; } 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 2987e11..57c4fec 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 @@ -638,14 +638,15 @@ class SuperAgentTaskResultControllerTest { "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 - }, - "manual_review": null - } + "notification": { + "required": true, + "notification_type": "source_message_review", + "show_source_message": true, + "requires_user_decision": true, + "visible_message": "未匹配到当前 Agent 支持的业务事件类型,请查看原邮件并决定是否需要回复或进行其他处理。" + }, + "manual_review": null + } """; MvcResult result = mockMvc.perform(signedPost(body, "nonce-v3-s10-entry-result-001")) @@ -695,23 +696,31 @@ class SuperAgentTaskResultControllerTest { "route_code": "S99", "handler_type": "main_agent_outcome", "result_type": "source_message_review_notification", - "current_or_history": "current", - "agent_assessment": { - "status": "insufficient_business_material", - "reason_code": "cannot_form_business_material_package", - "automation_action": "none" - }, - "notification": { - "required": true, - "notification_type": "source_message_review", - "show_source_message": true, - "requires_user_decision": true - }, - "manual_review": { - "reason_code": "cannot_form_business_material_package", - "review_notes": "需要人工查看原邮件。" - } - } + "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": "当前输入不足,无法判断是否匹配当前 Agent 支持的业务事件类型,请查看原邮件并决定后续处理。" + }, + "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": ["review_source_message"], + "evidence_to_check": ["source_message"], + "known_fields": {} + } + } """; MvcResult result = mockMvc.perform(signedPost(body, "nonce-v3-s99-entry-result-001")) @@ -761,23 +770,31 @@ class SuperAgentTaskResultControllerTest { "route_code": "S99", "handler_type": "main_agent_outcome", "result_type": "source_message_review_notification", - "current_or_history": "current", - "agent_assessment": { - "status": "insufficient_business_material", - "reason_code": "cannot_form_business_material_package", - "automation_action": "none" - }, - "notification": { - "required": true, - "notification_type": "source_message_review", - "show_source_message": true, - "requires_user_decision": true - }, - "manual_review": { - "reason_code": "cannot_form_business_material_package", - "review_notes": "需要人工查看原邮件。" - } - } + "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": "当前输入不足,无法判断是否匹配当前 Agent 支持的业务事件类型,请查看原邮件并决定后续处理。" + }, + "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": ["review_source_message"], + "evidence_to_check": ["source_message"], + "known_fields": {} + } + } """; MvcResult result = mockMvc.perform(signedPost(body, "nonce-v3-s99-frontend-visible-001")) @@ -810,11 +827,11 @@ class SuperAgentTaskResultControllerTest { .value("source_message_review_notification")) .andExpect(jsonPath("$.source_message_only_result.route_code").value("S99")) .andExpect(jsonPath("$.source_message_only_result.agent_assessment.status") - .value("insufficient_business_material")) + .value("material_package_unavailable")) .andExpect(jsonPath("$.source_message_only_result.notification.notification_type") .value("source_message_review")) .andExpect(jsonPath("$.source_message_only_result.manual_review.reason_code") - .value("cannot_form_business_material_package")); + .value("material_package_unavailable")); } @Test diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java new file mode 100644 index 0000000..082c93a --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java @@ -0,0 +1,459 @@ +package cn.nianxx.thhotel.workflows.reservation.control; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import cn.nianxx.thhotel.ThHotelApplication; +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; +import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.HexFormat; +import java.util.List; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "superagent.task-result.hmac-secret=test-superagent-secret", + "superagent.task-result.clock-skew-seconds=300", + "superagent.task-result.nonce-ttl-seconds=600", + "superagent.task-result.max-body-bytes=20000" + }) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class SuperAgentTaskResultP0FixtureRegressionTest { + + private static final String ENDPOINT = "/api/integrations/superagent/task-results"; + private static final String CLIENT_ID = "superagent-test-client"; + private static final String SECRET = "test-superagent-secret"; + private static final Path FIXTURES_DIR = Path.of( + "..", + "docs", + "import", + "20260711", + "开发交付_P0冻结基线_2026-07-11", + "03_P0_Acceptance", + "fixtures"); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private SourceMessageCaptureService captureService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void shouldAcceptLegalS10AndS99MainOutcomeFixtures() throws Exception { + JsonNode cases = fixture("main_outcomes.json").path("cases"); + JsonNode s10 = caseValue(cases, "legal_s10_no_supported_match"); + JsonNode s99 = caseValue(cases, "legal_s99_material_unavailable"); + String s10ExternalId = "p0-main-outcome-s10-001"; + String s99ExternalId = "p0-main-outcome-s99-001"; + captureSourceMessage(s10ExternalId); + captureSourceMessage(s99ExternalId); + + MvcResult s10Result = mockMvc.perform(signedPost(withSourceMessageId(s10, s10ExternalId), "nonce-p0-s10-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.items[0].route_code").value("S10")) + .andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION")) + .andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY")) + .andReturn(); + MvcResult s99Result = mockMvc.perform(signedPost(withSourceMessageId(s99, s99ExternalId), "nonce-p0-s99-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.items[0].route_code").value("S99")) + .andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION")) + .andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY")) + .andReturn(); + + assertSourceMessageOnlyTaskCount(s10Result, "S10"); + assertSourceMessageOnlyTaskCount(s99Result, "S99"); + } + + @Test + void shouldRejectIllegalMainOutcomeFixturesWithoutCreatingTasks() throws Exception { + JsonNode cases = fixture("main_outcomes.json").path("cases"); + for (String caseId : List.of( + "illegal_s10_with_review_object", + "illegal_s99_with_null_review", + "illegal_union_template_value")) { + JsonNode value = caseValue(cases, caseId); + String externalId = "p0-" + caseId.replace('_', '-'); + SourceMessageCaptureResult source = captureSourceMessage(externalId); + + mockMvc.perform(signedPost(withSourceMessageId(value, externalId), "nonce-" + externalId)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("ADAPTER_CONTRACT_ERROR")) + .andExpect(content().string(notContainsSecret())); + + Long taskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + assertThat(taskCount).isZero(); + } + } + + @Test + void shouldReturnTypedInfrastructureInputErrorForMissingSourceIdentityFixtures() throws Exception { + JsonNode cases = fixture("source_identity_errors.json").path("cases"); + for (JsonNode fixtureCase : cases) { + if (fixtureCase.path("expected_error").isNull()) { + continue; + } + String caseId = fixtureCase.path("case_id").asText(); + String body = objectMapper.writeValueAsString(fixtureCase.path("input")); + + mockMvc.perform(signedPost(body, "nonce-" + caseId)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.result_type").value("infrastructure_input_error")) + .andExpect(jsonPath("$.error_code").value("missing_source_message_id")) + .andExpect(jsonPath("$.retryable").value(true)) + .andExpect(jsonPath("$.missing_fields[0]").value("source_message.source_message_id")) + .andExpect(jsonPath("$.route_code").doesNotExist()) + .andExpect(jsonPath("$.message_events").doesNotExist()); + } + } + + @Test + void shouldNotRewriteOtherInfrastructureInputErrorsToMissingSourceMessageId() throws Exception { + mockMvc.perform(signedPost(""" + { + "result_type": "infrastructure_input_error", + "error_code": "object_storage_unavailable", + "retryable": true, + "missing_fields": [] + } + """, "nonce-p0-infra-other-001")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("INFRASTRUCTURE_INPUT_ERROR")) + .andExpect(jsonPath("$.result_type").doesNotExist()); + } + + @Test + void shouldLetNonBlankSourceIdentityPassP0GuardWithoutTypedMissingError() throws Exception { + JsonNode validInput = caseItem( + fixture("source_identity_errors.json").path("cases"), + "nonblank_source_message_id_passes_guard").path("input"); + + mockMvc.perform(signedPost(objectMapper.writeValueAsString(validInput), "nonce-p0-source-valid-guard-001")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_REQUIRED")) + .andExpect(jsonPath("$.result_type").doesNotExist()); + } + + @Test + void shouldKeepCandidateGateFixtureAsP0RegressionReference() throws Exception { + JsonNode cases = fixture("candidate_gate.json").path("cases"); + + assertThat(caseIdsByValidity(cases, true)).containsExactly( + "valid_coarse_attachment_signal", + "valid_single_supported_direction", + "valid_main_no_active_signal_routes_s10", + "valid_skill_no_supported_event_routes_s10"); + assertThat(caseIdsByValidity(cases, false)).containsExactly( + "invalid_candidate_missing_target_hints", + "invalid_candidate_wrong_status", + "invalid_candidate_empty_possible_types", + "invalid_review_outcome_used_as_candidate", + "invalid_evidence_refs_string_array", + "invalid_target_hints_object"); + + JsonNode supportedSignal = caseItem(cases, "valid_coarse_attachment_signal") + .path("candidate_events").get(0); + assertThat(supportedSignal.path("classification_status").asText()).isEqualTo("coarse_supported_signal"); + assertThat(supportedSignal.path("possible_event_types")).hasSize(2); + assertThat(supportedSignal.path("evidence_refs").get(0).isObject()).isTrue(); + assertThat(caseItem(cases, "invalid_review_outcome_used_as_candidate") + .path("candidate_events").get(0).path("possible_event_types").get(0).asText()) + .isEqualTo("Need Manual Review"); + } + + @Test + void shouldCreateParentSplitTasksFromP0FixtureInEventOrder() throws Exception { + ObjectNode root = fixture("parent_split_two_children.json").deepCopy(); + String externalId = "p0-parent-split-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalId); + + MvcResult result = mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-parent-split-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(3)) + .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL")) + .andExpect(jsonPath("$.items[0].execution_order").value(1)) + .andExpect(jsonPath("$.items[1].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL")) + .andExpect(jsonPath("$.items[1].execution_order").value(1)) + .andExpect(jsonPath("$.items[2].route_code").value("R07_LINKED_PARENT_RELEASE_AFTER_CHILD_SPLIT_NORMAL")) + .andReturn(); + + String parentOrderId = com.jayway.jsonpath.JsonPath.read( + result.getResponse().getContentAsString(), + "$.items[2].order_id"); + Integer parentExecutionOrder = com.jayway.jsonpath.JsonPath.read( + result.getResponse().getContentAsString(), + "$.items[2].execution_order"); + assertThat(parentOrderId).isNotBlank(); + assertThat(parentExecutionOrder).isEqualTo(1); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND route_code IN ( + 'R02_NEW_GROUP_BLOCK_NORMAL', + 'R07_LINKED_PARENT_RELEASE_AFTER_CHILD_SPLIT_NORMAL' + ) + """, Long.class, source.inboxId()); + assertThat(transitionCount).isEqualTo(3L); + } + + @Test + void shouldCreateRowDerivedMainParentAndTraceTasksFromP0Fixture() throws Exception { + ObjectNode root = fixture("row_multiple_derived.json").deepCopy(); + String externalId = "p0-row-derived-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalId); + + mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-row-derived-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(3)) + .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL")) + .andExpect(jsonPath("$.items[1].route_code").value("R07_LINKED_PARENT_RELEASE_AFTER_CHILD_SPLIT_NORMAL")) + .andExpect(jsonPath("$.items[2].route_code").value("R13_EXTRA_BED_NORMAL")); + + Long taskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + assertThat(taskCount).isEqualTo(3L); + } + + @Test + void shouldPreserveMixedAllotmentUnhandledIntentAndRejectPartialAllotmentAsBusinessTask() throws Exception { + JsonNode fixture = fixture("allotment_scope.json"); + ObjectNode mixedOutput = fixture.path("mixed_partial_allotment").path("output").deepCopy(); + String externalId = "p0-allotment-mixed-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalId); + + mockMvc.perform(signedPost(withSourceMessageId(mixedOutput, externalId), "nonce-p0-allotment-mixed-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(2)) + .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL")) + .andExpect(jsonPath("$.items[1].route_code").value("R42_UNHANDLED_CURRENT_INTENT")) + .andExpect(jsonPath("$.items[1].task_id").doesNotExist()); + + Long unsupportedTaskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND ai_task_type IN ( + 'Allotment Maintenance', + 'Update Booking / Amendment', + 'Cancel Allotment' + ) + """, Long.class, source.inboxId()); + assertThat(unsupportedTaskCount).isZero(); + } + + @Test + void shouldCreateTypeKnownManualReviewAndResolveSameCardFromP0Fixture() throws Exception { + JsonNode manualReview = fixture("manual_review_resolution.json"); + ObjectNode event = manualReview.path("known_subtype_manual_review").path("event").deepCopy(); + String externalId = "p0-manual-review-same-card-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalId); + String body = businessRoot(externalId, event); + + MvcResult result = mockMvc.perform(signedPost(body, "nonce-p0-manual-review-create-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_REVIEW")) + .andExpect(jsonPath("$.items[0].system_task_type").value("NEW_BOOKING")) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read( + result.getResponse().getContentAsString(), + "$.items[0].task_id"); + String orderId = com.jayway.jsonpath.JsonPath.read( + result.getResponse().getContentAsString(), + "$.items[0].order_id"); + + mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "confirmed_order_id": "%s", + "field_overrides": [ + { + "field_pointer": "/extracted_fields/room_items/0/pms_room_type_code", + "value": "SU1" + } + ] + } + """.formatted(orderId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.task_status").value("READY")) + .andExpect(jsonPath("$.review_status").value("RESOLVED")) + .andExpect(jsonPath("$.review_resolution.field_overrides[0].field_pointer") + .value("/extracted_fields/room_items/0/pms_room_type_code")) + .andExpect(jsonPath("$.review_resolution.field_overrides[0].field_path") + .value("extracted_fields.pms_room_type_code")) + .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_type']") + .value("SUITE")) + .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_quantity']") + .value(2)) + .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.pms_room_type_code']") + .value("SU1")); + + Long sourceTaskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + String aiPayloadJson = jdbcTemplate.queryForObject(""" + SELECT ai_payload_json + FROM workflow_reservation_task_card + WHERE task_id = ? + """, String.class, Long.valueOf(taskId)); + JsonNode aiPayload = objectMapper.readTree(aiPayloadJson); + assertThat(sourceTaskCount).isEqualTo(1L); + assertThat(aiPayload.path("extracted_fields").path("room_items").get(0) + .path("pms_room_type_code").isNull()).isTrue(); + assertThat(aiPayload.path("extracted_fields").has("room_type")).isFalse(); + assertThat(aiPayloadJson).doesNotContain("\"pms_room_type_code\":\"SU1\""); + } + + private JsonNode fixture(String fileName) throws Exception { + return objectMapper.readTree(FIXTURES_DIR.resolve(fileName).toFile()); + } + + private JsonNode caseValue(JsonNode cases, String caseId) { + return caseItem(cases, caseId).path("value"); + } + + private JsonNode caseItem(JsonNode cases, String caseId) { + for (JsonNode item : cases) { + if (caseId.equals(item.path("case_id").asText())) { + return item; + } + } + throw new IllegalArgumentException("fixture case not found: " + caseId); + } + + private List caseIdsByValidity(JsonNode cases, boolean expectValid) { + List ids = new java.util.ArrayList<>(); + for (JsonNode item : cases) { + if (item.path("expect_valid").asBoolean(false) == expectValid) { + ids.add(item.path("case_id").asText()); + } + } + return ids; + } + + private String withSourceMessageId(JsonNode root, String externalId) throws Exception { + ObjectNode copy = root.deepCopy(); + ((ObjectNode) copy.path("source_message")).put("source_message_id", externalId); + return objectMapper.writeValueAsString(copy); + } + + private String businessRoot(String externalId, JsonNode event) throws Exception { + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode sourceMessage = root.putObject("source_message"); + sourceMessage.put("source_message_id", externalId); + sourceMessage.put("subject", "P0 fixture"); + sourceMessage.put("from", "agent@example.test"); + sourceMessage.putArray("cc"); + sourceMessage.put("received_at", "2026-07-11T10:00:00+08:00"); + sourceMessage.put("source_channel", "Email"); + ArrayNode events = root.putArray("message_events"); + events.add(event); + root.putArray("case_candidates"); + root.putArray("extraction_warnings"); + root.putArray("unhandled_current_intents"); + return objectMapper.writeValueAsString(root); + } + + private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) { + return captureService.capture(new CaptureSourceMessageCommand( + "HOTEL-TEST", + "AGENTBUS", + "EMAIL", + externalMessageId, + "thread-" + externalMessageId, + "frame-" + externalMessageId, + "session-m002-p0", + Instant.parse("2026-07-11T02:00:00Z"), + "agent@example.test", + "M002 P0 fixture", + "P0 fixture source message.", + "P0 fixture source message.", + "{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}", + "agentbus-outlook-v1", + List.of() + )); + } + + private void assertSourceMessageOnlyTaskCount(MvcResult result, String routeCode) throws Exception { + String taskId = com.jayway.jsonpath.JsonPath.read( + result.getResponse().getContentAsString(), + "$.items[0].task_id"); + Long taskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task task + JOIN workflow_reservation_ai_transition transition + ON transition.id = task.ai_transition_id + WHERE task.id = ? + AND transition.route_code = ? + AND task.queue_participation = 0 + """, Long.class, Long.valueOf(taskId), routeCode); + assertThat(taskCount).isEqualTo(1L); + } + + private MockHttpServletRequestBuilder signedPost(String body, String nonce) throws Exception { + String timestamp = Instant.now().toString(); + return post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID) + .header("X-TH-Hotel-SuperAgent-Timestamp", timestamp) + .header("X-TH-Hotel-SuperAgent-Nonce", nonce) + .header("X-TH-Hotel-SuperAgent-Signature", signature(body, nonce, timestamp)); + } + + private String signature(String body, String nonce, String timestamp) throws Exception { + String bodyHash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(body.getBytes(StandardCharsets.UTF_8))); + String canonical = "POST\n" + ENDPOINT + "\n" + timestamp + "\n" + nonce + "\n" + CLIENT_ID + "\n" + bodyHash; + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return "sha256=" + HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8))); + } + + private org.hamcrest.Matcher notContainsSecret() { + return org.hamcrest.Matchers.not(containsString(SECRET)); + } +}